diff --git a/apps/cli/ai/auth.ts b/apps/cli/ai/auth.ts index feaa885610..41c0260e91 100644 --- a/apps/cli/ai/auth.ts +++ b/apps/cli/ai/auth.ts @@ -4,7 +4,7 @@ import { getAiProviderDefinition, type AiProviderId, } from 'cli/ai/providers'; -import { getAiProvider, saveAiProvider } from 'cli/lib/appdata'; +import { readCliConfig, updateCliConfig } from 'cli/lib/cli-config/core'; async function getPreferredReadyProvider( exclude?: AiProviderId @@ -49,7 +49,7 @@ export async function resolveUnavailableAiProvider( } export async function resolveInitialAiProvider(): Promise< AiProviderId > { - const savedProvider = await getAiProvider(); + const { aiProvider: savedProvider } = await readCliConfig(); if ( savedProvider ) { const definition = getAiProviderDefinition( savedProvider ); if ( @@ -73,7 +73,7 @@ export async function resolveInitialAiProvider(): Promise< AiProviderId > { } export async function saveSelectedAiProvider( provider: AiProviderId ): Promise< void > { - await saveAiProvider( provider ); + await updateCliConfig( { aiProvider: provider } ); } export async function prepareAiProvider( diff --git a/apps/cli/ai/providers.ts b/apps/cli/ai/providers.ts index c36001d1f6..69e0c7d0f1 100644 --- a/apps/cli/ai/providers.ts +++ b/apps/cli/ai/providers.ts @@ -1,8 +1,9 @@ import childProcess from 'child_process'; import { password } from '@inquirer/prompts'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { __ } from '@wordpress/i18n'; import { z } from 'zod'; -import { getAnthropicApiKey, getAuthToken, saveAnthropicApiKey } from 'cli/lib/appdata'; +import { readCliConfig, updateCliConfig } from 'cli/lib/cli-config/core'; import { LoggerError } from 'cli/logger'; export const AI_PROVIDERS = { @@ -51,7 +52,7 @@ export function hasClaudeCodeAuth(): boolean { async function resolveAnthropicApiKey( options?: { force?: boolean; } ): Promise< string | undefined > { - const savedKey = await getAnthropicApiKey(); + const { anthropicApiKey: savedKey } = await readCliConfig(); if ( savedKey && ! options?.force ) { return savedKey; } @@ -67,7 +68,7 @@ async function resolveAnthropicApiKey( options?: { }, } ); - await saveAnthropicApiKey( apiKey ); + await updateCliConfig( { anthropicApiKey: apiKey } ); return apiKey; } @@ -83,12 +84,8 @@ function getWpcomAiGatewayBaseUrl(): string { } async function hasValidWpcomAuth(): Promise< boolean > { - try { - await getAuthToken(); - return true; - } catch { - return false; - } + const token = await readAuthToken(); + return token !== null; } function createBaseEnvironment(): Record< string, string > { @@ -117,7 +114,10 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = { throw new LoggerError( __( 'WordPress.com login required. Use /login to authenticate.' ) ); }, resolveEnv: async () => { - const token = await getAuthToken(); + const token = await readAuthToken(); + if ( ! token ) { + throw new LoggerError( __( 'WordPress.com login required. Use /login to authenticate.' ) ); + } const env = createBaseEnvironment(); env.ANTHROPIC_BASE_URL = getWpcomAiGatewayBaseUrl(); env.ANTHROPIC_AUTH_TOKEN = token.accessToken; @@ -156,12 +156,15 @@ const AI_PROVIDER_DEFINITIONS: Record< AiProviderId, AiProviderDefinition > = { id: 'anthropic-api-key', autoFallbackWhenUnavailable: false, isVisible: async () => true, - isReady: async () => Boolean( await getAnthropicApiKey() ), + isReady: async () => { + const { anthropicApiKey } = await readCliConfig(); + return Boolean( anthropicApiKey ); + }, prepare: async ( options ) => { await resolveAnthropicApiKey( options ); }, resolveEnv: async () => { - const apiKey = await getAnthropicApiKey(); + const { anthropicApiKey: apiKey } = await readCliConfig(); if ( ! apiKey ) { throw new LoggerError( __( diff --git a/apps/cli/ai/tests/auth.test.ts b/apps/cli/ai/tests/auth.test.ts index aeda994171..86db65f4f1 100644 --- a/apps/cli/ai/tests/auth.test.ts +++ b/apps/cli/ai/tests/auth.test.ts @@ -1,5 +1,6 @@ import childProcess from 'child_process'; import { password } from '@inquirer/prompts'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { getAvailableAiProviders, @@ -9,12 +10,7 @@ import { resolveInitialAiProvider, resolveUnavailableAiProvider, } from 'cli/ai/auth'; -import { - getAiProvider, - getAnthropicApiKey, - getAuthToken, - saveAnthropicApiKey, -} from 'cli/lib/appdata'; +import { readCliConfig, updateCliConfig } from 'cli/lib/cli-config/core'; import { LoggerError } from 'cli/logger'; vi.mock( 'child_process', () => ( { @@ -28,33 +24,40 @@ vi.mock( '@inquirer/prompts', () => ( { password: vi.fn(), } ) ); -vi.mock( 'cli/lib/appdata', () => ( { - getAiProvider: vi.fn(), - getAnthropicApiKey: vi.fn(), - getAuthToken: vi.fn(), - saveAnthropicApiKey: vi.fn(), - saveAiProvider: vi.fn(), +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), +} ) ); + +vi.mock( 'cli/lib/cli-config/core', () => ( { + readCliConfig: vi.fn().mockResolvedValue( { version: 1, sites: [] } ), + updateCliConfig: vi.fn(), } ) ); describe( 'AI auth helpers', () => { beforeEach( () => { vi.resetAllMocks(); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); delete process.env.WPCOM_AI_PROXY_BASE_URL; } ); it( 'uses the saved Anthropic API key when provider is Anthropic API key', async () => { - vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [], + snapshots: [], + anthropicApiKey: 'saved-key', + } ); const env = await resolveAiEnvironment( 'anthropic-api-key' ); expect( env.ANTHROPIC_API_KEY ).toBe( 'saved-key' ); expect( env.ANTHROPIC_BASE_URL ).toBeUndefined(); expect( env.ANTHROPIC_AUTH_TOKEN ).toBeUndefined(); - expect( saveAnthropicApiKey ).not.toHaveBeenCalled(); + expect( updateCliConfig ).not.toHaveBeenCalled(); } ); it( 'requires a saved Anthropic API key in API key mode', async () => { - vi.mocked( getAnthropicApiKey ).mockResolvedValue( undefined ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); await expect( resolveAiEnvironment( 'anthropic-api-key' ) ).rejects.toBeInstanceOf( LoggerError @@ -72,23 +75,28 @@ describe( 'AI auth helpers', () => { } ); it( 'prompts for the API key immediately when preparing the API key provider', async () => { - vi.mocked( getAnthropicApiKey ).mockResolvedValue( undefined ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); vi.mocked( password ).mockResolvedValue( 'prompted-key' ); await prepareAiProvider( 'anthropic-api-key' ); expect( password ).toHaveBeenCalledOnce(); - expect( saveAnthropicApiKey ).toHaveBeenCalledWith( 'prompted-key' ); + expect( updateCliConfig ).toHaveBeenCalledWith( { anthropicApiKey: 'prompted-key' } ); } ); it( 'can force re-entering the API key even when one is already saved', async () => { - vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [], + snapshots: [], + anthropicApiKey: 'saved-key', + } ); vi.mocked( password ).mockResolvedValue( 'updated-key' ); await prepareAiProvider( 'anthropic-api-key', { force: true } ); expect( password ).toHaveBeenCalledOnce(); - expect( saveAnthropicApiKey ).toHaveBeenCalledWith( 'updated-key' ); + expect( updateCliConfig ).toHaveBeenCalledWith( { anthropicApiKey: 'updated-key' } ); } ); it( 'lists Claude auth only when it is available', async () => { @@ -106,7 +114,7 @@ describe( 'AI auth helpers', () => { } ); it( 'configures the WP.com gateway environment', async () => { - vi.mocked( getAuthToken ).mockResolvedValue( { + vi.mocked( readAuthToken ).mockResolvedValue( { accessToken: 'wpcom-token', displayName: 'User', email: 'user@example.com', @@ -127,15 +135,26 @@ describe( 'AI auth helpers', () => { } ); it( 'prefers the saved provider', async () => { - vi.mocked( getAiProvider ).mockResolvedValue( 'anthropic-api-key' ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [], + snapshots: [], + aiProvider: 'anthropic-api-key', + anthropicApiKey: 'key', + } ); await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-api-key' ); - expect( getAuthToken ).not.toHaveBeenCalled(); + expect( readAuthToken ).not.toHaveBeenCalled(); } ); it( 'falls back to API key mode when saved Claude auth is no longer available', async () => { - vi.mocked( getAiProvider ).mockResolvedValue( 'anthropic-claude' ); - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [], + snapshots: [], + aiProvider: 'anthropic-claude', + } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); vi.mocked( childProcess.execFileSync ).mockImplementation( () => { throw new Error( 'not authenticated' ); } ); @@ -144,16 +163,21 @@ describe( 'AI auth helpers', () => { } ); it( 'falls back from saved WP.com provider when WordPress.com auth is unavailable and Claude auth is ready', async () => { - vi.mocked( getAiProvider ).mockResolvedValue( 'wpcom' ); - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [], + snapshots: [], + aiProvider: 'wpcom', + } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); vi.mocked( childProcess.execFileSync ).mockReturnValue( 'Authenticated' as never ); await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-claude' ); } ); it( 'defaults to WP.com when no provider is saved and a valid WP.com token exists', async () => { - vi.mocked( getAiProvider ).mockResolvedValue( undefined ); - vi.mocked( getAuthToken ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); + vi.mocked( readAuthToken ).mockResolvedValue( { accessToken: 'wpcom-token', displayName: 'User', email: 'user@example.com', @@ -166,8 +190,8 @@ describe( 'AI auth helpers', () => { } ); it( 'falls back to Anthropic API key when no other auth is available', async () => { - vi.mocked( getAiProvider ).mockResolvedValue( undefined ); - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); vi.mocked( childProcess.execFileSync ).mockImplementation( () => { throw new Error( 'not authenticated' ); } ); @@ -176,15 +200,15 @@ describe( 'AI auth helpers', () => { } ); it( 'defaults to Claude auth when no provider is saved and Claude auth is available', async () => { - vi.mocked( getAiProvider ).mockResolvedValue( undefined ); - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); vi.mocked( childProcess.execFileSync ).mockReturnValue( 'Authenticated' as never ); await expect( resolveInitialAiProvider() ).resolves.toBe( 'anthropic-claude' ); } ); it( 'reports WordPress.com readiness based on WP.com auth state', async () => { - vi.mocked( getAuthToken ).mockResolvedValue( { + vi.mocked( readAuthToken ).mockResolvedValue( { accessToken: 'wpcom-token', displayName: 'User', email: 'user@example.com', @@ -195,13 +219,18 @@ describe( 'AI auth helpers', () => { await expect( isAiProviderReady( 'wpcom' ) ).resolves.toBe( true ); - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await expect( isAiProviderReady( 'wpcom' ) ).resolves.toBe( false ); } ); it( 'resolves a fallback provider only for providers that auto-fallback', async () => { - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'not authenticated' ) ); - vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'saved-key' ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [], + snapshots: [], + anthropicApiKey: 'saved-key', + } ); await expect( resolveUnavailableAiProvider( 'wpcom' ) ).resolves.toBe( 'anthropic-api-key' ); await expect( resolveUnavailableAiProvider( 'anthropic-api-key' ) ).resolves.toBeUndefined(); diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index a9c4442430..6fe8e2675d 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -17,6 +17,7 @@ import { truncateToWidth, visibleWidth, } from '@mariozechner/pi-tui'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import chalk from 'chalk'; import { AI_MODELS, DEFAULT_MODEL, type AiModelId, type AskUserQuestion } from 'cli/ai/agent'; import { AI_PROVIDERS, DEFAULT_AI_PROVIDER, type AiProviderId } from 'cli/ai/providers'; @@ -24,7 +25,6 @@ import { AI_CHAT_SLASH_COMMANDS, type SlashCommandDef } from 'cli/ai/slash-comma import { buildTodoUpdateLines, type TodoRenderLine } from 'cli/ai/todo-render'; import { diffTodoSnapshot, type TodoDiff, type TodoEntry } from 'cli/ai/todo-stream'; import { getWpComSites } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; import { getSiteUrl } from 'cli/lib/cli-config/sites'; @@ -787,10 +787,8 @@ export class AiChatUI { } private async switchToRemoteSites(): Promise< void > { - let token: Awaited< ReturnType< typeof getAuthToken > >; - try { - token = await getAuthToken(); - } catch { + const token = await readAuthToken(); + if ( ! token ) { this.showSitePickerError( 'Not logged in. Use /login first.' ); return; } diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index e3590558d0..4a18bee0d0 100644 --- a/apps/cli/commands/ai.ts +++ b/apps/cli/commands/ai.ts @@ -1,3 +1,4 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; import { __ } from '@wordpress/i18n'; import { AI_MODELS, DEFAULT_MODEL, startAiAgent, type AiModelId } from 'cli/ai/agent'; import { @@ -22,7 +23,7 @@ import { import { AiChatUI } from 'cli/ai/ui'; import { runCommand as runLoginCommand } from 'cli/commands/auth/login'; import { runCommand as runLogoutCommand } from 'cli/commands/auth/logout'; -import { getAnthropicApiKey, getAuthToken } from 'cli/lib/appdata'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { Logger, LoggerError, setProgressCallback } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -98,15 +99,16 @@ export async function runCommand(): Promise< void > { } if ( currentProvider === 'wpcom' ) { - try { - const token = await getAuthToken(); + const token = await readAuthToken(); + if ( token ) { ui.setStatusMessage( `Logged in as ${ token.displayName } (${ token.email })` ); - } catch { + } else { ui.setStatusMessage( 'Use /login to authenticate to WordPress.com' ); } } - if ( currentProvider === 'anthropic-api-key' && ! ( await getAnthropicApiKey() ) ) { + const { anthropicApiKey } = await readCliConfig(); + if ( currentProvider === 'anthropic-api-key' && ! anthropicApiKey ) { ui.showInfo( 'No Anthropic API key saved. Use /provider to enter one.' ); } @@ -216,8 +218,10 @@ export async function runCommand(): Promise< void > { await runLoginCommand(); ui.start(); if ( await isAiProviderReady( 'wpcom' ) ) { - const token = await getAuthToken(); - ui.setStatusMessage( `Logged in as ${ token.displayName } (${ token.email })` ); + const token = await readAuthToken(); + if ( token ) { + ui.setStatusMessage( `Logged in as ${ token.displayName } (${ token.email })` ); + } } else { ui.setStatusMessage( 'Login failed or canceled' ); } diff --git a/apps/cli/commands/auth/login.ts b/apps/cli/commands/auth/login.ts index 2dfe80b41e..54587458fd 100644 --- a/apps/cli/commands/auth/login.ts +++ b/apps/cli/commands/auth/login.ts @@ -1,16 +1,10 @@ import { input } from '@inquirer/prompts'; import { DEFAULT_TOKEN_LIFETIME_MS } from '@studio/common/constants'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; +import { readAuthToken, updateSharedConfig } from '@studio/common/lib/shared-config'; import { AuthCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { getUserInfo } from 'cli/lib/api'; -import { - getAuthToken, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; import { getAppLocale } from 'cli/lib/i18n'; import { Logger, LoggerError } from 'cli/logger'; @@ -21,12 +15,10 @@ const CLI_REDIRECT_URI = `https://developer.wordpress.com/copy-oauth-token`; export async function runCommand(): Promise< void > { const logger = new Logger< LoggerAction >(); - try { - await getAuthToken(); + const existingToken = await readAuthToken(); + if ( existingToken ) { logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) ); return; - } catch ( error ) { - // Assume the token is invalid and proceed with authentication } logger.reportStart( LoggerAction.LOGIN, __( 'Opening browser for authentication…' ) ); @@ -64,27 +56,22 @@ export async function runCommand(): Promise< void > { } try { - await lockAppdata(); - const userData = await readAppdata(); - - userData.authToken = { - accessToken, - id: user.ID, - email: user.email, - displayName: user.display_name, - expiresIn: DEFAULT_TOKEN_LIFETIME_MS / 1000, - expirationTime: Date.now() + DEFAULT_TOKEN_LIFETIME_MS, - }; - - await saveAppdata( userData ); + await updateSharedConfig( { + authToken: { + accessToken, + id: user.ID, + email: user.email, + displayName: user.display_name, + expiresIn: DEFAULT_TOKEN_LIFETIME_MS / 1000, + expirationTime: Date.now() + DEFAULT_TOKEN_LIFETIME_MS, + }, + } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); } else { logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) ); } - } finally { - await unlockAppdata(); } } diff --git a/apps/cli/commands/auth/logout.ts b/apps/cli/commands/auth/logout.ts index a3d50613a1..0e49e5eb80 100644 --- a/apps/cli/commands/auth/logout.ts +++ b/apps/cli/commands/auth/logout.ts @@ -1,13 +1,7 @@ +import { readAuthToken, updateSharedConfig } from '@studio/common/lib/shared-config'; import { AuthCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { revokeAuthToken } from 'cli/lib/api'; -import { - readAppdata, - saveAppdata, - lockAppdata, - unlockAppdata, - getAuthToken, -} from 'cli/lib/appdata'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -15,21 +9,16 @@ export async function runCommand(): Promise< void > { const logger = new Logger< LoggerAction >(); logger.reportStart( LoggerAction.LOGOUT, __( 'Logging out…' ) ); - let token: Awaited< ReturnType< typeof getAuthToken > >; + const token = await readAuthToken(); - try { - token = await getAuthToken(); - } catch ( error ) { + if ( ! token ) { logger.reportSuccess( __( 'Already logged out' ) ); return; } try { - await lockAppdata(); await revokeAuthToken( token.accessToken ); - const userData = await readAppdata(); - delete userData.authToken; - await saveAppdata( userData ); + await updateSharedConfig( { authToken: undefined } ); logger.reportSuccess( __( 'Successfully logged out' ) ); } catch ( error ) { @@ -38,8 +27,6 @@ export async function runCommand(): Promise< void > { } else { logger.reportError( new LoggerError( __( 'Failed to log out' ), error ) ); } - } finally { - await unlockAppdata(); } } diff --git a/apps/cli/commands/auth/status.ts b/apps/cli/commands/auth/status.ts index f15227de8a..2bef9d90ab 100644 --- a/apps/cli/commands/auth/status.ts +++ b/apps/cli/commands/auth/status.ts @@ -1,7 +1,7 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; import { AuthCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { getUserInfo } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -9,11 +9,9 @@ export async function runCommand(): Promise< void > { const logger = new Logger< LoggerAction >(); logger.reportStart( LoggerAction.STATUS_CHECK, __( 'Checking authentication status…' ) ); - let token: Awaited< ReturnType< typeof getAuthToken > >; + const token = await readAuthToken(); - try { - token = await getAuthToken(); - } catch ( error ) { + if ( ! token ) { logger.reportError( new LoggerError( __( 'Authentication token is invalid or expired' ) ) ); return; } diff --git a/apps/cli/commands/auth/tests/login.test.ts b/apps/cli/commands/auth/tests/login.test.ts index e5da0948f3..096be786ce 100644 --- a/apps/cli/commands/auth/tests/login.test.ts +++ b/apps/cli/commands/auth/tests/login.test.ts @@ -1,14 +1,8 @@ import { input } from '@inquirer/prompts'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; +import { readAuthToken, updateSharedConfig } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { getUserInfo } from 'cli/lib/api'; -import { - getAuthToken, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; import { getAppLocale } from 'cli/lib/i18n'; import { LoggerError } from 'cli/logger'; @@ -24,8 +18,11 @@ import { runCommand } from '../login'; vi.mock( '@inquirer/prompts' ); vi.mock( '@studio/common/lib/oauth' ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), + updateSharedConfig: vi.fn(), +} ) ); vi.mock( 'cli/lib/api' ); -vi.mock( 'cli/lib/appdata' ); vi.mock( 'cli/lib/browser' ); vi.mock( 'cli/lib/i18n' ); vi.mock( 'cli/logger', () => ( { @@ -51,15 +48,13 @@ describe( 'Auth Login Command', () => { display_name: 'Test User', username: 'testuser', }; - const mockAppdata = { - authToken: { - accessToken: 'existing-token', - id: 999, - email: 'existing@example.com', - displayName: 'Existing User', - expiresIn: 1209600, - expirationTime: Date.now() + 1209600000, - }, + const mockExistingToken = { + accessToken: 'existing-token', + id: 999, + email: 'existing@example.com', + displayName: 'Existing User', + expiresIn: 1209600, + expirationTime: Date.now() + 1209600000, }; beforeEach( () => { @@ -70,11 +65,8 @@ describe( 'Auth Login Command', () => { vi.mocked( getUserInfo ).mockResolvedValue( mockUserData ); vi.mocked( openBrowser ).mockResolvedValue( undefined ); vi.mocked( input ).mockResolvedValue( mockAccessToken ); - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( mockAppdata ); - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'Mock error' ) ); - vi.mocked( lockAppdata ).mockResolvedValue( undefined ); - vi.mocked( unlockAppdata ).mockResolvedValue( undefined ); - vi.mocked( saveAppdata ).mockResolvedValue( undefined ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); + vi.mocked( updateSharedConfig ).mockResolvedValue( undefined ); } ); afterEach( () => { @@ -82,7 +74,7 @@ describe( 'Auth Login Command', () => { } ); it( 'should skip login if already authenticated', async () => { - vi.mocked( getAuthToken ).mockResolvedValue( mockAppdata.authToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockExistingToken ); await runCommand(); @@ -102,8 +94,7 @@ describe( 'Auth Login Command', () => { message: 'Authentication token:', } ); expect( getUserInfo ).toHaveBeenCalledWith( mockAccessToken ); - expect( lockAppdata ).toHaveBeenCalled(); - expect( saveAppdata ).toHaveBeenCalledWith( { + expect( updateSharedConfig ).toHaveBeenCalledWith( { authToken: { accessToken: mockAccessToken, id: mockUserData.ID, @@ -113,7 +104,6 @@ describe( 'Auth Login Command', () => { expirationTime: expect.any( Number ), }, } ); - expect( unlockAppdata ).toHaveBeenCalled(); } ); it( 'should proceed with login if existing token is invalid', async () => { @@ -143,21 +133,9 @@ describe( 'Auth Login Command', () => { expect( getUserInfo ).toHaveBeenCalled(); } ); - it( 'should unlock appdata even if save fails', async () => { + it( 'should report error if updateSharedConfig fails', async () => { const saveError = new Error( 'Failed to save' ); - vi.mocked( saveAppdata ).mockRejectedValue( saveError ); - - await runCommand(); - - expect( mockReportError ).toHaveBeenCalled(); - expect( mockReportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( lockAppdata ).toHaveBeenCalled(); - expect( unlockAppdata ).toHaveBeenCalled(); - } ); - - it( 'should handle lock appdata failure', async () => { - const lockError = new Error( 'Failed to lock' ); - vi.mocked( lockAppdata ).mockRejectedValue( lockError ); + vi.mocked( updateSharedConfig ).mockRejectedValue( saveError ); await runCommand(); diff --git a/apps/cli/commands/auth/tests/logout.test.ts b/apps/cli/commands/auth/tests/logout.test.ts index 11ce95ed88..19caec7109 100644 --- a/apps/cli/commands/auth/tests/logout.test.ts +++ b/apps/cli/commands/auth/tests/logout.test.ts @@ -1,12 +1,6 @@ +import { readAuthToken, updateSharedConfig } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { revokeAuthToken } from 'cli/lib/api'; -import { - getAuthToken, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; import { LoggerError } from 'cli/logger'; import { mockReportStart, @@ -18,7 +12,10 @@ import { } from 'cli/tests/test-utils'; import { runCommand } from '../logout'; -vi.mock( 'cli/lib/appdata' ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), + updateSharedConfig: vi.fn(), +} ) ); vi.mock( 'cli/lib/api' ); vi.mock( 'cli/logger', () => ( { Logger: class { @@ -35,30 +32,21 @@ vi.mock( 'cli/logger', () => ( { } ) ); describe( 'Auth Logout Command', () => { - function getMockAppdata() { - return { - sites: [], - snapshots: [], - authToken: { - accessToken: 'existing-token', - id: 999, - email: 'existing@example.com', - displayName: 'Existing User', - expiresIn: 1209600, - expirationTime: Date.now() + 1209600000, - }, - }; - } + const mockAuthToken = { + accessToken: 'existing-token', + id: 999, + email: 'existing@example.com', + displayName: 'Existing User', + expiresIn: 1209600, + expirationTime: Date.now() + 1209600000, + }; beforeEach( () => { vi.clearAllMocks(); - vi.mocked( getAuthToken ).mockResolvedValue( getMockAppdata().authToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockAuthToken ); vi.mocked( revokeAuthToken ).mockResolvedValue( undefined ); - vi.mocked( lockAppdata ).mockResolvedValue( undefined ); - vi.mocked( unlockAppdata ).mockResolvedValue( undefined ); - vi.mocked( readAppdata ).mockResolvedValue( getMockAppdata() ); - vi.mocked( saveAppdata ).mockResolvedValue( undefined ); + vi.mocked( updateSharedConfig ).mockResolvedValue( undefined ); } ); afterEach( () => { @@ -68,14 +56,9 @@ describe( 'Auth Logout Command', () => { it( 'should complete the logout process successfully', async () => { await runCommand(); - expect( getAuthToken ).toHaveBeenCalled(); + expect( readAuthToken ).toHaveBeenCalled(); expect( revokeAuthToken ).toHaveBeenCalled(); - expect( lockAppdata ).toHaveBeenCalled(); - expect( readAppdata ).toHaveBeenCalled(); - expect( saveAppdata ).toHaveBeenCalledWith( - expect.not.objectContaining( { authToken: expect.anything() } ) - ); - expect( unlockAppdata ).toHaveBeenCalled(); + expect( updateSharedConfig ).toHaveBeenCalledWith( { authToken: undefined } ); expect( mockReportSuccess ).toHaveBeenCalledWith( 'Successfully logged out' ); } ); @@ -84,37 +67,18 @@ describe( 'Auth Logout Command', () => { await runCommand(); - expect( getAuthToken ).toHaveBeenCalled(); - expect( lockAppdata ).toHaveBeenCalled(); - expect( readAppdata ).not.toHaveBeenCalled(); - expect( saveAppdata ).not.toHaveBeenCalledWith( {} ); - expect( unlockAppdata ).toHaveBeenCalled(); + expect( readAuthToken ).toHaveBeenCalled(); expect( mockReportError ).toHaveBeenCalled(); expect( mockReportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); } ); it( 'should report already logged out if no auth token exists', async () => { - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'No auth token' ) ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await runCommand(); - expect( getAuthToken ).toHaveBeenCalled(); + expect( readAuthToken ).toHaveBeenCalled(); expect( revokeAuthToken ).not.toHaveBeenCalled(); - expect( lockAppdata ).not.toHaveBeenCalled(); - expect( readAppdata ).not.toHaveBeenCalled(); - expect( saveAppdata ).not.toHaveBeenCalled(); expect( mockReportSuccess ).toHaveBeenCalledWith( 'Already logged out' ); } ); - - it( 'should unlock appdata even if save fails', async () => { - vi.mocked( saveAppdata ).mockRejectedValue( new Error( 'Failed to save' ) ); - - await runCommand(); - - expect( revokeAuthToken ).toHaveBeenCalled(); - expect( lockAppdata ).toHaveBeenCalled(); - expect( unlockAppdata ).toHaveBeenCalled(); - expect( mockReportError ).toHaveBeenCalled(); - expect( mockReportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - } ); } ); diff --git a/apps/cli/commands/auth/tests/status.test.ts b/apps/cli/commands/auth/tests/status.test.ts index 61909956ad..a3c0e9c8af 100644 --- a/apps/cli/commands/auth/tests/status.test.ts +++ b/apps/cli/commands/auth/tests/status.test.ts @@ -1,6 +1,6 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { getUserInfo } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { LoggerError } from 'cli/logger'; import { mockReportStart, @@ -13,7 +13,9 @@ import { import { runCommand } from '../status'; vi.mock( 'cli/lib/api' ); -vi.mock( 'cli/lib/appdata' ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/logger', () => ( { Logger: class { reportStart = mockReportStart; @@ -47,7 +49,7 @@ describe( 'Auth Status Command', () => { beforeEach( () => { vi.clearAllMocks(); - vi.mocked( getAuthToken ).mockResolvedValue( mockToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockToken ); vi.mocked( getUserInfo ).mockResolvedValue( mockUserData ); } ); @@ -59,7 +61,7 @@ describe( 'Auth Status Command', () => { await runCommand(); expect( mockReportStart ).toHaveBeenCalled(); - expect( getAuthToken ).toHaveBeenCalled(); + expect( readAuthToken ).toHaveBeenCalled(); expect( getUserInfo ).toHaveBeenCalledWith( mockToken.accessToken ); expect( mockReportSuccess ).toHaveBeenCalledWith( expect.stringContaining( 'Authenticated with WordPress.com as `testuser`' ) @@ -67,7 +69,7 @@ describe( 'Auth Status Command', () => { } ); it( 'should report error when token is invalid', async () => { - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'Token error' ) ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await runCommand(); diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index 1ffcf284cf..77ba5e6f62 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -2,10 +2,10 @@ import os from 'os'; import path from 'path'; import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { getNextSnapshotSequence } from 'cli/lib/cli-config/snapshots'; @@ -26,7 +26,12 @@ export async function runCommand( siteFolder: string, name?: string ): Promise< logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); await getSiteByFolder( siteFolder ); await validateSiteSize( siteFolder ); - const token = await getAuthToken(); + const token = await readAuthToken(); + if ( ! token ) { + throw new LoggerError( + __( 'Authentication required. Please log in with `studio auth login`.' ) + ); + } logger.reportSuccess( __( 'Validation successful' ), true ); logger.reportStart( LoggerAction.ARCHIVE, __( 'Creating archive…' ) ); diff --git a/apps/cli/commands/preview/delete.ts b/apps/cli/commands/preview/delete.ts index d6ed51338b..766bd5cbaf 100644 --- a/apps/cli/commands/preview/delete.ts +++ b/apps/cli/commands/preview/delete.ts @@ -1,8 +1,8 @@ import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { deleteSnapshot } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { emitCliEvent } from 'cli/lib/daemon-client'; import { deleteSnapshotFromConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; @@ -14,7 +14,12 @@ export async function runCommand( host: string ): Promise< void > { try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - const token = await getAuthToken(); + const token = await readAuthToken(); + if ( ! token ) { + throw new LoggerError( + __( 'Authentication required. Please log in with `studio auth login`.' ) + ); + } const snapshots = await getSnapshotsFromConfig( token.id ); const snapshotToDelete = snapshots.find( ( s ) => s.url === host ); if ( ! snapshotToDelete ) { diff --git a/apps/cli/commands/preview/list.ts b/apps/cli/commands/preview/list.ts index 6c9027a53b..1a5cc39f96 100644 --- a/apps/cli/commands/preview/list.ts +++ b/apps/cli/commands/preview/list.ts @@ -1,9 +1,10 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import CliTable3 from 'cli-table3'; import { format } from 'date-fns'; -import { getAuthToken } from 'cli/lib/appdata'; import { readCliConfig } from 'cli/lib/cli-config/core'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { formatDurationUntilExpiry, getSnapshotsFromConfig, @@ -29,7 +30,13 @@ export async function runCommand( } logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - const token = await getAuthToken(); + await getSiteByFolder( siteFolder ); + const token = await readAuthToken(); + if ( ! token ) { + throw new LoggerError( + __( 'Authentication required. Please log in with `studio auth login`.' ) + ); + } logger.reportSuccess( __( 'Validation successful' ), true ); logger.reportStart( LoggerAction.LOAD, __( 'Loading preview sites…' ) ); diff --git a/apps/cli/commands/preview/tests/create.test.ts b/apps/cli/commands/preview/tests/create.test.ts index 2da3192ea9..bad876072d 100644 --- a/apps/cli/commands/preview/tests/create.test.ts +++ b/apps/cli/commands/preview/tests/create.test.ts @@ -1,9 +1,9 @@ import os from 'os'; import path from 'path'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { saveSnapshotToConfig } from 'cli/lib/snapshots'; @@ -19,10 +19,8 @@ const mockReportWarning = vi.fn(); const mockReportKeyValuePair = vi.fn(); vi.mock( '@studio/common/lib/get-wordpress-version' ); -vi.mock( 'cli/lib/appdata', async () => ( { - ...( await vi.importActual( 'cli/lib/appdata' ) ), - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - getAuthToken: vi.fn(), +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), } ) ); vi.mock( 'cli/lib/cli-config/snapshots', async () => ( { ...( await vi.importActual( 'cli/lib/cli-config/snapshots' ) ), @@ -85,7 +83,7 @@ describe( 'Preview Create Command', () => { vi.spyOn( path, 'basename' ).mockReturnValue( mockBasename ); vi.spyOn( process, 'cwd' ).mockReturnValue( mockFolder ); - vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockAuthToken ); vi.mocked( getSiteByFolder ).mockResolvedValue( { id: 'site-123', path: mockFolder, @@ -178,11 +176,7 @@ describe( 'Preview Create Command', () => { } ); it( 'should handle authentication errors', async () => { - const errorMessage = - 'Authentication required. Please run the Studio app and authenticate first.'; - vi.mocked( getAuthToken ).mockImplementation( () => { - throw new LoggerError( errorMessage ); - } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await runCommand( mockFolder ); diff --git a/apps/cli/commands/preview/tests/delete.test.ts b/apps/cli/commands/preview/tests/delete.test.ts index 625ba19643..3dccec0fdc 100644 --- a/apps/cli/commands/preview/tests/delete.test.ts +++ b/apps/cli/commands/preview/tests/delete.test.ts @@ -1,6 +1,6 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { deleteSnapshot } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { getSnapshotsFromConfig, deleteSnapshotFromConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { @@ -13,14 +13,9 @@ import { } from 'cli/tests/test-utils'; import { runCommand } from '../delete'; -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); - return { - ...actual, - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - getAuthToken: vi.fn(), - }; -} ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/api' ); vi.mock( 'cli/lib/snapshots' ); vi.mock( 'cli/logger', () => ( { @@ -60,7 +55,7 @@ describe( 'Preview Delete Command', () => { beforeEach( () => { vi.clearAllMocks(); - vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockAuthToken ); vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ mockSnapshot ] ); vi.mocked( deleteSnapshot ).mockResolvedValue( undefined ); vi.mocked( deleteSnapshotFromConfig ).mockResolvedValue( undefined ); @@ -73,7 +68,7 @@ describe( 'Preview Delete Command', () => { it( 'should complete the preview deletion process successfully', async () => { await runCommand( mockSiteUrl ); - expect( getAuthToken ).toHaveBeenCalled(); + expect( readAuthToken ).toHaveBeenCalled(); expect( getSnapshotsFromConfig ).toHaveBeenCalledWith( mockAuthToken.id ); expect( deleteSnapshot ).toHaveBeenCalledWith( mockAtomicSiteId, mockAuthToken.accessToken ); expect( deleteSnapshotFromConfig ).toHaveBeenCalledWith( mockSiteUrl ); @@ -85,11 +80,7 @@ describe( 'Preview Delete Command', () => { } ); it( 'should handle authentication errors', async () => { - const errorMessage = - 'Authentication required. Please run the Studio app and authenticate first.'; - vi.mocked( getAuthToken ).mockImplementation( () => { - throw new LoggerError( errorMessage ); - } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await runCommand( mockSiteUrl ); diff --git a/apps/cli/commands/preview/tests/list.test.ts b/apps/cli/commands/preview/tests/list.test.ts index e74938e98c..d0b5dc3785 100644 --- a/apps/cli/commands/preview/tests/list.test.ts +++ b/apps/cli/commands/preview/tests/list.test.ts @@ -1,5 +1,5 @@ +import { readAuthToken } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; -import { getAuthToken } from 'cli/lib/appdata'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { @@ -12,14 +12,9 @@ import { } from 'cli/tests/test-utils'; import { runCommand } from '../list'; -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); - return { - ...actual, - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - getAuthToken: vi.fn(), - }; -} ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/cli-config/core', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { @@ -91,7 +86,7 @@ describe( 'Preview List Command', () => { vi.spyOn( process, 'cwd' ).mockReturnValue( mockFolder ); vi.mocked( getSiteByFolder ).mockResolvedValue( mockSite ); - vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockAuthToken ); vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( mockSnapshots ); } ); @@ -110,7 +105,7 @@ describe( 'Preview List Command', () => { } ); it( 'should handle validation errors', async () => { - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'Authentication required' ) ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await runCommand( mockFolder, 'table' ); diff --git a/apps/cli/commands/preview/tests/update.test.ts b/apps/cli/commands/preview/tests/update.test.ts index dd5447c1fa..85e85d6367 100644 --- a/apps/cli/commands/preview/tests/update.test.ts +++ b/apps/cli/commands/preview/tests/update.test.ts @@ -2,10 +2,10 @@ import os from 'os'; import path from 'path'; import { DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { Archiver } from 'archiver'; import { vi } from 'vitest'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { updateSnapshotInConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots'; @@ -14,14 +14,9 @@ import { mockReportStart, mockReportSuccess, mockReportError } from 'cli/tests/t import { runCommand } from '../update'; vi.mock( '@studio/common/lib/get-wordpress-version' ); -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); - return { - ...actual, - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - getAuthToken: vi.fn(), - }; -} ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/cli-config/sites', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); return { @@ -79,7 +74,7 @@ describe( 'Preview Update Command', () => { vi.spyOn( path, 'basename' ).mockReturnValue( mockBasename ); vi.spyOn( process, 'cwd' ).mockReturnValue( mockFolder ); - vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); + vi.mocked( readAuthToken ).mockResolvedValue( mockAuthToken ); vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ mockSnapshot ] ); vi.mocked( archiveSiteContent ).mockResolvedValue( mockArchiver as Archiver ); vi.mocked( uploadArchive ).mockResolvedValue( { @@ -142,11 +137,7 @@ describe( 'Preview Update Command', () => { } ); it( 'should handle authentication errors', async () => { - const errorMessage = - 'Authentication required. Please run the Studio app and authenticate first.'; - vi.mocked( getAuthToken ).mockImplementation( () => { - throw new LoggerError( errorMessage ); - } ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); await runCommand( mockFolder, mockSiteUrl, false ); diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index ad460280af..cfe4a13385 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -3,12 +3,12 @@ import path from 'node:path'; import { DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { Snapshot } from '@studio/common/types/snapshot'; import { __, _n, sprintf } from '@wordpress/i18n'; import { addDays } from 'date-fns'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { cleanup, archiveSiteContent } from 'cli/lib/archive'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { emitCliEvent } from 'cli/lib/daemon-client'; @@ -55,7 +55,12 @@ export async function runCommand( try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - const token = await getAuthToken(); + const token = await readAuthToken(); + if ( ! token ) { + throw new LoggerError( + __( 'Authentication required. Please log in with `studio auth login`.' ) + ); + } const snapshots = await getSnapshotsFromConfig( token.id ); const snapshotToUpdate = await getSnapshotToUpdate( snapshots, host, siteFolder, overwrite ); diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 55846535ba..93c2280b79 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -1,10 +1,10 @@ import fs from 'fs'; import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; +import { readAuthToken, type StoredToken } from '@studio/common/lib/shared-config'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import { deleteSnapshot } from 'cli/lib/api'; -import { getAuthToken, ValidatedAuthToken } from 'cli/lib/appdata'; import { deleteSiteCertificate } from 'cli/lib/certificate-manager'; import { lockCliConfig, @@ -23,7 +23,7 @@ import { StudioArgv } from 'cli/types'; const logger = new Logger< LoggerAction >(); -async function deletePreviewSites( authToken: ValidatedAuthToken, siteFolder: string ) { +async function deletePreviewSites( authToken: StoredToken, siteFolder: string ) { try { const snapshots = await getSnapshotsFromConfig( authToken.id, siteFolder ); @@ -97,11 +97,9 @@ export async function runCommand( } } - try { - const authToken = await getAuthToken(); + const authToken = await readAuthToken(); + if ( authToken ) { await deletePreviewSites( authToken, siteFolder ); - } catch ( error ) { - // `getAuthToken` throws, but `deletePreviewSites` does not. Proceed anyway } try { diff --git a/apps/cli/commands/site/tests/delete.test.ts b/apps/cli/commands/site/tests/delete.test.ts index 9f11894893..fd9f9cfea5 100644 --- a/apps/cli/commands/site/tests/delete.test.ts +++ b/apps/cli/commands/site/tests/delete.test.ts @@ -1,9 +1,9 @@ import fs from 'fs'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import trash from 'trash'; import { vi } from 'vitest'; import { deleteSnapshot } from 'cli/lib/api'; -import { getAuthToken } from 'cli/lib/appdata'; import { deleteSiteCertificate } from 'cli/lib/certificate-manager'; import { SiteData, @@ -23,13 +23,9 @@ import { runCommand } from '../delete'; vi.mock( 'fs/promises' ); vi.mock( 'cli/lib/api' ); -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); - return { - ...actual, - getAuthToken: vi.fn(), - }; -} ); +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/cli-config/core', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { @@ -114,7 +110,7 @@ describe( 'CLI: studio site delete', () => { vi.mocked( getSiteByFolder ).mockResolvedValue( testSite ); vi.mocked( connectToDaemon ).mockResolvedValue( undefined ); vi.mocked( disconnectFromDaemon ).mockResolvedValue( undefined ); - vi.mocked( getAuthToken ).mockResolvedValue( testAuthToken ); + vi.mocked( readAuthToken ).mockResolvedValue( testAuthToken ); vi.mocked( lockCliConfig ).mockResolvedValue( undefined ); vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { version: 1, @@ -184,8 +180,8 @@ describe( 'CLI: studio site delete', () => { expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); - it( 'should proceed when getAuthToken fails', async () => { - vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'Auth failed' ) ); + it( 'should proceed when readAuthToken returns null', async () => { + vi.mocked( readAuthToken ).mockResolvedValue( null ); vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await expect( runCommand( testSiteFolder, false ) ).resolves.not.toThrow(); diff --git a/apps/cli/lib/appdata.ts b/apps/cli/lib/appdata.ts deleted file mode 100644 index 8c37818fc9..0000000000 --- a/apps/cli/lib/appdata.ts +++ /dev/null @@ -1,183 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { LOCKFILE_NAME, LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; -import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { getAuthenticationUrl } from '@studio/common/lib/oauth'; -import { __, sprintf } from '@wordpress/i18n'; -import { readFile, writeFile } from 'atomically'; -import { z } from 'zod'; -import { validateAccessToken } from 'cli/lib/api'; -import { LoggerError } from 'cli/logger'; -import type { AiProviderId } from 'cli/ai/providers'; - -const betaFeaturesSchema = z.object( {} ).loose(); -const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-key' ] ); - -const userDataSchema = z - .object( { - locale: z.string().optional(), - aiProvider: aiProviderSchema.optional(), - authToken: z - .object( { - accessToken: z.string().min( 1, __( 'Access token cannot be empty' ) ), - expiresIn: z.number(), // Seconds - expirationTime: z.number(), // Milliseconds since the Unix epoch - id: z.number().optional(), - email: z.string(), - displayName: z.string().default( '' ), - } ) - .loose() - .optional(), - lastBumpStats: z.record( z.string(), z.record( z.string(), z.number() ) ).optional(), - betaFeatures: betaFeaturesSchema.optional(), - } ) - .loose(); - -type UserData = z.infer< typeof userDataSchema > & { - anthropicApiKey?: string; -}; -export type ValidatedAuthToken = Required< NonNullable< UserData[ 'authToken' ] > >; - -export function getAppdataDirectory(): string { - // Support E2E testing with custom appdata path - // Must include 'Studio' subfolder to match Electron app's path structure - if ( process.env.E2E && process.env.E2E_APP_DATA_PATH ) { - return path.join( process.env.E2E_APP_DATA_PATH, 'Studio' ); - } - - if ( process.platform === 'win32' ) { - if ( ! process.env.APPDATA ) { - throw new LoggerError( __( 'Studio config file path not found.' ) ); - } - - return path.join( process.env.APPDATA, 'Studio' ); - } - - return path.join( os.homedir(), 'Library', 'Application Support', 'Studio' ); -} - -export function getAppdataPath(): string { - if ( process.env.DEV_APP_DATA_PATH ) { - return process.env.DEV_APP_DATA_PATH; - } - return path.join( getAppdataDirectory(), 'appdata-v1.json' ); -} - -export async function readAppdata(): Promise< UserData > { - const appDataPath = getAppdataPath(); - - if ( ! fs.existsSync( appDataPath ) ) { - throw new LoggerError( __( 'Studio config file not found. Please run the Studio app first.' ) ); - } - - try { - const fileContent = await readFile( appDataPath, { encoding: 'utf8' } ); - const userData = JSON.parse( fileContent ); - return userDataSchema.parse( userData ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - throw error; - } - - if ( error instanceof z.ZodError ) { - throw new LoggerError( - __( 'Invalid Studio config file format. Please run the Studio app again.' ), - error - ); - } - - if ( error instanceof SyntaxError ) { - throw new LoggerError( - __( 'Studio config file is corrupted. Please run the Studio app again.' ), - error - ); - } - - throw new LoggerError( - __( 'Failed to read Studio config file. Please run the Studio app again.' ), - error - ); - } -} - -export async function saveAppdata( userData: UserData ): Promise< void > { - try { - if ( ! userData.version ) { - userData.version = 1; - } - - const appDataPath = getAppdataPath(); - const fileContent = JSON.stringify( userData, null, 2 ) + '\n'; - - await writeFile( appDataPath, fileContent, { encoding: 'utf8' } ); - } catch ( error ) { - throw new LoggerError( __( 'Failed to save Studio config file' ), error ); - } -} - -const LOCKFILE_PATH = path.join( getAppdataDirectory(), LOCKFILE_NAME ); - -export async function lockAppdata(): Promise< void > { - await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); -} - -export async function unlockAppdata(): Promise< void > { - await unlockFileAsync( LOCKFILE_PATH ); -} - -export async function getAuthToken(): Promise< ValidatedAuthToken > { - try { - const { authToken } = await readAppdata(); - - if ( ! authToken?.accessToken || ! authToken?.id || Date.now() >= authToken?.expirationTime ) { - throw new Error( 'Authentication required' ); - } - - await validateAccessToken( authToken.accessToken ); - - return authToken as ValidatedAuthToken; - } catch ( error ) { - const authUrl = getAuthenticationUrl( 'en' ); - - throw new LoggerError( - sprintf( - // translators: %s is a URL to log in to WordPress.com - __( 'Authentication required. Please log in to WordPress.com first:\n%s' ), - authUrl - ) - ); - } -} - -export async function getAnthropicApiKey(): Promise< string | undefined > { - const userData = await readAppdata(); - return userData.anthropicApiKey; -} - -export async function getAiProvider(): Promise< AiProviderId | undefined > { - const userData = await readAppdata(); - return userData.aiProvider; -} - -export async function saveAnthropicApiKey( apiKey: string ): Promise< void > { - try { - await lockAppdata(); - const userData = await readAppdata(); - userData.anthropicApiKey = apiKey; - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} - -export async function saveAiProvider( provider: AiProviderId ): Promise< void > { - try { - await lockAppdata(); - const userData = await readAppdata(); - userData.aiProvider = provider; - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} diff --git a/apps/cli/lib/bump-stat.ts b/apps/cli/lib/bump-stat.ts index 5bff7cb062..f04b0a604a 100644 --- a/apps/cli/lib/bump-stat.ts +++ b/apps/cli/lib/bump-stat.ts @@ -4,22 +4,27 @@ import { AggregateInterval, LastBumpStatsProvider, } from '@studio/common/lib/bump-stat'; -import { lockAppdata, readAppdata, saveAppdata, unlockAppdata } from 'cli/lib/appdata'; +import { + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, +} from 'cli/lib/cli-config/core'; import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats'; const lastBumpStatsProvider: LastBumpStatsProvider = { load: async () => { - const { lastBumpStats } = await readAppdata(); + const { lastBumpStats } = await readCliConfig(); return lastBumpStats ?? {}; }, - lock: lockAppdata, - unlock: unlockAppdata, + lock: lockCliConfig, + unlock: unlockCliConfig, save: async ( lastBumpStats ) => { - const appdata = await readAppdata(); - appdata.lastBumpStats = lastBumpStats; + const config = await readCliConfig(); + config.lastBumpStats = lastBumpStats; // Locking is handled in `@studio/common/lib/bump-stat` // eslint-disable-next-line studio/require-lock-before-save - await saveAppdata( appdata ); + await saveCliConfig( config ); }, }; diff --git a/apps/cli/lib/certificate-manager.ts b/apps/cli/lib/certificate-manager.ts index 74dc06058e..2490b350ca 100644 --- a/apps/cli/lib/certificate-manager.ts +++ b/apps/cli/lib/certificate-manager.ts @@ -4,9 +4,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { domainToASCII } from 'node:url'; import { promisify } from 'node:util'; +import os from 'os'; import sudo from '@vscode/sudo-prompt'; +import { __ } from '@wordpress/i18n'; import forge from 'node-forge'; -import { getAppdataDirectory } from 'cli/lib/appdata'; +import { LoggerError } from 'cli/logger'; const execFilePromise = promisify( execFile ); @@ -66,6 +68,22 @@ function createNameConstraintsExtension( domains: string[] ) { const CA_NAME = 'WordPress Studio CA'; const CA_CERT_VALIDITY_DAYS = 3650; // 10 years const SITE_CERT_VALIDITY_DAYS = 825; // a little over 2 years +function getAppdataDirectory(): string { + if ( process.env.E2E && process.env.E2E_APP_DATA_PATH ) { + return path.join( process.env.E2E_APP_DATA_PATH, 'Studio' ); + } + + if ( process.platform === 'win32' ) { + if ( ! process.env.APPDATA ) { + throw new LoggerError( __( 'Studio config file path not found.' ) ); + } + + return path.join( process.env.APPDATA, 'Studio' ); + } + + return path.join( os.homedir(), 'Library', 'Application Support', 'Studio' ); +} + const CERT_DIRECTORY = path.join( getAppdataDirectory(), 'certificates' ); const CA_CERT_PATH = path.join( CERT_DIRECTORY, 'studio-ca.crt' ); const CA_KEY_PATH = path.join( CERT_DIRECTORY, 'studio-ca.key' ); diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index bdae5c5ea6..8438bf6746 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -8,6 +8,7 @@ import { __ } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; import { STUDIO_CLI_HOME } from 'cli/lib/paths'; +import { StatsMetric } from 'cli/lib/types/bump-stats'; import { LoggerError } from 'cli/logger'; const siteSchema = siteDetailsSchema @@ -22,9 +23,16 @@ const cliConfigWithJustVersion = z.object( { } ); // IMPORTANT: Always consider that independently installed versions of the CLI (from npm) may also // read this file, and any updates to this schema may require updating the `version` field. +const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-key' ] ); + const cliConfigSchema = cliConfigWithJustVersion.extend( { sites: z.array( siteSchema ).default( () => [] ), snapshots: z.array( snapshotSchema ).default( () => [] ), + aiProvider: aiProviderSchema.optional(), + anthropicApiKey: z.string().optional(), + lastBumpStats: z + .record( z.string(), z.partialRecord( z.enum( StatsMetric ), z.number() ) ) + .optional(), } ); type CliConfig = z.infer< typeof cliConfigSchema >; @@ -119,3 +127,16 @@ export async function lockCliConfig(): Promise< void > { export async function unlockCliConfig(): Promise< void > { await unlockFileAsync( LOCKFILE_PATH ); } + +export async function updateCliConfig( + update: Partial< Omit< CliConfig, 'version' | 'sites' > > +): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const updated = { ...config, ...update }; + await saveCliConfig( updated ); + } finally { + await unlockCliConfig(); + } +} diff --git a/apps/cli/lib/i18n.ts b/apps/cli/lib/i18n.ts index 0a0e21a791..b77b649bdd 100644 --- a/apps/cli/lib/i18n.ts +++ b/apps/cli/lib/i18n.ts @@ -4,15 +4,15 @@ import { DEFAULT_LOCALE, isSupportedLocale, } from '@studio/common/lib/locale'; +import { readSharedConfig } from '@studio/common/lib/shared-config'; import { defaultI18n } from '@wordpress/i18n'; -import { readAppdata } from 'cli/lib/appdata'; -async function getLocaleFromAppdata(): Promise< SupportedLocale | undefined > { +async function getLocaleFromSharedConfig(): Promise< SupportedLocale | undefined > { try { - const appdata = await readAppdata(); - return isSupportedLocale( appdata.locale ) ? appdata.locale : undefined; + const config = await readSharedConfig(); + return isSupportedLocale( config.locale ) ? config.locale : undefined; } catch ( error ) { - console.error( 'Error reading appdata', error ); + console.error( 'Error reading shared config', error ); return undefined; } } @@ -40,7 +40,7 @@ function mapToYargsLocale( locale: SupportedLocale ): string { } export async function getAppLocale(): Promise< SupportedLocale > { - const appdataLocale = await getLocaleFromAppdata(); + const appdataLocale = await getLocaleFromSharedConfig(); const envLocale = getLocaleFromEnvironment(); return appdataLocale || envLocale || DEFAULT_LOCALE; } diff --git a/apps/cli/lib/server-files.ts b/apps/cli/lib/server-files.ts index b148eb4733..e210f5afe4 100644 --- a/apps/cli/lib/server-files.ts +++ b/apps/cli/lib/server-files.ts @@ -1,9 +1,27 @@ +import os from 'os'; import path from 'path'; -import { getAppdataDirectory } from 'cli/lib/appdata'; +import { __ } from '@wordpress/i18n'; +import { LoggerError } from 'cli/logger'; const WP_CLI_PHAR_FILENAME = 'wp-cli.phar'; const SQLITE_COMMAND_FOLDER = 'sqlite-command'; +function getAppdataDirectory(): string { + if ( process.env.E2E && process.env.E2E_APP_DATA_PATH ) { + return path.join( process.env.E2E_APP_DATA_PATH, 'Studio' ); + } + + if ( process.platform === 'win32' ) { + if ( ! process.env.APPDATA ) { + throw new LoggerError( __( 'Studio config file path not found.' ) ); + } + + return path.join( process.env.APPDATA, 'Studio' ); + } + + return path.join( os.homedir(), 'Library', 'Application Support', 'Studio' ); +} + export function getServerFilesPath(): string { return path.join( getAppdataDirectory(), 'server-files' ); } diff --git a/apps/cli/lib/tests/appdata.test.ts b/apps/cli/lib/tests/appdata.test.ts deleted file mode 100644 index 479d690dea..0000000000 --- a/apps/cli/lib/tests/appdata.test.ts +++ /dev/null @@ -1,238 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { arePathsEqual } from '@studio/common/lib/fs-utils'; -import { readFile, writeFile } from 'atomically'; -import { vi } from 'vitest'; -import { - readAppdata, - saveAppdata, - getAuthToken, - lockAppdata, - unlockAppdata, -} from 'cli/lib/appdata'; -import { StatsMetric } from 'cli/lib/types/bump-stats'; - -vi.mock( 'fs', () => ( { - default: { - existsSync: vi.fn(), - }, -} ) ); -vi.mock( 'os', () => ( { - default: { - homedir: vi.fn(), - }, -} ) ); -vi.mock( 'path', () => ( { - default: { - join: vi.fn(), - basename: vi.fn(), - resolve: vi.fn(), - }, -} ) ); -vi.mock( 'atomically', () => ( { - readFile: vi.fn(), - writeFile: vi.fn(), -} ) ); - -vi.mock( '@studio/common/lib/fs-utils', () => ( { - arePathsEqual: vi.fn(), -} ) ); -vi.mock( 'cli/lib/api', () => ( { - validateAccessToken: vi.fn().mockResolvedValue( undefined ), -} ) ); - -describe( 'Appdata Module', () => { - const mockHomeDir = '/mock/home'; - const mockSiteFolderName = 'folder'; - - beforeEach( () => { - vi.clearAllMocks(); - vi.mocked( os.homedir ).mockReturnValue( mockHomeDir ); - vi.mocked( path.join ).mockImplementation( ( ...args ) => args.join( '/' ) ); - vi.mocked( path.basename ).mockReturnValue( mockSiteFolderName ); - vi.mocked( path.resolve ).mockImplementation( ( path ) => path ); - vi.spyOn( Date, 'now' ).mockReturnValue( 1234567890 ); - - vi.mocked( fs.existsSync ).mockReturnValue( true ); - vi.mocked( arePathsEqual ).mockImplementation( ( path1, path2 ) => path1 === path2 ); - vi.mocked( readFile ).mockResolvedValue( Buffer.from( '{}' ) ); - vi.mocked( writeFile ).mockResolvedValue( undefined ); - } ); - - describe( 'readAppdata', () => { - it( 'should throw LoggerError if appdata file does not exist', async () => { - vi.mocked( fs.existsSync ).mockReturnValue( false ); - await expect( readAppdata() ).rejects.toThrow( 'Studio config file not found' ); - } ); - - it( 'should return parsed appdata if it exists and is valid', async () => { - const mockUserData = { - version: 1, - sites: [], - snapshots: [ - { - url: 'example.com', - atomicSiteId: 123, - name: 'Example site', - localSiteId: 'site1', - date: 1234567, - }, - ], - }; - - vi.mocked( readFile ).mockResolvedValueOnce( Buffer.from( JSON.stringify( mockUserData ) ) ); - - const result = await readAppdata(); - expect( result ).toEqual( mockUserData ); - } ); - - it( 'should correctly validate lastBumpStats with local-environment-launch-uniques key', async () => { - const mockUserData = { - version: 1, - sites: [], - snapshots: [], - lastBumpStats: { - 'local-environment-launch-uniques': { - [ StatsMetric.DARWIN ]: 5, - }, - }, - }; - - vi.mocked( readFile ).mockResolvedValueOnce( Buffer.from( JSON.stringify( mockUserData ) ) ); - - const result = await readAppdata(); - expect( result ).toEqual( mockUserData ); - } ); - - it( 'should throw LoggerError if there is an error reading the file', async () => { - vi.mocked( readFile ).mockRejectedValue( new Error( 'Read error' ) ); - - await expect( readAppdata() ).rejects.toThrow( 'Failed to read Studio config file' ); - } ); - - it( 'should throw LoggerError if there is an error parsing the JSON', async () => { - vi.mocked( readFile ).mockResolvedValueOnce( Buffer.from( 'invalid json{' ) ); - - await expect( readAppdata() ).rejects.toThrow( 'corrupted' ); - } ); - } ); - - describe( 'saveAppdata', () => { - it( 'should save the userData to the appdata file', async () => { - const mockUserData = { - version: 1, - sites: [], - snapshots: [], - }; - - try { - await lockAppdata(); - await saveAppdata( mockUserData ); - } finally { - await unlockAppdata(); - } - - expect( writeFile ).toHaveBeenCalledWith( - expect.any( String ), - JSON.stringify( mockUserData, null, 2 ) + '\n', - { encoding: 'utf8' } - ); - } ); - - it( 'should throw LoggerError if there is an error saving the file', async () => { - const mockUserData = { - version: 1, - sites: [], - snapshots: [], - }; - - vi.mocked( writeFile ).mockRejectedValue( new Error( 'Write error' ) ); - - try { - await lockAppdata(); - await expect( saveAppdata( mockUserData ) ).rejects.toThrow( - 'Failed to save Studio config file' - ); - } finally { - await unlockAppdata(); - } - } ); - - it( 'should add version 1 if version is not provided', async () => { - const mockUserData = { - sites: [], - snapshots: [], - }; - - try { - await lockAppdata(); - await saveAppdata( mockUserData ); - } finally { - await unlockAppdata(); - } - - expect( writeFile ).toHaveBeenCalled(); - const savedData = JSON.parse( vi.mocked( writeFile ).mock.calls[ 0 ][ 1 ] as string ); - expect( savedData.version ).toBe( 1 ); - } ); - } ); - - describe( 'getAuthToken', () => { - it( 'should return auth token when it exists', async () => { - const mockAuthToken = { - accessToken: 'valid-token', - displayName: 'User Name', - email: 'user@example.com', - expirationTime: Date.now() + 3600000, // 1 hour in the future - expiresIn: 3600, - id: 123, - }; - - vi.mocked( readFile ).mockResolvedValueOnce( - Buffer.from( - JSON.stringify( { - version: 1, - authToken: mockAuthToken, - sites: [], - snapshots: [], - } ) - ) - ); - - const result = await getAuthToken(); - expect( result ).toEqual( mockAuthToken ); - } ); - - it( 'should throw LoggerError when auth token is missing', async () => { - vi.mocked( readFile ).mockResolvedValueOnce( - Buffer.from( - JSON.stringify( { - version: 1, - sites: [], - snapshots: [], - } ) - ) - ); - - await expect( getAuthToken() ).rejects.toThrow( 'Authentication required' ); - } ); - - it( 'should throw LoggerError when access token is missing', async () => { - vi.mocked( readFile ).mockResolvedValueOnce( - Buffer.from( - JSON.stringify( { - version: 1, - authToken: { - id: 123, - }, - sites: [], - snapshots: [], - } ) - ) - ); - - await expect( getAuthToken() ).rejects.toThrow( 'Authentication required' ); - } ); - } ); -} ); diff --git a/apps/studio/src/components/auth-provider.tsx b/apps/studio/src/components/auth-provider.tsx index 8afb587e36..4edb8ac0ac 100644 --- a/apps/studio/src/components/auth-provider.tsx +++ b/apps/studio/src/components/auth-provider.tsx @@ -79,6 +79,15 @@ const AuthProvider: React.FC< AuthProviderProps > = ( { children } ) => { } const { token } = payload; + + if ( ! token ) { + setIsAuthenticated( false ); + setClient( undefined ); + setWpcomClient( undefined ); + setUser( undefined ); + return; + } + const newClient = createWpcomClient( token.accessToken, locale, handleInvalidToken ); setIsAuthenticated( true ); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 22b3948feb..b05c0ae08b 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -35,6 +35,7 @@ import { import { handleDeeplink } from 'src/lib/deeplink'; import { getUserLocaleWithFallback } from 'src/lib/locale-node'; import { getSentryReleaseInfo } from 'src/lib/sentry-release'; +import { startSharedConfigWatcher, stopSharedConfigWatcher } from 'src/lib/shared-config-watcher'; import { startUserDataWatcher, stopUserDataWatcher } from 'src/lib/user-data-watcher'; import { setupLogging } from 'src/logging'; import { createMainWindow, getMainWindow } from 'src/main-window'; @@ -328,6 +329,7 @@ async function appBoot() { await startCliEventsSubscriber(); await startUserDataWatcher(); + await startSharedConfigWatcher(); await createMainWindow(); @@ -479,6 +481,7 @@ async function appBoot() { app.on( 'will-quit', ( event ) => { globalShortcut.unregisterAll(); stopUserDataWatcher(); + stopSharedConfigWatcher(); stopCliEventsSubscriber(); if ( shouldStopSitesOnQuit ) { diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 7d68add851..9d7d71aa58 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -33,6 +33,7 @@ import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; import { decodePassword, encodePassword } from '@studio/common/lib/passwords'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; +import { updateSharedConfig } from '@studio/common/lib/shared-config'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { MACOS_TRAFFIC_LIGHT_POSITION, MAIN_MIN_WIDTH, SIDEBAR_WIDTH } from 'src/constants'; @@ -733,7 +734,7 @@ export async function isAuthenticated() { } export async function clearAuthenticationToken() { - return await updateAppdata( { authToken: undefined } ); + return await updateSharedConfig( { authToken: undefined } ); } export async function exportSite( diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 0243dfbcfb..b567ee7d11 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -27,7 +27,7 @@ export interface IpcEvents { warnings?: BlueprintValidationWarning[]; }, ]; - 'auth-updated': [ { token: StoredToken } | { error: unknown } ]; + 'auth-updated': [ { token: StoredToken } | { token: null } | { error: unknown } ]; 'on-export': [ ImportExportEventData, string ]; 'on-import': [ ImportExportEventData, string ]; 'on-site-create-progress': [ { siteId: string; message: string } ]; diff --git a/apps/studio/src/lib/deeplink/handlers/auth.ts b/apps/studio/src/lib/deeplink/handlers/auth.ts index a7227cc728..50e7c9949b 100644 --- a/apps/studio/src/lib/deeplink/handlers/auth.ts +++ b/apps/studio/src/lib/deeplink/handlers/auth.ts @@ -1,18 +1,9 @@ import * as Sentry from '@sentry/electron/main'; +import { updateSharedConfig, authTokenSchema } from '@studio/common/lib/shared-config'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; import { z } from 'zod'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; -import { updateAppdata } from 'src/storage/user-data'; - -const authTokenSchema = z.object( { - accessToken: z.string(), - expiresIn: z.number(), - expirationTime: z.number(), - id: z.number(), - email: z.string(), - displayName: z.string().default( '' ), -} ); const meResponseSchema = z.object( { ID: z.number(), @@ -62,7 +53,7 @@ export async function handleAuthDeeplink( urlObject: URL ): Promise< void > { const { hash } = urlObject; try { const authResult = await handleAuthCallback( hash ); - await updateAppdata( { authToken: authResult } ); + await updateSharedConfig( { authToken: authResult } ); void sendIpcEventToRenderer( 'auth-updated', { token: authResult } ); } catch ( error ) { Sentry.captureException( error ); diff --git a/apps/studio/src/lib/locale-node.ts b/apps/studio/src/lib/locale-node.ts index 8d5421bc09..18c01f10dc 100644 --- a/apps/studio/src/lib/locale-node.ts +++ b/apps/studio/src/lib/locale-node.ts @@ -6,7 +6,7 @@ import { SupportedLocale, supportedLocales, } from '@studio/common/lib/locale'; -import { loadUserData } from 'src/storage/user-data'; +import { readSharedConfig } from '@studio/common/lib/shared-config'; export function getSupportedLocale() { // `app.getLocale` returns the current application locale, acquired using @@ -17,7 +17,7 @@ export function getSupportedLocale() { export async function getUserLocaleWithFallback() { try { - const { locale } = await loadUserData(); + const { locale } = await readSharedConfig(); if ( ! locale || ! isSupportedLocale( locale ) ) { return getSupportedLocale(); } diff --git a/apps/studio/src/lib/oauth.ts b/apps/studio/src/lib/oauth.ts index e59f151520..10c8de914b 100644 --- a/apps/studio/src/lib/oauth.ts +++ b/apps/studio/src/lib/oauth.ts @@ -1,28 +1,9 @@ import { CLIENT_ID } from '@studio/common/constants'; import { SupportedLocale } from '@studio/common/lib/locale'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; -import { z } from 'zod'; -import { loadUserData } from 'src/storage/user-data'; +import { readAuthToken, type StoredToken } from '@studio/common/lib/shared-config'; -const authTokenSchema = z.object( { - accessToken: z.string(), - expiresIn: z.number(), - expirationTime: z.number(), - id: z.number(), - email: z.string(), - displayName: z.string().default( '' ), -} ); - -export type StoredToken = z.infer< typeof authTokenSchema >; - -async function getToken(): Promise< StoredToken | null > { - try { - const userData = await loadUserData(); - return authTokenSchema.parse( userData.authToken ); - } catch ( error ) { - return null; - } -} +export type { StoredToken } from '@studio/common/lib/shared-config'; export function getSignUpUrl( locale: SupportedLocale ) { const oauth2Redirect = encodeURIComponent( getAuthenticationUrl( locale ) ); @@ -30,12 +11,7 @@ export function getSignUpUrl( locale: SupportedLocale ) { } export async function getAuthenticationToken(): Promise< StoredToken | null > { - // Check if tokens already exist and are valid - const existingToken = await getToken(); - if ( existingToken && new Date().getTime() < existingToken.expirationTime ) { - return existingToken; - } - return null; + return readAuthToken(); } export async function isAuthenticated(): Promise< boolean > { diff --git a/apps/studio/src/lib/shared-config-watcher.ts b/apps/studio/src/lib/shared-config-watcher.ts new file mode 100644 index 0000000000..3f3c9f3251 --- /dev/null +++ b/apps/studio/src/lib/shared-config-watcher.ts @@ -0,0 +1,62 @@ +import fs from 'fs'; +import { readAuthToken, getSharedConfigPath } from '@studio/common/lib/shared-config'; +import { sendIpcEventToRenderer } from 'src/ipc-utils'; +import { getMainWindow } from 'src/main-window'; + +let watcher: fs.FSWatcher | null = null; +let lastTokenJson: string | null = null; + +export async function startSharedConfigWatcher() { + if ( watcher ) { + return; + } + + const filePath = getSharedConfigPath(); + + // Capture initial state + lastTokenJson = JSON.stringify( await readAuthToken() ); + + const fsEventHandler = async ( eventType: string ) => { + await checkAuthChange(); + if ( eventType === 'rename' && watcher ) { + watcher.close(); + watcher = fs.watch( filePath, fsEventHandler ); + } + }; + + watcher = fs.watch( filePath, fsEventHandler ); +} + +export function stopSharedConfigWatcher() { + if ( watcher ) { + watcher.close(); + watcher = null; + } +} + +async function checkAuthChange() { + try { + const token = await readAuthToken(); + const tokenJson = JSON.stringify( token ); + + if ( tokenJson === lastTokenJson ) { + return; + } + + lastTokenJson = tokenJson; + + const mainWindow = await getMainWindow(); + if ( ! mainWindow || mainWindow.isDestroyed() ) { + return; + } + + if ( token ) { + await sendIpcEventToRenderer( 'auth-updated', { token } ); + } else { + // Token was removed — renderer needs to know to log out + await sendIpcEventToRenderer( 'auth-updated', { token: null } ); + } + } catch { + // Ignore read errors (file may be mid-write) + } +} diff --git a/apps/studio/src/lib/tests/oauth.test.ts b/apps/studio/src/lib/tests/oauth.test.ts index ddc1cd22df..59daad5c96 100644 --- a/apps/studio/src/lib/tests/oauth.test.ts +++ b/apps/studio/src/lib/tests/oauth.test.ts @@ -1,11 +1,11 @@ import { SupportedLocale } from '@studio/common/lib/locale'; -import { readFile } from 'atomically'; +import { readAuthToken } from '@studio/common/lib/shared-config'; import { vi } from 'vitest'; import { getAuthenticationToken, getSignUpUrl } from 'src/lib/oauth'; vi.mock( 'src/lib/certificate-manager', () => ( {} ) ); -vi.mock( 'atomically', () => ( { - readFile: vi.fn(), +vi.mock( '@studio/common/lib/shared-config', () => ( { + readAuthToken: vi.fn(), } ) ); vi.mock( '@studio/common/lib/wpcom-factory', () => ( { __esModule: true, @@ -26,46 +26,28 @@ describe( 'getAuthenticationToken', () => { email: 'user@example.com', displayName: 'Test User', }; - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( JSON.stringify( { authToken: validToken, sites: [] } ) ) - ); + vi.mocked( readAuthToken ).mockResolvedValue( validToken ); const result = await getAuthenticationToken(); expect( result ).toEqual( validToken ); } ); it( 'should return null for expired token', async () => { - const expiredToken = { - accessToken: 'expired-token', - expiresIn: 3600, - expirationTime: new Date().getTime() - 1000, // Past time - id: 123, - email: 'user@example.com', - displayName: 'Test User', - }; - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( JSON.stringify( { authToken: expiredToken, sites: [] } ) ) - ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); const result = await getAuthenticationToken(); expect( result ).toBeNull(); } ); it( 'should return null for malformed token data', async () => { - const malformedToken = { - accessToken: 'token', - // Missing required fields - }; - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( JSON.stringify( { authToken: malformedToken, sites: [] } ) ) - ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); const result = await getAuthenticationToken(); expect( result ).toBeNull(); } ); it( 'should return null when no token exists', async () => { - vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( { sites: [] } ) ) ); + vi.mocked( readAuthToken ).mockResolvedValue( null ); const result = await getAuthenticationToken(); expect( result ).toBeNull(); diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index 277aaa935d..3945732f2a 100644 --- a/apps/studio/src/modules/sync/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/sync/lib/ipc-handlers.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import fsPromises from 'fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; +import { getCurrentUserId } from '@studio/common/lib/shared-config'; import wpcomFactory from '@studio/common/lib/wpcom-factory'; import wpcomXhrRequest from '@studio/common/lib/wpcom-xhr-request-factory'; import { Upload } from 'tus-js-client'; @@ -420,13 +421,14 @@ type WpcomSitesToConnect = { sites: SyncSite[]; localSiteId: string }[]; export async function connectWpcomSites( event: IpcMainInvokeEvent, list: WpcomSitesToConnect ) { try { await lockAppdata(); - const userData = await loadUserData(); - const currentUserId = userData.authToken?.id; + const currentUserId = await getCurrentUserId(); if ( ! currentUserId ) { throw new Error( 'User not authenticated' ); } + const userData = await loadUserData(); + userData.connectedWpcomSites = userData.connectedWpcomSites || {}; userData.connectedWpcomSites[ currentUserId ] = userData.connectedWpcomSites[ currentUserId ] || []; @@ -464,13 +466,14 @@ export async function disconnectWpcomSites( ) { try { await lockAppdata(); - const userData = await loadUserData(); - const currentUserId = userData.authToken?.id; + const currentUserId = await getCurrentUserId(); if ( ! currentUserId ) { throw new Error( 'User not authenticated' ); } + const userData = await loadUserData(); + const connectedWpcomSites = userData.connectedWpcomSites; // Totally unreal case, added it to help TS parse the code below. And if this error happens, we definitely have something wrong. @@ -500,13 +503,14 @@ export async function updateConnectedWpcomSites( ) { try { await lockAppdata(); - const userData = await loadUserData(); - const currentUserId = userData.authToken?.id; + const currentUserId = await getCurrentUserId(); if ( ! currentUserId ) { throw new Error( 'User not authenticated' ); } + const userData = await loadUserData(); + const connections = userData.connectedWpcomSites?.[ currentUserId ] || []; if ( ! connections.length ) { @@ -535,13 +539,14 @@ export async function updateSingleConnectedWpcomSite( ) { try { await lockAppdata(); - const userData = await loadUserData(); - const currentUserId = userData.authToken?.id; + const currentUserId = await getCurrentUserId(); if ( ! currentUserId ) { throw new Error( 'User not authenticated' ); } + const userData = await loadUserData(); + const connections = userData.connectedWpcomSites?.[ currentUserId ] || []; const index = connections.findIndex( ( conn ) => conn.id === updatedSite.id && conn.localSiteId === updatedSite.localSiteId @@ -561,14 +566,14 @@ export async function getConnectedWpcomSites( event: IpcMainInvokeEvent, localSiteId?: string ): Promise< SyncSite[] > { - const userData = await loadUserData(); - - const currentUserId = userData.authToken?.id; + const currentUserId = await getCurrentUserId(); if ( ! currentUserId ) { return []; } + const userData = await loadUserData(); + const allConnected = userData.connectedWpcomSites?.[ currentUserId ] || []; if ( localSiteId ) { diff --git a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts index 392bc18239..4e5c62a306 100644 --- a/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/user-settings/lib/ipc-handlers.ts @@ -1,4 +1,5 @@ import { BrowserWindow, IpcMainInvokeEvent } from 'electron'; +import { updateSharedConfig } from '@studio/common/lib/shared-config'; import { DEFAULT_TERMINAL } from 'src/constants'; import { sendIpcEventToRenderer, sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { isInstalled } from 'src/lib/is-installed'; @@ -39,7 +40,7 @@ export async function getUserTerminal() { } export async function saveUserLocale( event: IpcMainInvokeEvent, locale: string ) { - await updateAppdata( { locale } ); + await updateSharedConfig( { locale } ); } export async function saveUserEditor( event: IpcMainInvokeEvent, editor: SupportedEditor ) { diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index e1aa47d2bb..6a204abce8 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -1,5 +1,4 @@ import { StatsMetric } from 'src/lib/bump-stats'; -import { StoredToken } from 'src/lib/oauth'; import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; import type { SyncSite } from 'src/modules/sync/types'; import type { SupportedTerminal } from 'src/modules/user-settings/lib/terminal'; @@ -16,9 +15,7 @@ export interface UserData { sites: SiteDetails[]; devToolsOpen?: boolean; windowBounds?: WindowBounds; - authToken?: StoredToken; onboardingCompleted?: boolean; - locale?: string; lastBumpStats?: Record< string, Partial< Record< StatsMetric, number > > >; promptWindowsSpeedUpResult?: PromptWindowsSpeedUpResult; connectedWpcomSites?: { [ userId: number ]: SyncSite[] }; diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 4c0162b9a9..62b45d80fc 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -133,9 +133,7 @@ export async function unlockAppdata() { type UserDataSafeKeys = | 'devToolsOpen' | 'windowBounds' - | 'authToken' | 'onboardingCompleted' - | 'locale' | 'promptWindowsSpeedUpResult' | 'stopSitesOnQuit' | 'sentryUserId' diff --git a/tools/common/constants.ts b/tools/common/constants.ts index 515e708740..31c5707a23 100644 --- a/tools/common/constants.ts +++ b/tools/common/constants.ts @@ -16,6 +16,7 @@ export const DEFAULT_TOKEN_LIFETIME_MS = DAY_MS * 14; // Lockfile constants export const LOCKFILE_NAME = 'appdata-v1.json.lock'; +export const SHARED_CONFIG_LOCKFILE_NAME = 'shared.json.lock'; export const LOCKFILE_STALE_TIME = 5000; export const LOCKFILE_WAIT_TIME = 5000; diff --git a/tools/common/lib/shared-config.ts b/tools/common/lib/shared-config.ts new file mode 100644 index 0000000000..07a4c92ec7 --- /dev/null +++ b/tools/common/lib/shared-config.ts @@ -0,0 +1,126 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { readFile, writeFile } from 'atomically'; +import { z } from 'zod'; +import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME, SHARED_CONFIG_LOCKFILE_NAME } from '../constants'; +import { lockFileAsync, unlockFileAsync } from './lockfile'; + +const SHARED_CONFIG_FILENAME = 'shared.json'; + +export const authTokenSchema = z.object( { + accessToken: z.string(), + expiresIn: z.number(), + expirationTime: z.number(), + id: z.number(), + email: z.string(), + displayName: z.string().default( '' ), +} ); + +export type StoredToken = z.infer< typeof authTokenSchema >; + +const sharedConfigSchema = z + .object( { + version: z.number().default( 1 ), + authToken: authTokenSchema.optional(), + locale: z.string().optional(), + } ) + .loose(); + +export type SharedConfig = z.infer< typeof sharedConfigSchema >; + +const DEFAULT_SHARED_CONFIG: SharedConfig = { + version: 1, +}; + +export function getSharedConfigDirectory(): string { + if ( process.env.E2E && process.env.E2E_SHARED_CONFIG_PATH ) { + return process.env.E2E_SHARED_CONFIG_PATH; + } + return path.join( os.homedir(), '.studio' ); +} + +export function getSharedConfigPath(): string { + return path.join( getSharedConfigDirectory(), SHARED_CONFIG_FILENAME ); +} + +export async function readSharedConfig(): Promise< SharedConfig > { + const configPath = getSharedConfigPath(); + + if ( ! fs.existsSync( configPath ) ) { + return structuredClone( DEFAULT_SHARED_CONFIG ); + } + + try { + const fileContent = await readFile( configPath, { encoding: 'utf8' } ); + const data = JSON.parse( fileContent ); + return sharedConfigSchema.parse( data ); + } catch ( error ) { + if ( error instanceof z.ZodError || error instanceof SyntaxError ) { + return structuredClone( DEFAULT_SHARED_CONFIG ); + } + throw new Error( 'Failed to read shared config file.' ); + } +} + +export async function saveSharedConfig( config: SharedConfig ): Promise< void > { + config.version = 1; + + const configDir = getSharedConfigDirectory(); + if ( ! fs.existsSync( configDir ) ) { + fs.mkdirSync( configDir, { recursive: true } ); + } + + const configPath = getSharedConfigPath(); + const fileContent = JSON.stringify( config, null, 2 ) + '\n'; + await writeFile( configPath, fileContent, { encoding: 'utf8' } ); +} + +function getLockfilePath(): string { + return path.join( getSharedConfigDirectory(), SHARED_CONFIG_LOCKFILE_NAME ); +} + +export async function lockSharedConfig(): Promise< void > { + await lockFileAsync( getLockfilePath(), { + wait: LOCKFILE_WAIT_TIME, + stale: LOCKFILE_STALE_TIME, + } ); +} + +export async function unlockSharedConfig(): Promise< void > { + await unlockFileAsync( getLockfilePath() ); +} + +export async function updateSharedConfig( + update: Partial< Omit< SharedConfig, 'version' > > +): Promise< void > { + try { + await lockSharedConfig(); + const config = await readSharedConfig(); + const updated = { ...config, ...update }; + await saveSharedConfig( updated ); + } finally { + await unlockSharedConfig(); + } +} + +export async function readAuthToken(): Promise< StoredToken | null > { + try { + const config = await readSharedConfig(); + if ( ! config.authToken ) { + return null; + } + const token = authTokenSchema.parse( config.authToken ); + if ( Date.now() >= token.expirationTime ) { + return null; + } + return token; + } catch { + return null; + } +} + +export async function getCurrentUserId(): Promise< number | null > { + const token = await readAuthToken(); + return token?.id ?? null; +} diff --git a/tools/common/lib/tests/shared-config.test.ts b/tools/common/lib/tests/shared-config.test.ts new file mode 100644 index 0000000000..d7774ecb1d --- /dev/null +++ b/tools/common/lib/tests/shared-config.test.ts @@ -0,0 +1,242 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { readFile, writeFile } from 'atomically'; +import { vi } from 'vitest'; +import { + readSharedConfig, + saveSharedConfig, + updateSharedConfig, + readAuthToken, + getCurrentUserId, + getSharedConfigDirectory, + getSharedConfigPath, +} from '@studio/common/lib/shared-config'; + +vi.mock( 'fs', () => ( { + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + }, +} ) ); +vi.mock( 'os', () => ( { + default: { + homedir: vi.fn(), + }, +} ) ); +vi.mock( 'path', () => ( { + default: { + join: vi.fn(), + }, +} ) ); +vi.mock( 'atomically', () => ( { + readFile: vi.fn(), + writeFile: vi.fn(), +} ) ); +vi.mock( '@studio/common/lib/lockfile', () => ( { + lockFileAsync: vi.fn().mockResolvedValue( undefined ), + unlockFileAsync: vi.fn().mockResolvedValue( undefined ), +} ) ); + +const validToken = { + accessToken: 'valid-token', + expiresIn: 1209600, + expirationTime: Date.now() + 1000 * 60 * 60 * 24, // 1 day from now + id: 123, + email: 'test@example.com', + displayName: 'Test User', +}; + +const expiredToken = { + ...validToken, + expirationTime: Date.now() - 1000, // 1 second ago +}; + +describe( 'Shared Config', () => { + const mockHomeDir = '/mock/home'; + + beforeEach( () => { + vi.clearAllMocks(); + vi.mocked( os.homedir ).mockReturnValue( mockHomeDir ); + vi.mocked( path.join ).mockImplementation( ( ...args ) => args.join( '/' ) ); + vi.mocked( fs.existsSync ).mockReturnValue( true ); + vi.mocked( readFile ).mockResolvedValue( Buffer.from( '{}' ) ); + vi.mocked( writeFile ).mockResolvedValue( undefined ); + delete process.env.E2E; + delete process.env.E2E_SHARED_CONFIG_PATH; + } ); + + describe( 'getSharedConfigDirectory', () => { + it( 'should return ~/.studio by default', () => { + expect( getSharedConfigDirectory() ).toBe( `${ mockHomeDir }/.studio` ); + } ); + + it( 'should use E2E override when set', () => { + process.env.E2E = '1'; + process.env.E2E_SHARED_CONFIG_PATH = '/custom/path'; + expect( getSharedConfigDirectory() ).toBe( '/custom/path' ); + } ); + } ); + + describe( 'getSharedConfigPath', () => { + it( 'should return path to shared.json', () => { + expect( getSharedConfigPath() ).toBe( `${ mockHomeDir }/.studio/shared.json` ); + } ); + } ); + + describe( 'readSharedConfig', () => { + it( 'should return default config when file does not exist', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + const config = await readSharedConfig(); + expect( config ).toEqual( { version: 1 } ); + } ); + + it( 'should parse valid shared.json', async () => { + const data = { + version: 1, + authToken: validToken, + locale: 'en', + }; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( data ) ) ); + + const config = await readSharedConfig(); + expect( config.authToken?.accessToken ).toBe( 'valid-token' ); + expect( config.locale ).toBe( 'en' ); + } ); + + it( 'should return defaults on malformed JSON', async () => { + vi.mocked( readFile ).mockResolvedValue( Buffer.from( 'not json' ) ); + const config = await readSharedConfig(); + expect( config ).toEqual( { version: 1 } ); + } ); + + it( 'should return defaults on invalid schema', async () => { + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { version: 'invalid' } ) ) + ); + const config = await readSharedConfig(); + expect( config ).toEqual( { version: 1 } ); + } ); + + it( 'should preserve unknown fields with loose schema', async () => { + const data = { version: 1, unknownField: 'value' }; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( data ) ) ); + + const config = await readSharedConfig(); + expect( ( config as Record< string, unknown > ).unknownField ).toBe( 'value' ); + } ); + } ); + + describe( 'saveSharedConfig', () => { + it( 'should write JSON to shared.json', async () => { + const config = { version: 1, locale: 'en' }; + await saveSharedConfig( config ); + + expect( writeFile ).toHaveBeenCalledWith( + `${ mockHomeDir }/.studio/shared.json`, + JSON.stringify( { version: 1, locale: 'en' }, null, 2 ) + '\n', + { encoding: 'utf8' } + ); + } ); + + it( 'should create directory if it does not exist', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + await saveSharedConfig( { version: 1 } ); + + expect( fs.mkdirSync ).toHaveBeenCalledWith( `${ mockHomeDir }/.studio`, { + recursive: true, + } ); + } ); + + it( 'should set version to 1', async () => { + const config = { version: 99 as number }; + await saveSharedConfig( config ); + + const written = vi.mocked( writeFile ).mock.calls[ 0 ][ 1 ] as string; + expect( JSON.parse( written ).version ).toBe( 1 ); + } ); + } ); + + describe( 'updateSharedConfig', () => { + it( 'should merge partial updates', async () => { + const existing = { version: 1, locale: 'en' }; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( existing ) ) ); + + await updateSharedConfig( { locale: 'fr' } ); + + const written = vi.mocked( writeFile ).mock.calls[ 0 ][ 1 ] as string; + const saved = JSON.parse( written ); + expect( saved.locale ).toBe( 'fr' ); + } ); + } ); + + describe( 'readAuthToken', () => { + it( 'should return valid token', async () => { + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { version: 1, authToken: validToken } ) ) + ); + + const token = await readAuthToken(); + expect( token ).not.toBeNull(); + expect( token?.accessToken ).toBe( 'valid-token' ); + expect( token?.id ).toBe( 123 ); + } ); + + it( 'should return null for expired token', async () => { + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { version: 1, authToken: expiredToken } ) ) + ); + + const token = await readAuthToken(); + expect( token ).toBeNull(); + } ); + + it( 'should return null when no token exists', async () => { + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( { version: 1 } ) ) ); + + const token = await readAuthToken(); + expect( token ).toBeNull(); + } ); + + it( 'should return null when file does not exist', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + + const token = await readAuthToken(); + expect( token ).toBeNull(); + } ); + + it( 'should return null on malformed file', async () => { + vi.mocked( readFile ).mockResolvedValue( Buffer.from( 'not json' ) ); + + const token = await readAuthToken(); + expect( token ).toBeNull(); + } ); + } ); + + describe( 'getCurrentUserId', () => { + it( 'should return user id from valid token', async () => { + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { version: 1, authToken: validToken } ) ) + ); + + const userId = await getCurrentUserId(); + expect( userId ).toBe( 123 ); + } ); + + it( 'should return null when no token', async () => { + vi.mocked( fs.existsSync ).mockReturnValue( false ); + + const userId = await getCurrentUserId(); + expect( userId ).toBeNull(); + } ); + + it( 'should return null for expired token', async () => { + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { version: 1, authToken: expiredToken } ) ) + ); + + const userId = await getCurrentUserId(); + expect( userId ).toBeNull(); + } ); + } ); +} );