diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 328e7406..16accd5f 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -65,7 +65,7 @@ export interface ProfileNotFoundError extends Error { * Uses expandPath() for consistent cross-platform path handling. * Returns empty object on error. */ -function loadSettingsFromFile(settingsPath: string): Record { +export function loadSettingsFromFile(settingsPath: string): Record { const expandedPath = expandPath(settingsPath); try { if (!fs.existsSync(expandedPath)) return {}; diff --git a/src/types/index.ts b/src/types/index.ts index 30fec139..648ae97f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,5 +46,5 @@ export type { } from './glmt'; // Utility types -export { ErrorCode, LogLevel } from './utils'; -export type { ColorName, TerminalInfo, Result } from './utils'; +export { LogLevel } from './utils'; +export type { ErrorCode, ColorName, TerminalInfo, Result } from './utils'; diff --git a/src/types/utils.ts b/src/types/utils.ts index a9320d53..51ccc6a7 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -3,7 +3,8 @@ */ // Re-export from error-codes for consistency -export { ERROR_CODES, ErrorCode, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export { ERROR_CODES, getErrorDocUrl, getErrorCategory } from '../utils/error-codes'; +export type { ErrorCode } from '../utils/error-codes'; /** * Log levels diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 5f1f9031..f281f7e6 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -14,9 +14,14 @@ export function error(message: string): never { * Path expansion (~ and env vars) */ export function expandPath(pathStr: string): string { + // Normalize separators first to handle mixed paths + pathStr = pathStr.replace(/\\/g, '/'); + // Handle tilde expansion - if (pathStr.startsWith('~/') || pathStr.startsWith('~\\')) { + if (pathStr.startsWith('~/')) { pathStr = path.join(os.homedir(), pathStr.slice(2)); + } else if (pathStr === '~') { + pathStr = os.homedir(); } // Expand environment variables (Windows and Unix) diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts new file mode 100644 index 00000000..fb29028d --- /dev/null +++ b/tests/unit/auth/profile-detector.test.ts @@ -0,0 +1,156 @@ +/** + * Unit tests for Profile Detector + */ +import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector'; +import * as unifiedConfigLoader from '../../../src/config/unified-config-loader'; + +describe('ProfileDetector', () => { + const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`); + + beforeEach(() => { + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('loadSettingsFromFile', () => { + it('should load settings from a valid JSON file', () => { + const settingsPath = path.join(tempDir, 'valid.settings.json'); + const settings = { env: { KEY: 'VALUE' } }; + fs.writeFileSync(settingsPath, JSON.stringify(settings)); + + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ KEY: 'VALUE' }); + }); + + it('should return empty object for non-existent file', () => { + const result = loadSettingsFromFile(path.join(tempDir, 'non-existent.json')); + expect(result).toEqual({}); + }); + + it('should return empty object for invalid JSON', () => { + const settingsPath = path.join(tempDir, 'invalid.json'); + fs.writeFileSync(settingsPath, 'invalid json'); + + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({}); + }); + + it('should handle tilde expansion correctly', () => { + const mockHome = path.join(tempDir, 'home'); + fs.mkdirSync(mockHome, { recursive: true }); + const settingsPath = '~/test.settings.json'; + const actualPath = path.join(mockHome, 'test.settings.json'); + fs.writeFileSync(actualPath, JSON.stringify({ env: { HOME_VAR: 'TRUE' } })); + + // Mock os.homedir + const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome); + + try { + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ HOME_VAR: 'TRUE' }); + } finally { + homedirSpy.mockRestore(); + } + }); + + it('should handle env var expansion correctly', () => { + const settingsPath = path.join(tempDir, '${TEST_VAR}.json'); + const actualPath = path.join(tempDir, 'actual.json'); + fs.writeFileSync(actualPath, JSON.stringify({ env: { ENV_VAR: 'EXPANDED' } })); + + process.env.TEST_VAR = 'actual'; + try { + const result = loadSettingsFromFile(settingsPath); + expect(result).toEqual({ ENV_VAR: 'EXPANDED' }); + } finally { + delete process.env.TEST_VAR; + } + }); + }); + + describe('detectProfileType', () => { + let detector: ProfileDetector; + + beforeEach(() => { + detector = new ProfileDetector(); + }); + + it('should detect CLIProxy profiles', () => { + const result = detector.detectProfileType('gemini'); + expect(result.type).toBe('cliproxy'); + expect(result.name).toBe('gemini'); + expect(result.provider).toBe('gemini'); + }); + + it('should detect settings-based profile from unified config', () => { + const settingsPath = path.join(tempDir, 'glm.settings.json'); + fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); + + const mockUnifiedConfig = { + version: 2, + profiles: { + glm: { settings: settingsPath, type: 'api' } + } + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + + try { + const result = detector.detectProfileType('glm'); + expect(result.type).toBe('settings'); + expect(result.name).toBe('glm'); + expect(result.env).toEqual({ ANTHROPIC_MODEL: 'glm-4' }); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should detect account-based profile from unified config', () => { + const mockUnifiedConfig = { + version: 2, + accounts: { + work: { created: '2025-01-01', last_used: '2025-01-02' } + } + }; + + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true); + const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(mockUnifiedConfig as any); + + try { + const result = detector.detectProfileType('work'); + expect(result.type).toBe('account'); + expect(result.name).toBe('work'); + expect(result.profile).toBeDefined(); + expect((result.profile as any).type).toBe('account'); + } finally { + isUnifiedModeSpy.mockRestore(); + loadUnifiedConfigSpy.mockRestore(); + } + }); + + it('should return null for unknown profile (throws error)', () => { + const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false); + // Mock readConfig/readProfiles to return empty + spyOn(fs, 'existsSync').mockReturnValue(false); + + try { + expect(() => detector.detectProfileType('unknown')).toThrow(/Profile not found/); + } finally { + isUnifiedModeSpy.mockRestore(); + } + }); + }); +}); diff --git a/tests/unit/commands/setup-command.test.ts b/tests/unit/commands/setup-command.test.ts new file mode 100644 index 00000000..e4bb37b4 --- /dev/null +++ b/tests/unit/commands/setup-command.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for setup-command.ts - isFirstTimeInstall() function + * + * Issue #195: GLM auth regression - isFirstTimeInstall() was ignoring legacy configs + */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// Create temp directory for test isolation +let testDir: string; + +beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-')); +}); + +afterEach(() => { + fs.rmSync(testDir, { recursive: true, force: true }); +}); + +// Helper to create config files +function createConfigYaml(content: string): void { + fs.writeFileSync(path.join(testDir, 'config.yaml'), content, 'utf8'); +} + +function createConfigJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'config.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +function createProfilesJson(content: object): void { + fs.writeFileSync(path.join(testDir, 'profiles.json'), JSON.stringify(content, null, 2), 'utf8'); +} + +describe('isFirstTimeInstall logic', () => { + describe('legacy config detection', () => { + it('should detect legacy config.json with profiles', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: { glm: '~/.ccs/glm.settings.json' } }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + + expect(hasLegacyProfiles).toBe(true); + }); + + it('should detect legacy profiles.json with accounts', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createProfilesJson({ profiles: { work: { path: '/some/path' } } }); + + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + const hasLegacyAccounts = legacyProfiles.profiles && Object.keys(legacyProfiles.profiles).length > 0; + + expect(hasLegacyAccounts).toBe(true); + }); + + it('should return true when no configs exist', () => { + const hasConfigYaml = fs.existsSync(path.join(testDir, 'config.yaml')); + const hasConfigJson = fs.existsSync(path.join(testDir, 'config.json')); + const hasProfilesJson = fs.existsSync(path.join(testDir, 'profiles.json')); + + expect(hasConfigYaml).toBe(false); + expect(hasConfigJson).toBe(false); + expect(hasProfilesJson).toBe(false); + }); + + it('should detect unified config with profiles', () => { + createConfigYaml(` +version: 2 +profiles: + glm: + type: api + settings: ~/.ccs/glm.settings.json +accounts: {} +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasProfiles = content.includes('glm:') && content.includes('type: api'); + + expect(hasProfiles).toBe(true); + }); + + it('should detect unified config with remote proxy', () => { + createConfigYaml(` +version: 2 +profiles: {} +accounts: {} +cliproxy_server: + remote: + enabled: true + host: my-server.example.com +`); + + const content = fs.readFileSync(path.join(testDir, 'config.yaml'), 'utf8'); + const hasRemoteProxy = content.includes('enabled: true') && content.includes('host:'); + + expect(hasRemoteProxy).toBe(true); + }); + }); + + describe('empty config handling', () => { + it('should treat empty configs as first-time', () => { + createConfigYaml('version: 2\nprofiles: {}\naccounts: {}'); + createConfigJson({ profiles: {} }); + createProfilesJson({ profiles: {} }); + + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + const legacyProfiles = JSON.parse(fs.readFileSync(path.join(testDir, 'profiles.json'), 'utf8')); + + const hasLegacyProfiles = Object.keys(legacyConfig.profiles || {}).length > 0; + const hasLegacyAccounts = Object.keys(legacyProfiles.profiles || {}).length > 0; + + expect(hasLegacyProfiles).toBe(false); + expect(hasLegacyAccounts).toBe(false); + }); + }); + + describe('corrupted config handling', () => { + it('should handle corrupted config.json gracefully', () => { + fs.writeFileSync(path.join(testDir, 'config.json'), 'not valid json{{{', 'utf8'); + + let hasLegacyProfiles = false; + try { + const legacyConfig = JSON.parse(fs.readFileSync(path.join(testDir, 'config.json'), 'utf8')); + hasLegacyProfiles = legacyConfig.profiles && Object.keys(legacyConfig.profiles).length > 0; + } catch { + hasLegacyProfiles = false; + } + + expect(hasLegacyProfiles).toBe(false); + }); + }); +}); diff --git a/tests/unit/utils/expand-path.test.ts b/tests/unit/utils/expand-path.test.ts new file mode 100644 index 00000000..9764dca1 --- /dev/null +++ b/tests/unit/utils/expand-path.test.ts @@ -0,0 +1,69 @@ +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import * as path from "path"; +import * as os from "os"; +import { expandPath } from "../../../src/utils/helpers"; + +describe("expandPath", () => { + const originalEnv = { ...process.env }; + const HOME = os.homedir(); + + beforeEach(() => { + process.env.TEST_HOME = "/custom/home"; + process.env.TEST_VAR = "foo"; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("1. Tilde expansion: ~/path -> /home/user/path", () => { + expect(expandPath("~/test/file.txt")).toBe(path.join(HOME, "test/file.txt")); + }); + + test("2. Windows tilde with backslash: ~\\path", () => { + // expandPath handles both ~/ and ~\ regardless of platform + expect(expandPath("~\\test\\file.txt")).toBe(path.join(HOME, "test/file.txt")); + }); + + test("3. Environment variable expansion: ${VAR}/path", () => { + expect(expandPath("${TEST_HOME}/file.txt")).toBe(path.normalize("/custom/home/file.txt")); + }); + + test("4. Dollar sign env vars: $VAR/path", () => { + expect(expandPath("$TEST_HOME/file.txt")).toBe(path.normalize("/custom/home/file.txt")); + }); + + test("5. Windows %VAR% expansion: %VAR%\\path (simulated)", () => { + // We can't easily mock process.platform if it's not win32, + // but the function check process.platform === 'win32' + if (process.platform === 'win32') { + expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("/custom/home/file.txt")); + } else { + // Should remain unchanged on non-windows (but separators normalized) + expect(expandPath("%TEST_HOME%\\file.txt")).toBe(path.normalize("%TEST_HOME%/file.txt")); + } + }); + + test("6. Mixed path separators normalization", () => { + const result = expandPath("path/to\\some/file"); + expect(result).toBe(path.normalize("path/to/some/file")); + }); + + test("7. Nested env vars: ${HOME}/${VAR}/path", () => { + expect(expandPath("${TEST_HOME}/${TEST_VAR}/file.txt")).toBe(path.normalize("/custom/home/foo/file.txt")); + }); + + test("8. Empty/null path handling", () => { + expect(expandPath("")).toBe("."); + }); + + test("9. Already absolute paths stay unchanged", () => { + const absPath = "/absolute/path"; + expect(expandPath(absPath)).toBe(path.normalize(absPath)); + }); + + test("10. Undefined env vars -> empty string", () => { + expect(expandPath("${UNDEFINED_VAR}/file.txt")).toBe(path.normalize("/file.txt")); + expect(expandPath("$UNDEFINED_VAR/file.txt")).toBe(path.normalize("/file.txt")); + }); +});