diff --git a/src/cli.ts b/src/cli.ts index 12e4344..d6f9f44 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,7 @@ import { updateCommand } from './commands/update.js'; import { whoamiCommand } from './commands/whoami.js'; import { version } from './index.js'; import { AuthError, getMe } from './services/auth.service.js'; -import { loadConfig } from './utils/config.js'; +import { loadAuth } from './utils/config.js'; import { checkForUpdates } from './utils/version.js'; const displayBanner = (): void => { @@ -38,8 +38,8 @@ const displayVersionInfo = async (): Promise => { // Display login status try { - const config = await loadConfig(); - if (config.auth?.token) { + const auth = await loadAuth(); + if (auth.token) { const user = await getMe(); const displayName = user.name || user.email; const aliasDisplay = user.verifiedAlias diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index b4ebba9..9910052 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -24,11 +24,11 @@ const mockRequestDeviceCode = const mockPollForToken = authService.pollForToken as jest.MockedFunction< typeof authService.pollForToken >; -const mockLoadConfig = configUtils.loadConfig as jest.MockedFunction< - typeof configUtils.loadConfig +const mockLoadAuth = configUtils.loadAuth as jest.MockedFunction< + typeof configUtils.loadAuth >; -const mockSaveConfig = configUtils.saveConfig as jest.MockedFunction< - typeof configUtils.saveConfig +const mockSaveAuth = configUtils.saveAuth as jest.MockedFunction< + typeof configUtils.saveAuth >; const mockGetRegistryUrl = configUtils.getRegistryUrl as jest.MockedFunction< typeof configUtils.getRegistryUrl @@ -56,9 +56,7 @@ describe('loginCommand', () => { }); it('shows message when already logged in', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'existing-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'existing-token' }); await loginCommand(); @@ -71,7 +69,7 @@ describe('loginCommand', () => { }); it('completes login flow successfully', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockResolvedValue({ device_code: 'device123', user_code: 'ABCD-1234', @@ -84,18 +82,16 @@ describe('loginCommand', () => { token_type: 'Bearer', user: { id: '1', email: 'test@example.com', name: 'Test User' }, }); - mockSaveConfig.mockResolvedValue(undefined); + mockSaveAuth.mockResolvedValue(undefined); await loginCommand(); expect(mockRequestDeviceCode).toHaveBeenCalled(); expect(mockPollForToken).toHaveBeenCalledWith('device123', 5, 900); - expect(mockSaveConfig).toHaveBeenCalledWith({ - auth: { - token: 'new-token', - user: { id: '1', email: 'test@example.com', name: 'Test User' }, - expiresAt: undefined, - }, + expect(mockSaveAuth).toHaveBeenCalledWith({ + token: 'new-token', + user: { id: '1', email: 'test@example.com', name: 'Test User' }, + expiresAt: undefined, }); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Logged in as'), @@ -104,7 +100,7 @@ describe('loginCommand', () => { }); it('handles login errors', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockRejectedValue( new AuthError('Failed', 'request_failed') ); @@ -118,7 +114,7 @@ describe('loginCommand', () => { }); it('handles expired token error with hint', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockResolvedValue({ device_code: 'device123', user_code: 'ABCD-1234', @@ -144,7 +140,7 @@ describe('loginCommand', () => { }); it('handles non-AuthError errors', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockRejectedValue(new Error('Network error')); await expect(loginCommand()).rejects.toThrow('process.exit called'); @@ -156,7 +152,7 @@ describe('loginCommand', () => { }); it('shows email when user has no name', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockResolvedValue({ device_code: 'device123', user_code: 'ABCD-1234', @@ -169,7 +165,7 @@ describe('loginCommand', () => { token_type: 'Bearer', user: { id: '1', email: 'test@example.com' }, }); - mockSaveConfig.mockResolvedValue(undefined); + mockSaveAuth.mockResolvedValue(undefined); await loginCommand(); @@ -180,7 +176,7 @@ describe('loginCommand', () => { }); it('shows generic success when no user info', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockResolvedValue({ device_code: 'device123', user_code: 'ABCD-1234', @@ -192,7 +188,7 @@ describe('loginCommand', () => { access_token: 'new-token', token_type: 'Bearer', }); - mockSaveConfig.mockResolvedValue(undefined); + mockSaveAuth.mockResolvedValue(undefined); await loginCommand(); @@ -207,7 +203,7 @@ describe('loginCommand', () => { default: jest.fn().mockRejectedValue(new Error('Cannot open browser')), })); - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); mockRequestDeviceCode.mockResolvedValue({ device_code: 'device123', user_code: 'ABCD-1234', @@ -220,7 +216,7 @@ describe('loginCommand', () => { token_type: 'Bearer', user: { id: '1', email: 'test@example.com', name: 'Test User' }, }); - mockSaveConfig.mockResolvedValue(undefined); + mockSaveAuth.mockResolvedValue(undefined); await loginCommand(); diff --git a/src/commands/login.ts b/src/commands/login.ts index e629330..e27157c 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -4,7 +4,7 @@ import { pollForToken, requestDeviceCode, } from '../services/auth.service.js'; -import { getRegistryUrl, loadConfig, saveConfig } from '../utils/config.js'; +import { getRegistryUrl, loadAuth, saveAuth } from '../utils/config.js'; /** * Login command - authenticate via device authorization flow @@ -12,8 +12,8 @@ import { getRegistryUrl, loadConfig, saveConfig } from '../utils/config.js'; export const loginCommand = async (): Promise => { try { // Check if already logged in - const config = await loadConfig(); - if (config.auth?.token) { + const auth = await loadAuth(); + if (auth.token) { console.log( chalk.yellow('⚠️ Already logged in.'), 'Run', @@ -65,19 +65,13 @@ export const loginCommand = async (): Promise => { deviceCode.expires_in ); - // Reload config to get any changes made during auth (e.g., deviceId) - const currentConfig = await loadConfig(); - - // Save token to config - await saveConfig({ - ...currentConfig, - auth: { - token: tokenResponse.access_token, - user: tokenResponse.user, - expiresAt: tokenResponse.expires_in - ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() - : undefined, - }, + // Save token to auth.json (config.json with deviceId/registry is untouched) + await saveAuth({ + token: tokenResponse.access_token, + user: tokenResponse.user, + expiresAt: tokenResponse.expires_in + ? new Date(Date.now() + tokenResponse.expires_in * 1000).toISOString() + : undefined, }); console.log(); diff --git a/src/commands/logout.test.ts b/src/commands/logout.test.ts index a909b18..f159dcf 100644 --- a/src/commands/logout.test.ts +++ b/src/commands/logout.test.ts @@ -9,11 +9,11 @@ jest.mock('../utils/config.js'); const mockLogout = authService.logout as jest.MockedFunction< typeof authService.logout >; -const mockLoadConfig = configUtils.loadConfig as jest.MockedFunction< - typeof configUtils.loadConfig +const mockLoadAuth = configUtils.loadAuth as jest.MockedFunction< + typeof configUtils.loadAuth >; -const mockClearConfig = configUtils.clearConfig as jest.MockedFunction< - typeof configUtils.clearConfig +const mockClearAuth = configUtils.clearAuth as jest.MockedFunction< + typeof configUtils.clearAuth >; describe('logoutCommand', () => { @@ -29,30 +29,28 @@ describe('logoutCommand', () => { }); it('shows message when not logged in', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); await logoutCommand(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Not logged in') ); - expect(mockClearConfig).not.toHaveBeenCalled(); + expect(mockClearAuth).not.toHaveBeenCalled(); }); it('clears credentials and shows success', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { - token: 'test-token', - user: { id: '1', email: 'test@example.com', name: 'Test User' }, - }, + mockLoadAuth.mockResolvedValue({ + token: 'test-token', + user: { id: '1', email: 'test@example.com', name: 'Test User' }, }); mockLogout.mockResolvedValue(undefined); - mockClearConfig.mockResolvedValue(undefined); + mockClearAuth.mockResolvedValue(undefined); await logoutCommand(); expect(mockLogout).toHaveBeenCalled(); - expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearAuth).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Logged out from'), expect.stringContaining('Test User') @@ -60,15 +58,13 @@ describe('logoutCommand', () => { }); it('clears credentials even when server logout fails', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockLogout.mockRejectedValue(new Error('Network error')); - mockClearConfig.mockResolvedValue(undefined); + mockClearAuth.mockResolvedValue(undefined); await logoutCommand(); - expect(mockClearConfig).toHaveBeenCalled(); + expect(mockClearAuth).toHaveBeenCalled(); expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Logged out locally') ); diff --git a/src/commands/logout.ts b/src/commands/logout.ts index f2e836c..ae61e8d 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { logout as logoutApi } from '../services/auth.service.js'; -import { clearConfig, loadConfig } from '../utils/config.js'; +import { clearAuth, loadAuth } from '../utils/config.js'; /** * Logout command - clear stored credentials @@ -8,21 +8,21 @@ import { clearConfig, loadConfig } from '../utils/config.js'; export const logoutCommand = async (): Promise => { try { // Check if logged in - const config = await loadConfig(); - if (!config.auth?.token) { + const auth = await loadAuth(); + if (!auth.token) { console.log(chalk.yellow('Not logged in.')); return; } // Get user info before clearing - const userName = config.auth.user?.name || config.auth.user?.email; + const userName = auth.user?.name || auth.user?.email; // Attempt server-side logout (optional, ignore errors) console.log(chalk.gray('Logging out...')); await logoutApi(); - // Clear local credentials - await clearConfig(); + // Clear auth.json only — config.json (deviceId, registry) is preserved + await clearAuth(); console.log(); if (userName) { @@ -32,7 +32,7 @@ export const logoutCommand = async (): Promise => { } } catch { // Even if server logout fails, clear local credentials - await clearConfig(); + await clearAuth(); console.log(chalk.green('✅ Logged out locally.')); console.log( chalk.gray( diff --git a/src/commands/whoami.test.ts b/src/commands/whoami.test.ts index 401574b..d84eff2 100644 --- a/src/commands/whoami.test.ts +++ b/src/commands/whoami.test.ts @@ -16,8 +16,8 @@ jest.mock('../utils/config.js'); const mockGetMe = authService.getMe as jest.MockedFunction< typeof authService.getMe >; -const mockLoadConfig = configUtils.loadConfig as jest.MockedFunction< - typeof configUtils.loadConfig +const mockLoadAuth = configUtils.loadAuth as jest.MockedFunction< + typeof configUtils.loadAuth >; const mockGetRegistryUrl = configUtils.getRegistryUrl as jest.MockedFunction< typeof configUtils.getRegistryUrl @@ -49,7 +49,7 @@ describe('whoamiCommand', () => { }); it('shows not logged in when no token', async () => { - mockLoadConfig.mockResolvedValue({}); + mockLoadAuth.mockResolvedValue({}); await whoamiCommand(); @@ -59,8 +59,9 @@ describe('whoamiCommand', () => { }); it('shows expired session when token is locally expired', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'expired-token', expiresAt: '2020-01-01T00:00:00Z' }, + mockLoadAuth.mockResolvedValue({ + token: 'expired-token', + expiresAt: '2020-01-01T00:00:00Z', }); mockIsTokenExpired.mockReturnValue(true); @@ -72,9 +73,7 @@ describe('whoamiCommand', () => { }); it('displays user info when authenticated', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockGetMe.mockResolvedValue({ id: '123', email: 'test@example.com', @@ -91,9 +90,7 @@ describe('whoamiCommand', () => { }); it('handles session expired error', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'expired-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'expired-token' }); mockGetMe.mockRejectedValue( new AuthError('Session expired', 'session_expired') ); @@ -107,9 +104,7 @@ describe('whoamiCommand', () => { }); it('handles not_authenticated error', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockGetMe.mockRejectedValue( new AuthError('Not authenticated', 'not_authenticated') ); @@ -122,9 +117,7 @@ describe('whoamiCommand', () => { }); it('handles other AuthError errors', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockGetMe.mockRejectedValue(new AuthError('Server error', 'server_error')); await expect(whoamiCommand()).rejects.toThrow('process.exit called'); @@ -136,9 +129,7 @@ describe('whoamiCommand', () => { }); it('handles non-AuthError errors', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockGetMe.mockRejectedValue(new Error('Network error')); await expect(whoamiCommand()).rejects.toThrow('process.exit called'); @@ -150,9 +141,7 @@ describe('whoamiCommand', () => { }); it('displays user without name', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockGetMe.mockResolvedValue({ id: '123', email: 'test@example.com', @@ -167,9 +156,7 @@ describe('whoamiCommand', () => { }); it('displays user with verifiedAlias', async () => { - mockLoadConfig.mockResolvedValue({ - auth: { token: 'test-token' }, - }); + mockLoadAuth.mockResolvedValue({ token: 'test-token' }); mockGetMe.mockResolvedValue({ id: '123', email: 'test@example.com', diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts index f221d0c..3303a54 100644 --- a/src/commands/whoami.ts +++ b/src/commands/whoami.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import { AuthError, getMe } from '../services/auth.service.js'; -import { getRegistryUrl, isTokenExpired, loadConfig } from '../utils/config.js'; +import { getRegistryUrl, isTokenExpired, loadAuth } from '../utils/config.js'; /** * Whoami command - display current authenticated user @@ -8,15 +8,15 @@ import { getRegistryUrl, isTokenExpired, loadConfig } from '../utils/config.js'; export const whoamiCommand = async (): Promise => { try { // Check if token exists locally - const config = await loadConfig(); - if (!config.auth?.token) { + const auth = await loadAuth(); + if (!auth.token) { console.log(chalk.yellow('Not logged in.')); console.log('Run', chalk.cyan('agent login'), 'to authenticate.'); return; } // Check if token is expired locally - if (isTokenExpired(config.auth.expiresAt)) { + if (isTokenExpired(auth.expiresAt)) { console.log(chalk.yellow('⚠️ Session expired.')); console.log('Run', chalk.cyan('agent login'), 'to authenticate again.'); process.exit(1); diff --git a/src/types/config.types.ts b/src/types/config.types.ts index fb6a98c..a9e8d38 100644 --- a/src/types/config.types.ts +++ b/src/types/config.types.ts @@ -5,18 +5,18 @@ import { z } from 'zod'; */ export const userSchema = z.object({ id: z.string(), - email: z.string().email(), + email: z.string(), name: z.string().optional(), - avatar: z.string().url().optional(), + avatar: z.string().optional(), verifiedAlias: z.string().optional(), }); /** - * Auth configuration schema + * Auth config schema (auth.json) */ -export const authConfigSchema = z.object({ - token: z.string(), - expiresAt: z.string().datetime().optional(), +export const authFileSchema = z.object({ + token: z.string().optional(), + expiresAt: z.string().optional(), user: userSchema.optional(), }); @@ -28,10 +28,9 @@ export const registryConfigSchema = z.object({ }); /** - * Complete Agentage configuration schema + * App config schema (config.json) — NO tokens */ -export const agentageConfigSchema = z.object({ - auth: authConfigSchema.optional(), +export const appConfigSchema = z.object({ registry: registryConfigSchema.optional(), deviceId: z.string().optional(), }); @@ -42,9 +41,9 @@ export const agentageConfigSchema = z.object({ export type User = z.infer; /** - * Auth configuration + * Auth config (auth.json) */ -export type AuthConfig = z.infer; +export type AuthFileConfig = z.infer; /** * Registry configuration @@ -52,9 +51,9 @@ export type AuthConfig = z.infer; export type RegistryConfig = z.infer; /** - * Complete Agentage configuration + * App configuration (config.json — no tokens) */ -export type AgentageConfig = z.infer; +export type AppConfig = z.infer; /** * Device code response from the auth API diff --git a/src/utils/config.test.ts b/src/utils/config.test.ts index f8094aa..a4b78c4 100644 --- a/src/utils/config.test.ts +++ b/src/utils/config.test.ts @@ -1,9 +1,10 @@ -import { mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { homedir } from 'os'; -import type { AgentageConfig } from '../types/config.types.js'; +import type { AppConfig, AuthFileConfig } from '../types/config.types.js'; import { - clearConfig, + clearAuth, DEFAULT_REGISTRY_URL, + getAuthPath, getAuthStatus, getAuthToken, getConfigDir, @@ -11,8 +12,10 @@ import { getDeviceId, getRegistryUrl, isTokenExpired, - loadConfig, - saveConfig, + loadAppConfig, + loadAuth, + saveAppConfig, + saveAuth, } from './config.js'; // Mock fs/promises @@ -22,14 +25,12 @@ jest.mock('os'); const mockMkdir = mkdir as jest.MockedFunction; const mockReadFile = readFile as jest.MockedFunction; const mockWriteFile = writeFile as jest.MockedFunction; -const mockRm = rm as jest.MockedFunction; const mockHomedir = homedir as jest.MockedFunction; describe('config utils', () => { beforeEach(() => { jest.clearAllMocks(); mockHomedir.mockReturnValue('/home/testuser'); - // Clear environment variables delete process.env.AGENTAGE_REGISTRY_URL; delete process.env.AGENTAGE_AUTH_TOKEN; }); @@ -46,79 +47,165 @@ describe('config utils', () => { }); }); - describe('loadConfig', () => { - it('returns parsed config when file exists', async () => { - const config: AgentageConfig = { - auth: { - token: 'test-token', - user: { id: '123', email: 'test@example.com' }, - }, + describe('getAuthPath', () => { + it('returns the correct auth file path', () => { + expect(getAuthPath()).toBe('/home/testuser/.agentage/auth.json'); + }); + }); + + describe('loadAuth', () => { + it('returns parsed auth when file exists', async () => { + const auth: AuthFileConfig = { + token: 'test-token', + expiresAt: '2030-01-01T00:00:00.000Z', + user: { id: '123', email: 'test@example.com' }, }; - mockReadFile.mockResolvedValue(JSON.stringify(config)); + mockReadFile.mockResolvedValue(JSON.stringify(auth)); - const result = await loadConfig(); + const result = await loadAuth(); - expect(result).toEqual(config); + expect(result).toEqual(auth); expect(mockReadFile).toHaveBeenCalledWith( - '/home/testuser/.agentage/config.json', + '/home/testuser/.agentage/auth.json', 'utf-8' ); }); - it('returns empty config when file does not exist', async () => { + it('returns empty object when file does not exist', async () => { mockReadFile.mockRejectedValue(new Error('ENOENT')); - const result = await loadConfig(); + const result = await loadAuth(); expect(result).toEqual({}); }); - it('returns empty config when file contains invalid JSON', async () => { - mockReadFile.mockResolvedValue('invalid json'); + it('returns empty object when file contains invalid JSON', async () => { + mockReadFile.mockResolvedValue('not json'); - const result = await loadConfig(); + const result = await loadAuth(); expect(result).toEqual({}); }); }); - describe('saveConfig', () => { - it('creates directory and writes config file', async () => { + describe('saveAuth', () => { + it('creates directory and writes auth.json', async () => { mockMkdir.mockResolvedValue(undefined); mockWriteFile.mockResolvedValue(undefined); - const config: AgentageConfig = { - auth: { token: 'test-token' }, + const auth: AuthFileConfig = { + token: 'my-token', + user: { id: '1', email: 'a@b.com' }, }; - await saveConfig(config); + await saveAuth(auth); expect(mockMkdir).toHaveBeenCalledWith('/home/testuser/.agentage', { recursive: true, }); expect(mockWriteFile).toHaveBeenCalledWith( - '/home/testuser/.agentage/config.json', - JSON.stringify(config, null, 2), + '/home/testuser/.agentage/auth.json', + JSON.stringify(auth, null, 2), 'utf-8' ); }); }); - describe('clearConfig', () => { - it('removes the config file', async () => { - mockRm.mockResolvedValue(undefined); + describe('clearAuth', () => { + it('writes empty object to auth.json', async () => { + mockWriteFile.mockResolvedValue(undefined); - await clearConfig(); + await clearAuth(); - expect(mockRm).toHaveBeenCalledWith( - '/home/testuser/.agentage/config.json' + expect(mockWriteFile).toHaveBeenCalledWith( + '/home/testuser/.agentage/auth.json', + JSON.stringify({}, null, 2), + 'utf-8' ); }); - it('ignores error if file does not exist', async () => { - mockRm.mockRejectedValue(new Error('ENOENT')); + it('does not touch config.json', async () => { + mockWriteFile.mockResolvedValue(undefined); + + await clearAuth(); + + expect(mockWriteFile).toHaveBeenCalledTimes(1); + const writtenPath = mockWriteFile.mock.calls[0][0] as string; + expect(writtenPath).toContain('auth.json'); + expect(writtenPath).not.toContain('config.json'); + }); + + it('ignores errors gracefully', async () => { + mockWriteFile.mockRejectedValue(new Error('ENOENT')); + + await expect(clearAuth()).resolves.not.toThrow(); + }); + }); + + describe('loadAppConfig', () => { + it('returns parsed app config when file exists', async () => { + const config: AppConfig = { + registry: { url: 'https://agentage.io' }, + deviceId: 'abc123', + }; + mockReadFile.mockResolvedValue(JSON.stringify(config)); + + const result = await loadAppConfig(); + + expect(result).toEqual(config); + expect(mockReadFile).toHaveBeenCalledWith( + '/home/testuser/.agentage/config.json', + 'utf-8' + ); + }); + + it('returns empty object when file does not exist', async () => { + mockReadFile.mockRejectedValue(new Error('ENOENT')); + + const result = await loadAppConfig(); + + expect(result).toEqual({}); + }); + + it('strips unknown fields from config.json', async () => { + const configWithExtra = { + auth: { token: 'should-be-ignored' }, + registry: { url: 'https://agentage.io' }, + deviceId: 'abc123', + }; + mockReadFile.mockResolvedValue(JSON.stringify(configWithExtra)); - await expect(clearConfig()).resolves.not.toThrow(); + const result = await loadAppConfig(); + + expect(result).toEqual({ + registry: { url: 'https://agentage.io' }, + deviceId: 'abc123', + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((result as any).auth).toBeUndefined(); + }); + }); + + describe('saveAppConfig', () => { + it('creates directory and writes config.json', async () => { + mockMkdir.mockResolvedValue(undefined); + mockWriteFile.mockResolvedValue(undefined); + + const config: AppConfig = { + registry: { url: 'https://agentage.io' }, + deviceId: 'dev-123', + }; + + await saveAppConfig(config); + + expect(mockMkdir).toHaveBeenCalledWith('/home/testuser/.agentage', { + recursive: true, + }); + expect(mockWriteFile).toHaveBeenCalledWith( + '/home/testuser/.agentage/config.json', + JSON.stringify(config, null, 2), + 'utf-8' + ); }); }); @@ -132,7 +219,7 @@ describe('config utils', () => { }); it('returns config value when no env var', async () => { - const config: AgentageConfig = { + const config: AppConfig = { registry: { url: 'https://config.registry.io' }, }; mockReadFile.mockResolvedValue(JSON.stringify(config)); @@ -160,15 +247,13 @@ describe('config utils', () => { expect(result).toBe('env-token'); }); - it('returns config value when no env var', async () => { - const config: AgentageConfig = { - auth: { token: 'config-token' }, - }; - mockReadFile.mockResolvedValue(JSON.stringify(config)); + it('returns token from auth.json when no env var', async () => { + const auth: AuthFileConfig = { token: 'auth-file-token' }; + mockReadFile.mockResolvedValue(JSON.stringify(auth)); const result = await getAuthToken(); - expect(result).toBe('config-token'); + expect(result).toBe('auth-file-token'); }); it('returns undefined when no token available', async () => { @@ -180,11 +265,12 @@ describe('config utils', () => { }); it('returns undefined when token is expired', async () => { - const pastDate = new Date(Date.now() - 86400000).toISOString(); // 1 day ago - const config: AgentageConfig = { - auth: { token: 'expired-token', expiresAt: pastDate }, + const pastDate = new Date(Date.now() - 86400000).toISOString(); + const auth: AuthFileConfig = { + token: 'expired-token', + expiresAt: pastDate, }; - mockReadFile.mockResolvedValue(JSON.stringify(config)); + mockReadFile.mockResolvedValue(JSON.stringify(auth)); const result = await getAuthToken(); @@ -192,11 +278,12 @@ describe('config utils', () => { }); it('returns token when not expired', async () => { - const futureDate = new Date(Date.now() + 86400000).toISOString(); // 1 day from now - const config: AgentageConfig = { - auth: { token: 'valid-token', expiresAt: futureDate }, + const futureDate = new Date(Date.now() + 86400000).toISOString(); + const auth: AuthFileConfig = { + token: 'valid-token', + expiresAt: futureDate, }; - mockReadFile.mockResolvedValue(JSON.stringify(config)); + mockReadFile.mockResolvedValue(JSON.stringify(auth)); const result = await getAuthToken(); @@ -244,10 +331,11 @@ describe('config utils', () => { it('returns expired when token is expired', async () => { const pastDate = new Date(Date.now() - 86400000).toISOString(); - const config: AgentageConfig = { - auth: { token: 'expired-token', expiresAt: pastDate }, + const auth: AuthFileConfig = { + token: 'expired-token', + expiresAt: pastDate, }; - mockReadFile.mockResolvedValue(JSON.stringify(config)); + mockReadFile.mockResolvedValue(JSON.stringify(auth)); const result = await getAuthStatus(); @@ -256,10 +344,11 @@ describe('config utils', () => { it('returns authenticated when token is valid', async () => { const futureDate = new Date(Date.now() + 86400000).toISOString(); - const config: AgentageConfig = { - auth: { token: 'valid-token', expiresAt: futureDate }, + const auth: AuthFileConfig = { + token: 'valid-token', + expiresAt: futureDate, }; - mockReadFile.mockResolvedValue(JSON.stringify(config)); + mockReadFile.mockResolvedValue(JSON.stringify(auth)); const result = await getAuthStatus(); @@ -268,8 +357,8 @@ describe('config utils', () => { }); describe('getDeviceId', () => { - it('returns existing device ID from config', async () => { - const config: AgentageConfig = { + it('returns existing device ID from config.json', async () => { + const config: AppConfig = { deviceId: 'existing-device-id-12345678', }; mockReadFile.mockResolvedValue(JSON.stringify(config)); @@ -277,7 +366,6 @@ describe('config utils', () => { const result = await getDeviceId(); expect(result).toBe('existing-device-id-12345678'); - // Should not save config since device ID already exists expect(mockWriteFile).not.toHaveBeenCalled(); }); @@ -288,18 +376,18 @@ describe('config utils', () => { const result = await getDeviceId(); - // Should return a 32-character hex string expect(result).toMatch(/^[a-f0-9]{32}$/); - // Should save the new device ID expect(mockWriteFile).toHaveBeenCalled(); + const writtenPath = mockWriteFile.mock.calls[0][0] as string; + expect(writtenPath).toContain('config.json'); const savedConfig = JSON.parse( mockWriteFile.mock.calls[0][1] as string - ) as AgentageConfig; + ) as AppConfig; expect(savedConfig.deviceId).toBe(result); }); it('returns consistent device ID on subsequent calls', async () => { - const config: AgentageConfig = { + const config: AppConfig = { deviceId: 'consistent-device-id-abc', }; mockReadFile.mockResolvedValue(JSON.stringify(config)); diff --git a/src/utils/config.ts b/src/utils/config.ts index e430b0e..7cdf88c 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,8 +1,13 @@ import { createHash } from 'crypto'; -import { mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { arch, homedir, hostname, platform } from 'os'; import { dirname, join } from 'path'; -import { AgentageConfig, agentageConfigSchema } from '../types/config.types.js'; +import { + AppConfig, + appConfigSchema, + AuthFileConfig, + authFileSchema, +} from '../types/config.types.js'; /** * Default registry URL @@ -15,51 +20,76 @@ export const DEFAULT_REGISTRY_URL = 'https://dev.agentage.io'; export const getConfigDir = (): string => join(homedir(), '.agentage'); /** - * Get the config file path + * Get the config file path (config.json) */ export const getConfigPath = (): string => join(getConfigDir(), 'config.json'); /** - * Load configuration from disk + * Get the auth file path (auth.json) */ -export const loadConfig = async (): Promise => { +export const getAuthPath = (): string => join(getConfigDir(), 'auth.json'); + +/** + * Load auth state from auth.json + */ +export const loadAuth = async (): Promise => { try { - const configPath = getConfigPath(); - const content = await readFile(configPath, 'utf-8'); + const authPath = getAuthPath(); + const content = await readFile(authPath, 'utf-8'); const parsed = JSON.parse(content); - return agentageConfigSchema.parse(parsed); + return authFileSchema.parse(parsed); } catch { - // Return empty config if file doesn't exist or is invalid return {}; } }; /** - * Save configuration to disk + * Save auth state to auth.json */ -export const saveConfig = async (config: AgentageConfig): Promise => { - const configPath = getConfigPath(); - const configDir = dirname(configPath); - - // Ensure directory exists - await mkdir(configDir, { recursive: true }); +export const saveAuth = async (auth: AuthFileConfig): Promise => { + const authPath = getAuthPath(); + const authDir = dirname(authPath); + await mkdir(authDir, { recursive: true }); + await writeFile(authPath, JSON.stringify(auth, null, 2), 'utf-8'); +}; - // Write config file - await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); +/** + * Clear auth state (writes empty object to auth.json) + * Does NOT touch config.json — deviceId and registry are preserved + */ +export const clearAuth = async (): Promise => { + try { + const authPath = getAuthPath(); + await writeFile(authPath, JSON.stringify({}, null, 2), 'utf-8'); + } catch { + // Ignore if directory doesn't exist + } }; /** - * Clear stored credentials (logout) + * Load app config from config.json (registry + deviceId only, no tokens) */ -export const clearConfig = async (): Promise => { +export const loadAppConfig = async (): Promise => { try { const configPath = getConfigPath(); - await rm(configPath); + const content = await readFile(configPath, 'utf-8'); + const parsed = JSON.parse(content); + return appConfigSchema.parse(parsed); } catch { - // Ignore if file doesn't exist + return {}; } }; +/** + * Save app config to config.json (registry + deviceId only) + */ +export const saveAppConfig = async (config: AppConfig): Promise => { + const configPath = getConfigPath(); + const configDir = dirname(configPath); + await mkdir(configDir, { recursive: true }); + await writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8'); +}; + /** * Get the registry URL from config or environment */ @@ -70,8 +100,7 @@ export const getRegistryUrl = async (): Promise => { return envUrl; } - // Check config file - const config = await loadConfig(); + const config = await loadAppConfig(); return config.registry?.url || DEFAULT_REGISTRY_URL; }; @@ -87,7 +116,7 @@ export const isTokenExpired = (expiresAt: string | undefined): boolean => { }; /** - * Get the auth token from config or environment + * Get the auth token from auth.json or environment * Returns undefined if the token is expired */ export const getAuthToken = async (): Promise => { @@ -97,15 +126,13 @@ export const getAuthToken = async (): Promise => { return envToken; } - // Check config file - const config = await loadConfig(); + const auth = await loadAuth(); - // Check if token exists and is not expired - if (config.auth?.token) { - if (isTokenExpired(config.auth.expiresAt)) { - return undefined; // Token is expired + if (auth.token) { + if (isTokenExpired(auth.expiresAt)) { + return undefined; } - return config.auth.token; + return auth.token; } return undefined; @@ -129,18 +156,17 @@ export const getAuthStatus = async (): Promise => { return { status: 'authenticated', token: envToken }; } - // Check config file - const config = await loadConfig(); + const auth = await loadAuth(); - if (!config.auth?.token) { + if (!auth.token) { return { status: 'not_authenticated' }; } - if (isTokenExpired(config.auth.expiresAt)) { + if (isTokenExpired(auth.expiresAt)) { return { status: 'expired' }; } - return { status: 'authenticated', token: config.auth.token }; + return { status: 'authenticated', token: auth.token }; }; /** @@ -162,19 +188,17 @@ const generateDeviceFingerprint = async (): Promise => { /** * Get or create a unique device ID - * The device ID is generated from machine ID + OS info and stored in config + * The device ID is generated from machine ID + OS info and stored in config.json */ export const getDeviceId = async (): Promise => { - const config = await loadConfig(); + const config = await loadAppConfig(); - // Return existing device ID if available if (config.deviceId) { return config.deviceId; } - // Generate and store new device ID const deviceId = await generateDeviceFingerprint(); - await saveConfig({ ...config, deviceId }); + await saveAppConfig({ ...config, deviceId }); return deviceId; };