From cdb311a64e936d7dea499d3be573f63510857f6a Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Wed, 11 Mar 2026 16:29:13 +0000 Subject: [PATCH 01/11] Read site data from CLI instead of appdata (#2701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * STU-1350: Read site data from CLI instead of appdata Studio now reads site data via `site list --format json` at startup and maintains state through CLI events subscriber. Removes direct appdata reads for site details, simplifies SiteServer create/start flows, and removes waitForSiteEvent synchronization. * Fix lint: indentation in index.test.ts * Fix typecheck: narrow SiteDetails union before accessing url * Remove dead url branch in SiteServer.create β€” events subscriber handles it * Address PR review feedback: shared schemas, keyValuePair output, simplify start/delete --- apps/cli/commands/site/list.ts | 42 +++++-- apps/cli/commands/site/tests/list.test.ts | 71 ++++++----- apps/studio/src/index.ts | 9 +- apps/studio/src/ipc-handlers.ts | 91 ++++---------- .../modules/cli/lib/cli-events-subscriber.ts | 10 +- apps/studio/src/site-server.ts | 115 ++++++++---------- apps/studio/src/tests/index.test.ts | 7 +- apps/studio/src/tests/ipc-handlers.test.ts | 83 +++++++------ tools/common/lib/site-events.ts | 8 ++ 9 files changed, 213 insertions(+), 223 deletions(-) diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index c725009a80..d422691c61 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -1,3 +1,4 @@ +import { type SiteDetails } from '@studio/common/lib/site-events'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import Table from 'cli-table3'; @@ -8,7 +9,7 @@ import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -interface SiteListEntry { +interface SiteListTableEntry { id: string; status: string; name: string; @@ -16,27 +17,44 @@ interface SiteListEntry { url: string; } -async function getSiteListData( sites: SiteData[] ): Promise< SiteListEntry[] > { - const result: SiteListEntry[] = []; +interface SiteListJsonEntry extends SiteDetails { + running: boolean; +} + +async function getSiteListData( sites: SiteData[] ): Promise< { + tableEntries: SiteListTableEntry[]; + jsonEntries: SiteListJsonEntry[]; +} > { + const tableEntries: SiteListTableEntry[] = []; + const jsonEntries: SiteListJsonEntry[] = []; for await ( const site of sites ) { - const isReady = await isSiteRunning( site ); - const status = isReady ? `🟒 ${ __( 'Online' ) }` : `πŸ”΄ ${ __( 'Offline' ) }`; + const running = await isSiteRunning( site ); const url = getSiteUrl( site ); + const status = running ? `🟒 ${ __( 'Online' ) }` : `πŸ”΄ ${ __( 'Offline' ) }`; - result.push( { + tableEntries.push( { id: site.id, status, name: site.name, path: getPrettyPath( site.path ), url, } ); + + jsonEntries.push( { + ...site, + url, + running, + } ); } - return result; + return { tableEntries, jsonEntries }; } -function displaySiteList( sitesData: SiteListEntry[], format: 'table' | 'json' ): void { +function displaySiteList( + data: { tableEntries: SiteListTableEntry[]; jsonEntries: SiteListJsonEntry[] }, + format: 'table' | 'json' +): void { if ( format === 'table' ) { const colWidths = getColumnWidths( [ 0.1, 0.2, 0.3, 0.4 ] ); @@ -52,7 +70,7 @@ function displaySiteList( sitesData: SiteListEntry[], format: 'table' | 'json' ) } ); table.push( - ...sitesData.map( ( site ) => [ + ...data.tableEntries.map( ( site ) => [ site.status, site.name, site.path, @@ -62,7 +80,7 @@ function displaySiteList( sitesData: SiteListEntry[], format: 'table' | 'json' ) console.log( table.toString() ); } else { - console.log( JSON.stringify( sitesData, null, 2 ) ); + logger.reportKeyValuePair( 'sites', JSON.stringify( data.jsonEntries ) ); } } @@ -88,8 +106,8 @@ export async function runCommand( format: 'table' | 'json' ): Promise< void > { await connectToDaemon(); logger.reportSuccess( __( 'Connected to process daemon' ) ); - const sitesData = await getSiteListData( appdata.sites ); - displaySiteList( sitesData, format ); + const siteListData = await getSiteListData( appdata.sites ); + displaySiteList( siteListData, format ); } finally { await disconnectFromDaemon(); } diff --git a/apps/cli/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index 02832d8e88..566e44790a 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest'; import { readAppdata } from 'cli/lib/appdata'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; +import { mockReportKeyValuePair } from 'cli/tests/test-utils'; import { runCommand } from '../list'; vi.mock( 'cli/lib/appdata', async () => { const actual = await vi.importActual( 'cli/lib/appdata' ); @@ -13,6 +14,19 @@ vi.mock( 'cli/lib/appdata', async () => { } ); vi.mock( 'cli/lib/daemon-client' ); vi.mock( 'cli/lib/wordpress-server-manager' ); +vi.mock( 'cli/logger', () => ( { + Logger: class { + reportStart = vi.fn(); + reportSuccess = vi.fn(); + reportError = vi.fn(); + reportProgress = vi.fn(); + reportWarning = vi.fn(); + reportKeyValuePair = mockReportKeyValuePair; + spinner = {}; + currentAction = null; + }, + LoggerError: class extends Error {}, +} ) ); describe( 'CLI: studio site list', () => { // Simple test data @@ -78,35 +92,33 @@ describe( 'CLI: studio site list', () => { } ); it( 'should list sites with json format', async () => { - const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); - await runCommand( 'json' ); - expect( consoleSpy ).toHaveBeenCalledWith( - JSON.stringify( - [ - { - id: 'site-1', - status: 'πŸ”΄ Offline', - name: 'Test Site 1', - path: '/path/to/site1', - url: 'http://localhost:8080', - }, - { - id: 'site-2', - status: 'πŸ”΄ Offline', - name: 'Test Site 2', - path: '/path/to/site2', - url: 'http://my-site.wp.local', - }, - ], - null, - 2 - ) + expect( mockReportKeyValuePair ).toHaveBeenCalledWith( + 'sites', + JSON.stringify( [ + { + id: 'site-1', + name: 'Test Site 1', + path: '/path/to/site1', + port: 8080, + phpVersion: '8.0', + url: 'http://localhost:8080', + running: false, + }, + { + id: 'site-2', + name: 'Test Site 2', + path: '/path/to/site2', + port: 8081, + phpVersion: '8.0', + customDomain: 'my-site.wp.local', + url: 'http://my-site.wp.local', + running: false, + }, + ] ) ); expect( disconnectFromDaemon ).toHaveBeenCalled(); - - consoleSpy.mockRestore(); } ); it( 'should handle no sites found', async () => { @@ -119,14 +131,13 @@ describe( 'CLI: studio site list', () => { } ); it( 'should handle custom domain in site URL', async () => { - const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); - await runCommand( 'json' ); - expect( consoleSpy ).toHaveBeenCalledWith( expect.stringContaining( 'my-site.wp.local' ) ); + expect( mockReportKeyValuePair ).toHaveBeenCalledWith( + 'sites', + expect.stringContaining( 'my-site.wp.local' ) + ); expect( disconnectFromDaemon ).toHaveBeenCalled(); - - consoleSpy.mockRestore(); } ); } ); } ); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index a44d16f30c..da528bb26d 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -53,7 +53,7 @@ import { import { isStudioCliInstalled } from 'src/modules/cli/lib/ipc-handlers'; import { updateWindowsCliVersionedPathIfNeeded } from 'src/modules/cli/lib/windows-installation-manager'; import { setupWPServerFiles, updateWPServerFiles } from 'src/setup-wp-server-files'; -import { getRunningSiteCount, stopAllServers } from 'src/site-server'; +import { getRunningSiteCount, SiteServer, stopAllServers } from 'src/site-server'; import { loadUserData, lockAppdata, @@ -337,9 +337,14 @@ async function appBoot() { await renameLaunchUniquesStat(); - await startUserDataWatcher(); + // Fetch data from CLI and subscribe to CLI events before starting the user data + // watcher. The watcher can trigger getMainWindow() which creates the window early, + // so sites must be loaded first. + await SiteServer.fetchAll(); await startCliEventsSubscriber(); + await startUserDataWatcher(); + await createMainWindow(); const userData = await loadUserData(); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 345ecbddcb..c22dc44d81 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -33,7 +33,6 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; 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 { portFinder } from '@studio/common/lib/port-finder'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; import { Snapshot } from '@studio/common/types/snapshot'; @@ -198,41 +197,26 @@ function readPm2Logs( siteId: string ): { stdout?: string[]; stderr?: string[] } }; } -function mergeSiteDetailsWithRunningDetails( sites: SiteDetails[] ): SiteDetails[] { - return sites.map( ( site ) => { - const server = SiteServer.get( site.id ); - if ( server ) { - return server.details; - } - return site; - } ); -} - export async function getSiteDetails( _event: IpcMainInvokeEvent ): Promise< SiteDetails[] > { + const sites = SiteServer.getAllDetails(); const userData = await loadUserData(); - - const { sites } = userData; - - // Ensure we have an instance of a server for each site we know about for ( const site of sites ) { - if ( ! SiteServer.get( site.id ) && ! site.running ) { - SiteServer.register( site ); + const appdataSite = userData.sites.find( ( s ) => s.id === site.id ); + if ( appdataSite ) { + site.sortOrder = appdataSite.sortOrder; + site.themeDetails = appdataSite.themeDetails; } } - return mergeSiteDetailsWithRunningDetails( sites ); + return sites; } export async function getXdebugEnabledSite( _event: IpcMainInvokeEvent ): Promise< SiteDetails | null > { - const userData = await loadUserData(); - const { sites } = userData; + const sites = SiteServer.getAllDetails(); const xdebugSite = sites.find( ( site ) => site.enableXdebug ); - if ( ! xdebugSite ) { - return null; - } - return mergeSiteDetailsWithRunningDetails( [ xdebugSite ] )[ 0 ] || null; + return xdebugSite || null; } export async function importSite( @@ -455,31 +439,6 @@ export async function updateSite( if ( hasCliChanges ) { await editSiteViaCli( options ); - - const userData = await loadUserData(); - const freshSiteData = userData.sites.find( ( s ) => s.id === updatedSite.id ); - if ( freshSiteData ) { - const wasRunning = server.details.running; - - if ( wasRunning ) { - const url = freshSiteData.customDomain - ? `${ freshSiteData.enableHttps ? 'https' : 'http' }://${ freshSiteData.customDomain }` - : `http://localhost:${ freshSiteData.port }`; - - server.details = { - ...freshSiteData, - running: true, - url, - }; - - server.server.url = url; - } else { - server.details = { - ...freshSiteData, - running: false, - }; - } - } } } @@ -665,7 +624,6 @@ export async function copySite( const newThumbnailPath = getSiteThumbnailPath( newSiteId ); if ( fs.existsSync( sourceThumbnailPath ) ) { await fs.promises.copyFile( sourceThumbnailPath, newThumbnailPath ); - // Send thumbnail-loaded event so UI updates immediately const thumbnailData = await getImageData( newThumbnailPath ); sendIpcEventToRendererWithWindow( BrowserWindow.fromWebContents( event.sender ), @@ -674,33 +632,26 @@ export async function copySite( ); } - const port = await portFinder.getOpenPort(); - - const newSiteDetails: StoppedSiteDetails = { - id: newSiteId, - name: siteName, + const { server, details } = await SiteServer.create( { path: finalSitePath, - port, + name: siteName, + siteId: newSiteId, phpVersion: sourceSite.phpVersion, - running: false, adminUsername: sourceSite.adminUsername, - adminPassword: sourceSite.adminPassword, + adminPassword: sourceSite.adminPassword + ? decodePassword( sourceSite.adminPassword ) + : undefined, adminEmail: sourceSite.adminEmail, - themeDetails: sourceSite.themeDetails, - }; + noStart: true, + } ); - try { - await lockAppdata(); - const userData = await loadUserData(); - userData.sites.push( newSiteDetails ); - await saveUserData( userData ); - } finally { - await unlockAppdata(); + // Persist themeDetails to appdata (Studio-only data) + if ( sourceSite.themeDetails ) { + server.details.themeDetails = sourceSite.themeDetails; + await server.persistThemeDetails(); } - SiteServer.register( newSiteDetails ); - - return newSiteDetails; + return details; } export function logRendererMessage( diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index f5338f73af..be882b6a3a 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -35,23 +35,21 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > return; } - // Only register new sites on CREATED events to prevent duplicates if ( eventType === SITE_EVENTS.CREATED ) { const existingServer = SiteServer.get( siteId ) ?? SiteServer.getByPath( site.path ); if ( ! existingServer ) { SiteServer.register( siteDetailsToServerDetails( site, running ) ); - } - // Don't send to renderer if site is being created by UI (createSite IPC will handle it) - if ( existingServer?.hasOngoingOperation ) { - return; + } else { + existingServer.details = siteDetailsToServerDetails( site, running, existingServer.details ); } void sendIpcEventToRenderer( 'site-event', event ); return; } - // For UPDATED events, only update if the site already exists + // For UPDATED events, update existing server details const server = SiteServer.get( siteId ) ?? SiteServer.getByPath( site.path ); if ( ! server ) { + console.warn( `Received UPDATED event for unknown site: ${ siteId }` ); return; } diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 461ad49a2e..e46b9d8523 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -3,6 +3,7 @@ import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; +import { siteListSchema, type SiteListItem } from '@studio/common/lib/site-events'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; @@ -80,13 +81,6 @@ type SiteServerMeta = { export class SiteServer { server: CliServerProcess; - /** - * Indicates whether a Studio-managed operation (start/stop) is in progress. - * When true, file watchers should ignore site events to prevent interference - * with the ongoing operation. - */ - hasOngoingOperation = false; - private constructor( public details: SiteDetails, public meta: SiteServerMeta @@ -108,10 +102,56 @@ export class SiteServer { return undefined; } + static getAll(): SiteServer[] { + return Array.from( servers.values() ); + } + + static getAllDetails(): SiteDetails[] { + return Array.from( servers.values() ).map( ( server ) => server.details ); + } + static isDeleted( id: string ) { return deletedServers.includes( id ); } + private static siteListKeyValueSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'sites' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) ) + .pipe( siteListSchema ), + } ); + + static async fetchAll(): Promise< void > { + try { + const sites = await new Promise< SiteListItem[] >( ( resolve, reject ) => { + const [ emitter ] = executeCliCommand( [ 'site', 'list', '--format', 'json' ], { + output: 'capture', + } ); + + emitter.on( 'data', ( { data } ) => { + const parsed = SiteServer.siteListKeyValueSchema.safeParse( data ); + if ( parsed.success ) { + resolve( parsed.data.value ); + } + } ); + + emitter.on( 'success', () => resolve( [] ) ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + + for ( const site of sites ) { + if ( ! SiteServer.get( site.id ) ) { + SiteServer.register( site ); + } + } + } catch ( error ) { + console.error( 'Failed to fetch sites from CLI:', error ); + } + } + static register( details: SiteDetails, meta: SiteServerMeta = {} ): SiteServer { const server = new SiteServer( details, meta ); servers.set( details.id, server ); @@ -137,46 +177,14 @@ export class SiteServer { running: false, }; const server = SiteServer.register( placeholderDetails, meta ); - server.hasOngoingOperation = true; - try { - const result = await createSiteViaCli( { ...options, siteId } ); - const userData = await loadUserData(); - const siteData = userData.sites.find( ( s ) => s.id === result.id ); - if ( ! siteData ) { - throw new Error( `Site with ID ${ result.id } not found in appdata after CLI creation` ); - } - - let siteDetails: SiteDetails; - if ( result.running ) { - const url = siteData.customDomain - ? `${ siteData.enableHttps ? 'https' : 'http' }://${ siteData.customDomain }` - : `http://localhost:${ siteData.port }`; - siteDetails = { - ...siteData, - running: true, - url, - }; - } else { - siteDetails = { - ...siteData, - running: false, - }; - } + const result = await createSiteViaCli( { ...options, siteId } ); - // Update the server with the real details from CLI - servers.delete( placeholderDetails.id ); - servers.set( siteDetails.id, server ); - server.details = siteDetails; - - if ( siteDetails.running && siteDetails.url ) { - server.server.url = siteDetails.url; - } - - return { server, details: siteDetails }; - } finally { - server.hasOngoingOperation = false; + if ( result.running ) { + server.details.running = true; } + + return { server, details: server.details }; } async delete( deleteFiles: boolean ) { @@ -197,25 +205,6 @@ export class SiteServer { console.log( `Starting server for '${ this.details.name }'` ); await this.server.start(); - - const userData = await loadUserData(); - const freshSiteData = userData.sites.find( ( s ) => s.id === this.details.id ); - - if ( freshSiteData?.port ) { - this.details.port = freshSiteData.port; - } - - const url = getAbsoluteUrl( this.details ); - - this.details = { - ...this.details, - url, - running: true, - autoStart: true, - latestCliPid: freshSiteData?.latestCliPid, - }; - - this.server.url = url; } updateSiteDetails( site: SiteDetails ) { diff --git a/apps/studio/src/tests/index.test.ts b/apps/studio/src/tests/index.test.ts index c3acb1455e..58c88f9bbb 100644 --- a/apps/studio/src/tests/index.test.ts +++ b/apps/studio/src/tests/index.test.ts @@ -43,11 +43,13 @@ vi.mock( 'src/storage/paths', () => ( { } ) ); vi.mock( 'src/modules/cli/lib/execute-command', () => { const mockEventEmitter = { - on: vi.fn().mockImplementation( ( event: string, callback: () => void ) => { + on: vi.fn().mockImplementation( ( event: string, callback: ( ...args: any[] ) => void ) => { if ( event === 'started' ) { - // Call started callback immediately setTimeout( () => callback(), 0 ); } + if ( event === 'success' ) { + setTimeout( () => callback( { result: { stdout: '[]', stderr: '' } } ), 0 ); + } return mockEventEmitter; } ), emit: vi.fn(), @@ -110,6 +112,7 @@ function mockElectron() { } ), requestSingleInstanceLock: vi.fn().mockReturnValue( true ), quit: vi.fn(), + exit: vi.fn(), setName: vi.fn(), setAsDefaultProtocolClient: vi.fn(), enableSandbox: vi.fn(), diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts index 315e22cf82..8d68e38d76 100644 --- a/apps/studio/src/tests/ipc-handlers.test.ts +++ b/apps/studio/src/tests/ipc-handlers.test.ts @@ -268,16 +268,25 @@ describe( 'importSite', () => { describe( 'getXdebugEnabledSite', () => { it( 'should return null when no site has Xdebug enabled', async () => { - const mockUserDataWithoutXdebug = { - sites: [ - { id: 'site-1', name: 'Site 1', path: '/path/to/site-1', enableXdebug: false }, - { id: 'site-2', name: 'Site 2', path: '/path/to/site-2' }, - ], - }; - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( JSON.stringify( mockUserDataWithoutXdebug ) ) - ); - vi.mocked( fs.existsSync ).mockReturnValue( true ); + vi.mocked( SiteServer.getAllDetails ).mockReturnValue( [ + { + id: 'site-1', + name: 'Site 1', + path: '/path/to/site-1', + enableXdebug: false, + running: false, + phpVersion: '8.3', + port: 9999, + }, + { + id: 'site-2', + name: 'Site 2', + path: '/path/to/site-2', + running: false, + phpVersion: '8.3', + port: 9998, + }, + ] as SiteDetails[] ); const result = await getXdebugEnabledSite( mockIpcMainInvokeEvent ); @@ -285,28 +294,27 @@ describe( 'getXdebugEnabledSite', () => { } ); it( 'should return the site that has Xdebug enabled', async () => { - const mockUserDataWithXdebug = { - sites: [ - { id: 'site-1', name: 'Site 1', path: '/path/to/site-1', enableXdebug: false }, - { id: 'site-2', name: 'Site 2', path: '/path/to/site-2', enableXdebug: true }, - ], - }; - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( JSON.stringify( mockUserDataWithXdebug ) ) - ); - vi.mocked( fs.existsSync ).mockReturnValue( true ); - vi.mocked( SiteServer.get, { partial: true } ).mockReturnValue( { - details: { + vi.mocked( SiteServer.getAllDetails ).mockReturnValue( [ + { + id: 'site-1', + name: 'Site 1', + path: '/path/to/site-1', + enableXdebug: false, + running: false, + phpVersion: '8.3', + port: 9999, + }, + { id: 'site-2', name: 'Site 2', path: '/path/to/site-2', - running: true, enableXdebug: true, + running: true, phpVersion: '8.3', port: 9999, url: 'https://site-2.test', }, - } ); + ] as SiteDetails[] ); const result = await getXdebugEnabledSite( mockIpcMainInvokeEvent ); @@ -323,27 +331,26 @@ describe( 'getXdebugEnabledSite', () => { } ); it( 'should return the first site when multiple have Xdebug enabled', async () => { - const mockUserDataWithMultipleXdebug = { - sites: [ - { id: 'site-1', name: 'Site 1', path: '/path/to/site-1', enableXdebug: true }, - { id: 'site-2', name: 'Site 2', path: '/path/to/site-2', enableXdebug: true }, - ], - }; - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( JSON.stringify( mockUserDataWithMultipleXdebug ) ) - ); - vi.mocked( fs.existsSync ).mockReturnValue( true ); - vi.mocked( SiteServer.get, { partial: true } ).mockReturnValue( { - details: { + vi.mocked( SiteServer.getAllDetails ).mockReturnValue( [ + { id: 'site-1', name: 'Site 1', path: '/path/to/site-1', - running: false, enableXdebug: true, + running: false, phpVersion: '8.3', port: 9999, }, - } ); + { + id: 'site-2', + name: 'Site 2', + path: '/path/to/site-2', + enableXdebug: true, + running: true, + phpVersion: '8.3', + port: 9998, + }, + ] as SiteDetails[] ); const result = await getXdebugEnabledSite( mockIpcMainInvokeEvent ); diff --git a/tools/common/lib/site-events.ts b/tools/common/lib/site-events.ts index ec8f25ca02..b5ae1a2bb4 100644 --- a/tools/common/lib/site-events.ts +++ b/tools/common/lib/site-events.ts @@ -30,6 +30,14 @@ export const siteDetailsSchema = z.object( { export type SiteDetails = z.infer< typeof siteDetailsSchema >; +export const siteListItemSchema = siteDetailsSchema.extend( { + running: z.boolean(), +} ); + +export type SiteListItem = z.infer< typeof siteListItemSchema >; + +export const siteListSchema = z.array( siteListItemSchema ); + export enum SITE_EVENTS { CREATED = 'site-created', UPDATED = 'site-updated', From b2ce79e0a54d156af1be6799c745cedbc59f961a Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Fri, 13 Mar 2026 16:48:31 +0000 Subject: [PATCH 02/11] Move CLI site data to dedicated config file (#2731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * STU-1350: Read site data from CLI instead of appdata Studio now reads site data via `site list --format json` at startup and maintains state through CLI events subscriber. Removes direct appdata reads for site details, simplifies SiteServer create/start flows, and removes waitForSiteEvent synchronization. * Fix lint: indentation in index.test.ts * STU-1350: Move CLI site data to dedicated cli.json config file Split site data from appdata-v1.json into a new CLI-owned config at ~/.studio/cli.json. Studio now reads sites exclusively via `studio site list` and _events. The CLI is the source of truth for site configuration. Auth tokens, snapshots, locale remain in appdata. Co-Authored-By: Claude Haiku 4.5 * Fix delete test after trunk merge: use saveCliConfig instead of saveAppdata * Extend require-lock-before-save eslint rule to support cli-config lock/save pairs * Use shared lockfile constants and extract DEFAULT_CLI_CONFIG * Fix typecheck: narrow SiteDetails union before accessing url * Remove dead url branch in SiteServer.create β€” events subscriber handles it * Fix lint: remove extra blank line in cli-config.ts * triggert ci * Update e2e tests to read site data from cli.json * Address PR review feedback for cli-config - Use structuredClone for DEFAULT_CLI_CONFIG deep copy - Rename daemon-paths.ts to paths.ts, extract STUDIO_CLI_HOME - Split config schema for version mismatch detection - Improve readCliConfig error handling with version check - Rename removeSite to removeSiteFromConfig - Revert UPDATED event handler to warn for unknown sites * Fix AI files to import site functions from cli-config instead of appdata --------- Co-authored-by: Claude Haiku 4.5 --- apps/cli/ai/tools.ts | 6 +- apps/cli/ai/ui.ts | 14 +- apps/cli/commands/_events.ts | 14 +- apps/cli/commands/preview/create.ts | 3 +- apps/cli/commands/preview/list.ts | 3 +- .../cli/commands/preview/tests/create.test.ts | 6 +- apps/cli/commands/preview/tests/list.test.ts | 9 +- .../cli/commands/preview/tests/update.test.ts | 9 +- apps/cli/commands/preview/update.ts | 3 +- apps/cli/commands/site/create.ts | 36 +-- apps/cli/commands/site/delete.ts | 27 ++- apps/cli/commands/site/list.ts | 12 +- apps/cli/commands/site/set.ts | 36 +-- apps/cli/commands/site/start.ts | 2 +- apps/cli/commands/site/status.ts | 2 +- apps/cli/commands/site/stop.ts | 24 +- apps/cli/commands/site/tests/create.test.ts | 73 +++--- apps/cli/commands/site/tests/delete.test.ts | 86 +++---- apps/cli/commands/site/tests/list.test.ts | 32 ++- apps/cli/commands/site/tests/set.test.ts | 134 +++++------ apps/cli/commands/site/tests/start.test.ts | 7 +- apps/cli/commands/site/tests/status.test.ts | 7 +- apps/cli/commands/site/tests/stop.test.ts | 47 ++-- apps/cli/commands/wp.ts | 2 +- apps/cli/lib/appdata.ts | 105 --------- apps/cli/lib/cli-config.ts | 211 ++++++++++++++++++ apps/cli/lib/daemon-client.ts | 2 +- apps/cli/lib/generate-site-name.ts | 6 +- apps/cli/lib/{daemon-paths.ts => paths.ts} | 4 +- apps/cli/lib/proxy-server.ts | 6 +- apps/cli/lib/site-utils.ts | 6 +- apps/cli/lib/snapshots.ts | 2 +- apps/cli/lib/tests/site-utils.test.ts | 33 ++- .../tests/wordpress-server-manager.test.ts | 2 +- apps/cli/lib/wordpress-server-manager.ts | 2 +- apps/cli/process-manager-daemon.ts | 2 +- apps/studio/e2e/e2e-helpers.ts | 4 + apps/studio/e2e/sites.test.ts | 10 +- apps/studio/src/ipc-handlers.ts | 13 ++ eslint.config.mjs | 18 +- .../src/rules/require-lock-before-save.js | 115 +++++++--- .../tests/require-lock-before-save.test.ts | 102 ++++++++- 42 files changed, 771 insertions(+), 466 deletions(-) create mode 100644 apps/cli/lib/cli-config.ts rename apps/cli/lib/{daemon-paths.ts => paths.ts} (77%) diff --git a/apps/cli/ai/tools.ts b/apps/cli/ai/tools.ts index b4fc36950d..94cdec27a1 100644 --- a/apps/cli/ai/tools.ts +++ b/apps/cli/ai/tools.ts @@ -12,7 +12,7 @@ import { runCommand as runListSitesCommand } from 'cli/commands/site/list'; import { runCommand as runStartSiteCommand } from 'cli/commands/site/start'; import { runCommand as runStatusCommand } from 'cli/commands/site/status'; import { runCommand as runStopSiteCommand, Mode as StopMode } from 'cli/commands/site/stop'; -import { getSiteByFolder, getSiteUrl, readAppdata, type SiteData } from 'cli/lib/appdata'; +import { getSiteByFolder, getSiteUrl, readCliConfig, type SiteData } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; import { emitProgress } from 'cli/logger'; @@ -62,8 +62,8 @@ function splitCommandArgs( command: string ): string[] { } async function findSiteByName( name: string ): Promise< SiteData | undefined > { - const appdata = await readAppdata(); - return appdata.sites.find( ( site ) => site.name.toLowerCase() === name.toLowerCase() ); + const config = await readCliConfig(); + return config.sites.find( ( site ) => site.name.toLowerCase() === name.toLowerCase() ); } async function resolveSite( nameOrPath: string ): Promise< SiteData > { diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 931f56b05c..263f8ef1d0 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -25,8 +25,8 @@ import { type TodoDiff, type TodoEntry, } from 'cli/ai/todo-stream'; -import { getSiteUrl, readAppdata, type SiteData } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; +import { getSiteUrl, readCliConfig, type SiteData } from 'cli/lib/cli-config'; import { isSiteRunning } from 'cli/lib/site-utils'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools'; @@ -568,8 +568,8 @@ export class AiChatUI { } private async openSitePicker(): Promise< void > { - const appdata = await readAppdata(); - const sites: SiteData[] = appdata.sites ?? []; + const config = await readCliConfig(); + const sites: SiteData[] = config.sites ?? []; if ( sites.length === 0 ) { this.messages.addChild( new Text( chalk.dim( ' No sites found. Create one first.' ), 1, 0 ) @@ -652,8 +652,8 @@ export class AiChatUI { } private async findSiteFromAppdata( nameOrPath: string ): Promise< SiteInfo | null > { - const appdata = await readAppdata(); - const site = appdata.sites.find( + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.name.toLowerCase() === nameOrPath.toLowerCase() || s.path === nameOrPath ); if ( ! site ) { @@ -772,8 +772,8 @@ export class AiChatUI { return false; } // Re-read appdata to get the current site state (port/domain may have changed) - const appdata = await readAppdata(); - const freshSiteData = appdata.sites?.find( ( s ) => s.name === this._activeSite?.name ); + const config = await readCliConfig(); + const freshSiteData = config.sites?.find( ( s ) => s.name === this._activeSite?.name ); const siteData = freshSiteData ?? this._activeSiteData; const url = getSiteUrl( siteData ); if ( url ) { diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index 3228e18b61..7ca4fb1ce3 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -11,7 +11,7 @@ import { SITE_EVENTS, siteDetailsSchema, SiteEvent } from '@studio/common/lib/si import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { z } from 'zod'; -import { getSiteUrl, readAppdata, SiteData } from 'cli/lib/appdata'; +import { getSiteUrl, readCliConfig, SiteData } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon, @@ -34,8 +34,8 @@ function toSiteDetails( site: SiteData ) { const emitSiteEvent = sequential( async ( event: SITE_EVENTS, siteId: string, running?: boolean ): Promise< void > => { - const appdata = await readAppdata(); - const site = appdata.sites.find( ( s ) => s.id === siteId ); + const cliConfig = await readCliConfig(); + const site = cliConfig.sites.find( ( s ) => s.id === siteId ); const payload: SiteEvent = { event, siteId, @@ -49,15 +49,15 @@ const emitSiteEvent = sequential( ); async function emitAllSitesStatus(): Promise< void > { - const appdata = await readAppdata(); - for ( const site of appdata.sites ) { + const cliConfig = await readCliConfig(); + for ( const site of cliConfig.sites ) { await emitSiteEvent( SITE_EVENTS.UPDATED, site.id ); } } async function emitAllSitesStopped(): Promise< void > { - const appdata = await readAppdata(); - for ( const site of appdata.sites ) { + const cliConfig = await readCliConfig(); + for ( const site of cliConfig.sites ) { const payload: SiteEvent = { event: SITE_EVENTS.UPDATED, siteId: site.id, diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index de350dd12c..91e053365d 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -4,8 +4,9 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata'; +import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { saveSnapshotToAppdata } from 'cli/lib/snapshots'; import { validateSiteSize } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; diff --git a/apps/cli/commands/preview/list.ts b/apps/cli/commands/preview/list.ts index ae14e691ca..330eba603b 100644 --- a/apps/cli/commands/preview/list.ts +++ b/apps/cli/commands/preview/list.ts @@ -2,7 +2,8 @@ import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logge import { __, _n, sprintf } from '@wordpress/i18n'; import Table from 'cli-table3'; import { format } from 'date-fns'; -import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata'; +import { getAuthToken } from 'cli/lib/appdata'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { formatDurationUntilExpiry, getSnapshotsFromAppdata, diff --git a/apps/cli/commands/preview/tests/create.test.ts b/apps/cli/commands/preview/tests/create.test.ts index 311ac1a1d9..bed082406d 100644 --- a/apps/cli/commands/preview/tests/create.test.ts +++ b/apps/cli/commands/preview/tests/create.test.ts @@ -3,8 +3,9 @@ import path from 'path'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { vi } from 'vitest'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata'; +import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { saveSnapshotToAppdata } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { runCommand } from '../create'; @@ -22,6 +23,9 @@ vi.mock( 'cli/lib/appdata', async () => ( { ...( await vi.importActual( 'cli/lib/appdata' ) ), getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), getAuthToken: vi.fn(), +} ) ); +vi.mock( 'cli/lib/cli-config', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config' ) ), getSiteByFolder: vi.fn(), } ) ); vi.mock( 'cli/lib/validation', () => ( { diff --git a/apps/cli/commands/preview/tests/list.test.ts b/apps/cli/commands/preview/tests/list.test.ts index ed3851c8e5..01db1dd9e4 100644 --- a/apps/cli/commands/preview/tests/list.test.ts +++ b/apps/cli/commands/preview/tests/list.test.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest'; -import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata'; +import { getAuthToken } from 'cli/lib/appdata'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { getSnapshotsFromAppdata } from 'cli/lib/snapshots'; import { mockReportStart, @@ -17,6 +18,12 @@ vi.mock( 'cli/lib/appdata', async () => { ...actual, getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), getAuthToken: vi.fn(), + }; +} ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); + return { + ...actual, getSiteByFolder: vi.fn(), }; } ); diff --git a/apps/cli/commands/preview/tests/update.test.ts b/apps/cli/commands/preview/tests/update.test.ts index 4818dacb07..7b6d12afb1 100644 --- a/apps/cli/commands/preview/tests/update.test.ts +++ b/apps/cli/commands/preview/tests/update.test.ts @@ -5,8 +5,9 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { Archiver } from 'archiver'; import { vi } from 'vitest'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; -import { getAuthToken, getSiteByFolder } from 'cli/lib/appdata'; +import { getAuthToken } from 'cli/lib/appdata'; import { archiveSiteContent, cleanup } from 'cli/lib/archive'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { updateSnapshotInAppdata, getSnapshotsFromAppdata } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { mockReportStart, mockReportSuccess, mockReportError } from 'cli/tests/test-utils'; @@ -19,6 +20,12 @@ vi.mock( 'cli/lib/appdata', async () => { ...actual, getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), getAuthToken: vi.fn(), + }; +} ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); + return { + ...actual, getSiteByFolder: vi.fn(), }; } ); diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index 146db4e4d7..3d17e24a8b 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -7,8 +7,9 @@ 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, getSiteByFolder } from 'cli/lib/appdata'; +import { getAuthToken } from 'cli/lib/appdata'; import { cleanup, archiveSiteContent } from 'cli/lib/archive'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { getSnapshotsFromAppdata, updateSnapshotInAppdata } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index f37e20f565..a7a103806c 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -50,15 +50,15 @@ import { type StepDefinition, } from '@wp-playground/blueprints'; import { - lockAppdata, - readAppdata, - removeSiteFromAppdata, - saveAppdata, + lockCliConfig, + readCliConfig, + removeSiteFromConfig, + saveCliConfig, SiteData, - unlockAppdata, + unlockCliConfig, updateSiteAutoStart, updateSiteLatestCliPid, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; import { generateSiteName, getDefaultSitePath } from 'cli/lib/generate-site-name'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; @@ -165,17 +165,17 @@ export async function runCommand( } } - const appdata = await readAppdata(); - if ( appdata.sites.some( ( site ) => arePathsEqual( site.path, sitePath ) ) ) { + const cliConfig = await readCliConfig(); + if ( cliConfig.sites.some( ( site ) => arePathsEqual( site.path, sitePath ) ) ) { throw new LoggerError( __( 'The selected directory is already in use.' ) ); } - for ( const site of appdata.sites ) { + for ( const site of cliConfig.sites ) { portFinder.addUnavailablePort( site.port ); } if ( options.customDomain ) { - const existingDomains = appdata.sites + const existingDomains = cliConfig.sites .map( ( site ) => site.customDomain ) .filter( ( domain ): domain is string => Boolean( domain ) ); const domainError = getDomainNameValidationError( @@ -350,16 +350,16 @@ export async function runCommand( logger.reportStart( LoggerAction.SAVE_SITE, __( 'Saving site…' ) ); try { - await lockAppdata(); - const userData = await readAppdata(); + await lockCliConfig(); + const userData = await readCliConfig(); userData.sites.push( siteDetails ); sortSites( userData.sites ); - await saveAppdata( userData ); + await saveCliConfig( userData ); logger.reportSuccess( __( 'Site created successfully' ) ); } finally { - await unlockAppdata(); + await unlockCliConfig(); } if ( ! options.noStart ) { @@ -400,7 +400,7 @@ export async function runCommand( await openSiteInBrowser( siteDetails ); } } catch ( error ) { - await removeSiteFromAppdata( siteDetails.id ); + await removeSiteFromConfig( siteDetails.id ); if ( ! isWordPressDirResult ) { await fs.promises.rm( sitePath, { recursive: true, force: true } ); } @@ -426,7 +426,7 @@ export async function runCommand( stripWpConfigDbConstants( sitePath ); } catch ( error ) { - await removeSiteFromAppdata( siteDetails.id ); + await removeSiteFromConfig( siteDetails.id ); if ( ! isWordPressDirResult ) { await fs.promises.rm( sitePath, { recursive: true, force: true } ); } @@ -661,8 +661,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { } if ( ! customDomain ) { - const appdata = await readAppdata(); - const existingDomains = appdata.sites + const cliConfig = await readCliConfig(); + const existingDomains = cliConfig.sites .map( ( site ) => site.customDomain ) .filter( ( domain ): domain is string => Boolean( domain ) ); diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 4e758b8bdb..72453a14e0 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -4,16 +4,15 @@ import { SITE_EVENTS } from '@studio/common/lib/site-events'; 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 { getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, - getAuthToken, - ValidatedAuthToken, -} from 'cli/lib/appdata'; -import { deleteSiteCertificate } from 'cli/lib/certificate-manager'; + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; @@ -106,16 +105,16 @@ export async function runCommand( } try { - await lockAppdata(); - const appdata = await readAppdata(); - const siteIndex = appdata.sites.findIndex( ( s ) => arePathsEqual( s.path, siteFolder ) ); + await lockCliConfig(); + const cliConfig = await readCliConfig(); + const siteIndex = cliConfig.sites.findIndex( ( s ) => arePathsEqual( s.path, siteFolder ) ); if ( siteIndex === -1 ) { throw new LoggerError( __( 'The specified directory is not added to Studio.' ) ); } - appdata.sites.splice( siteIndex, 1 ); - await saveAppdata( appdata ); + cliConfig.sites.splice( siteIndex, 1 ); + await saveCliConfig( cliConfig ); } finally { - await unlockAppdata(); + await unlockCliConfig(); } if ( deleteFiles ) { diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index d422691c61..bb9586a92c 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -2,7 +2,7 @@ import { type SiteDetails } from '@studio/common/lib/site-events'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import Table from 'cli-table3'; -import { getSiteUrl, readAppdata, type SiteData } from 'cli/lib/appdata'; +import { getSiteUrl, readCliConfig, type SiteData } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isSiteRunning } from 'cli/lib/site-utils'; import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; @@ -89,16 +89,16 @@ const logger = new Logger< LoggerAction >(); export async function runCommand( format: 'table' | 'json' ): Promise< void > { try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading sites…' ) ); - const appdata = await readAppdata(); + const cliConfig = await readCliConfig(); - if ( appdata.sites.length === 0 ) { + if ( cliConfig.sites.length === 0 ) { logger.reportSuccess( __( 'No sites found' ) ); return; } const sitesMessage = sprintf( - _n( 'Found %d site', 'Found %d sites', appdata.sites.length ), - appdata.sites.length + _n( 'Found %d site', 'Found %d sites', cliConfig.sites.length ), + cliConfig.sites.length ); logger.reportSuccess( sitesMessage ); @@ -106,7 +106,7 @@ export async function runCommand( format: 'table' | 'json' ): Promise< void > { await connectToDaemon(); logger.reportSuccess( __( 'Connected to process daemon' ) ); - const siteListData = await getSiteListData( appdata.sites ); + const siteListData = await getSiteListData( cliConfig.sites ); displaySiteList( siteListData, format ); } finally { await disconnectFromDaemon(); diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index e4894151e4..d70e8351ea 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -18,12 +18,12 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-a import { __, sprintf } from '@wordpress/i18n'; import { getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, updateSiteLatestCliPid, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; import { updateDomainInHosts } from 'cli/lib/hosts-file'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; @@ -122,10 +122,10 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) let site = await getSiteByFolder( sitePath ); logger.reportSuccess( __( 'Site loaded' ) ); - const initialAppdata = await readAppdata(); + const initialCliConfig = await readCliConfig(); if ( domain ) { - const existingDomainNames = initialAppdata.sites + const existingDomainNames = initialCliConfig.sites .filter( ( s ) => s.id !== site.id ) .map( ( s ) => s.customDomain ) .filter( ( d ): d is string => Boolean( d ) ); @@ -143,7 +143,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) } if ( xdebug === true ) { - const otherXdebugSite = initialAppdata.sites.find( + const otherXdebugSite = initialCliConfig.sites.find( ( s ) => s.enableXdebug && s.id !== site.id ); if ( otherXdebugSite ) { @@ -201,9 +201,9 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const oldDomain = site.customDomain; try { - await lockAppdata(); - const appdata = await readAppdata(); - const foundSite = appdata.sites.find( ( s ) => arePathsEqual( s.path, sitePath ) ); + await lockCliConfig(); + const cliConfig = await readCliConfig(); + const foundSite = cliConfig.sites.find( ( s ) => arePathsEqual( s.path, sitePath ) ); if ( ! foundSite ) { throw new LoggerError( __( 'The specified directory is not added to Studio.' ) ); } @@ -239,10 +239,10 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) foundSite.enableDebugDisplay = debugDisplay; } - await saveAppdata( appdata ); + await saveCliConfig( cliConfig ); site = foundSite; } finally { - await unlockAppdata(); + await unlockCliConfig(); } if ( domainChanged ) { @@ -285,16 +285,16 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) logger.reportSuccess( __( 'WordPress version updated' ) ); try { - await lockAppdata(); - const appdata = await readAppdata(); - const updatedSite = appdata.sites.find( ( s ) => s.id === site.id ); + await lockCliConfig(); + const cliConfig = await readCliConfig(); + const updatedSite = cliConfig.sites.find( ( s ) => s.id === site.id ); if ( updatedSite ) { updatedSite.isWpAutoUpdating = wp === DEFAULT_WORDPRESS_VERSION; - await saveAppdata( appdata ); + await saveCliConfig( cliConfig ); site = updatedSite; } } finally { - await unlockAppdata(); + await unlockCliConfig(); } exitPhp(); diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index 03c4f69794..d2d13d145e 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -1,6 +1,6 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; -import { getSiteByFolder, updateSiteAutoStart, updateSiteLatestCliPid } from 'cli/lib/appdata'; +import { getSiteByFolder, updateSiteAutoStart, updateSiteLatestCliPid } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; diff --git a/apps/cli/commands/site/status.ts b/apps/cli/commands/site/status.ts index 9334d62846..01078d1f6e 100644 --- a/apps/cli/commands/site/status.ts +++ b/apps/cli/commands/site/status.ts @@ -3,7 +3,7 @@ import { decodePassword } from '@studio/common/lib/passwords'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n } from '@wordpress/i18n'; import CliTable3 from 'cli-table3'; -import { getSiteByFolder, getSiteUrl } from 'cli/lib/appdata'; +import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getPrettyPath } from 'cli/lib/utils'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; diff --git a/apps/cli/commands/site/stop.ts b/apps/cli/commands/site/stop.ts index 3699b6a7ae..4ca486ccec 100644 --- a/apps/cli/commands/site/stop.ts +++ b/apps/cli/commands/site/stop.ts @@ -3,13 +3,13 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import { clearSiteLatestCliPid, getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, updateSiteAutoStart, type SiteData, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon, @@ -65,10 +65,10 @@ export async function runCommand( throw new LoggerError( __( 'Failed to stop WordPress server' ), error ); } } else { - const appdata = await readAppdata(); + const cliConfig = await readCliConfig(); const runningSites: SiteData[] = []; - for ( const site of appdata.sites ) { + for ( const site of cliConfig.sites ) { const runningProcess = await isServerRunning( site.id ); if ( runningProcess ) { @@ -81,17 +81,17 @@ export async function runCommand( logger.reportSuccess( __( 'No sites are currently running' ) ); } else { try { - await lockAppdata(); - const appdata = await readAppdata(); - for ( const site of appdata.sites ) { + await lockCliConfig(); + const cliConfig = await readCliConfig(); + for ( const site of cliConfig.sites ) { if ( runningSites.find( ( r ) => r.id === site.id ) ) { delete site.latestCliPid; site.autoStart = autoStart; } } - await saveAppdata( appdata ); + await saveCliConfig( cliConfig ); } finally { - await unlockAppdata(); + await unlockCliConfig(); } logger.reportStart( LoggerAction.STOP_ALL_SITES, __( 'Stopping all WordPress servers…' ) ); diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index 6a899eccd4..56632a4947 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -16,14 +16,14 @@ import { normalizeLineEndings } from '@studio/common/lib/remove-default-db-const import { Blueprint, BlueprintV1Declaration, StepDefinition } from '@wp-playground/blueprints'; import { vi, type MockInstance } from 'vitest'; import { - lockAppdata, - readAppdata, - removeSiteFromAppdata, - saveAppdata, - unlockAppdata, + lockCliConfig, + readCliConfig, + removeSiteFromConfig, + saveCliConfig, + unlockCliConfig, updateSiteAutoStart, SiteData, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { getServerFilesPath } from 'cli/lib/server-files'; @@ -47,18 +47,17 @@ vi.mock( '@studio/common/lib/passwords', () => ( { createPassword: vi.fn().mockReturnValue( 'generated-password-123' ), } ) ); vi.mock( '@studio/common/lib/blueprint-validation' ); -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); return { ...actual, - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - readAppdata: vi.fn(), - saveAppdata: vi.fn(), - lockAppdata: vi.fn(), - unlockAppdata: vi.fn(), + readCliConfig: vi.fn(), + saveCliConfig: vi.fn(), + lockCliConfig: vi.fn(), + unlockCliConfig: vi.fn(), updateSiteLatestCliPid: vi.fn(), updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), - removeSiteFromAppdata: vi.fn(), + removeSiteFromConfig: vi.fn(), getSiteUrl: vi.fn().mockImplementation( ( site ) => `http://localhost:${ site.port }` ), }; } ); @@ -141,13 +140,13 @@ describe( 'CLI: studio site create', () => { vi.mocked( arePathsEqual ).mockImplementation( ( a, b ) => a === b ); vi.mocked( recursiveCopyDirectory ).mockResolvedValue( undefined ); vi.mocked( portFinder.getOpenPort ).mockResolvedValue( mockPort ); - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ ...mockAppdata.sites ], - snapshots: [ ...mockAppdata.snapshots ], } ); - vi.mocked( saveAppdata ).mockResolvedValue( undefined ); - vi.mocked( lockAppdata ).mockResolvedValue( undefined ); - vi.mocked( unlockAppdata ).mockResolvedValue( undefined ); + vi.mocked( saveCliConfig ).mockResolvedValue( undefined ); + vi.mocked( lockCliConfig ).mockResolvedValue( undefined ); + vi.mocked( unlockCliConfig ).mockResolvedValue( undefined ); vi.mocked( keepSqliteIntegrationUpdated ).mockResolvedValue( true ); vi.mocked( connectToDaemon ).mockResolvedValue( undefined ); vi.mocked( disconnectFromDaemon ).mockResolvedValue( undefined ); @@ -183,9 +182,9 @@ describe( 'CLI: studio site create', () => { } ); it( 'should error if site path is already in use', async () => { - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ mockExistingSite ], - snapshots: [], } ); vi.mocked( arePathsEqual ).mockReturnValue( true ); @@ -208,9 +207,9 @@ describe( 'CLI: studio site create', () => { } ); it( 'should error if custom domain is already in use', async () => { - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ { ...mockExistingSite, customDomain: 'mysite.local' } ], - snapshots: [], } ); await expect( @@ -263,8 +262,8 @@ describe( 'CLI: studio site create', () => { expect( keepSqliteIntegrationUpdated ).toHaveBeenCalledWith( mockSitePath ); expect( loggerReportSuccessSpy ).toHaveBeenCalledWith( 'SQLite integration configured' ); expect( portFinder.getOpenPort ).toHaveBeenCalled(); - expect( lockAppdata ).toHaveBeenCalled(); - expect( saveAppdata ).toHaveBeenCalled(); + expect( lockCliConfig ).toHaveBeenCalled(); + expect( saveCliConfig ).toHaveBeenCalled(); expect( connectToDaemon ).toHaveBeenCalled(); expect( startWordPressServer ).toHaveBeenCalled(); expect( updateSiteAutoStart ).toHaveBeenCalledWith( expect.any( String ), true ); @@ -288,7 +287,7 @@ describe( 'CLI: studio site create', () => { name: 'My Custom Site', } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -380,7 +379,7 @@ describe( 'CLI: studio site create', () => { it( 'should use folder name as site name if no name provided', async () => { await runCommand( mockSitePath, { ...defaultTestOptions } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -416,7 +415,7 @@ describe( 'CLI: studio site create', () => { customDomain: 'mysite.local', } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -435,7 +434,7 @@ describe( 'CLI: studio site create', () => { enableHttps: true, } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -447,9 +446,9 @@ describe( 'CLI: studio site create', () => { } ); it( 'should add existing site ports to unavailable ports', async () => { - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ mockExistingSite ], - snapshots: [], } ); await runCommand( mockSitePath, { ...defaultTestOptions } ); @@ -460,7 +459,7 @@ describe( 'CLI: studio site create', () => { it( 'should set isWpAutoUpdating true for latest WordPress version', async () => { await runCommand( mockSitePath, { ...defaultTestOptions } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -477,7 +476,7 @@ describe( 'CLI: studio site create', () => { wpVersion: '6.4', } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -792,7 +791,7 @@ describe( 'CLI: studio site create', () => { describe( 'Cleanup', () => { it( 'should disconnect from process manager even on error', async () => { - vi.mocked( readAppdata ).mockRejectedValue( new Error( 'Appdata error' ) ); + vi.mocked( readCliConfig ).mockRejectedValue( new Error( 'Appdata error' ) ); try { await runCommand( mockSitePath, { ...defaultTestOptions } ); @@ -812,7 +811,7 @@ describe( 'CLI: studio site create', () => { it( 'should unlock appdata after saving', async () => { await runCommand( mockSitePath, { ...defaultTestOptions } ); - expect( unlockAppdata ).toHaveBeenCalled(); + expect( unlockCliConfig ).toHaveBeenCalled(); } ); it( 'should remove site from appdata when server start fails', async () => { @@ -820,7 +819,7 @@ describe( 'CLI: studio site create', () => { await expect( runCommand( mockSitePath, { ...defaultTestOptions } ) ).rejects.toThrow(); - expect( removeSiteFromAppdata ).toHaveBeenCalled(); + expect( removeSiteFromConfig ).toHaveBeenCalled(); } ); it( 'should remove site from appdata when Blueprint application fails', async () => { @@ -838,7 +837,7 @@ describe( 'CLI: studio site create', () => { } ) ).rejects.toThrow(); - expect( removeSiteFromAppdata ).toHaveBeenCalled(); + expect( removeSiteFromConfig ).toHaveBeenCalled(); } ); it( 'should delete site directory when server start fails for new directory', async () => { diff --git a/apps/cli/commands/site/tests/delete.test.ts b/apps/cli/commands/site/tests/delete.test.ts index 416fe2102c..4dceb44446 100644 --- a/apps/cli/commands/site/tests/delete.test.ts +++ b/apps/cli/commands/site/tests/delete.test.ts @@ -3,16 +3,16 @@ import { arePathsEqual } from '@studio/common/lib/fs-utils'; 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, getSiteByFolder, - lockAppdata, - readAppdata, - saveAppdata, - unlockAppdata, - getAuthToken, -} from 'cli/lib/appdata'; -import { deleteSiteCertificate } from 'cli/lib/certificate-manager'; + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; @@ -27,14 +27,20 @@ vi.mock( 'cli/lib/appdata', async () => { const actual = await vi.importActual( 'cli/lib/appdata' ); return { ...actual, - getSiteByFolder: vi.fn(), - lockAppdata: vi.fn(), - readAppdata: vi.fn(), - saveAppdata: vi.fn(), - unlockAppdata: vi.fn(), getAuthToken: vi.fn(), }; } ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); + return { + ...actual, + getSiteByFolder: vi.fn(), + lockCliConfig: vi.fn(), + readCliConfig: vi.fn(), + saveCliConfig: vi.fn(), + unlockCliConfig: vi.fn(), + }; +} ); vi.mock( 'cli/lib/certificate-manager' ); vi.mock( 'cli/lib/hosts-file' ); vi.mock( 'cli/lib/daemon-client' ); @@ -103,13 +109,13 @@ describe( 'CLI: studio site delete', () => { vi.mocked( connectToDaemon ).mockResolvedValue( undefined ); vi.mocked( disconnectFromDaemon ).mockResolvedValue( undefined ); vi.mocked( getAuthToken ).mockResolvedValue( testAuthToken ); - vi.mocked( lockAppdata ).mockResolvedValue( undefined ); - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( lockCliConfig ).mockResolvedValue( undefined ); + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ testSite ], - snapshots: [], } ); - vi.mocked( saveAppdata ).mockResolvedValue( undefined ); - vi.mocked( unlockAppdata ).mockResolvedValue( undefined ); + vi.mocked( saveCliConfig ).mockResolvedValue( undefined ); + vi.mocked( unlockCliConfig ).mockResolvedValue( undefined ); vi.mocked( isServerRunning ).mockResolvedValue( undefined ); vi.mocked( stopWordPressServer ).mockResolvedValue( undefined ); vi.mocked( removeDomainFromHosts ).mockResolvedValue( undefined ); @@ -137,16 +143,16 @@ describe( 'CLI: studio site delete', () => { } ); it( 'should throw when appdata read fails', async () => { - vi.mocked( readAppdata ).mockRejectedValue( new Error( 'Read failed' ) ); + vi.mocked( readCliConfig ).mockRejectedValue( new Error( 'Read failed' ) ); await expect( runCommand( testSiteFolder ) ).rejects.toThrow(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); it( 'should throw when site not found in appdata', async () => { - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [], - snapshots: [], } ); await expect( runCommand( testSiteFolder ) ).rejects.toThrow( @@ -175,7 +181,7 @@ describe( 'CLI: studio site delete', () => { vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); await expect( runCommand( testSiteFolder, false ) ).resolves.not.toThrow(); - expect( saveAppdata ).toHaveBeenCalled(); + expect( saveCliConfig ).toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); } ); @@ -189,12 +195,12 @@ describe( 'CLI: studio site delete', () => { expect( connectToDaemon ).toHaveBeenCalled(); expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); expect( stopWordPressServer ).not.toHaveBeenCalled(); - expect( lockAppdata ).toHaveBeenCalled(); - expect( readAppdata ).toHaveBeenCalled(); - expect( saveAppdata ).toHaveBeenCalled(); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites ).toHaveLength( 0 ); - expect( unlockAppdata ).toHaveBeenCalled(); + expect( lockCliConfig ).toHaveBeenCalled(); + expect( readCliConfig ).toHaveBeenCalled(); + expect( saveCliConfig ).toHaveBeenCalled(); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites ).toHaveLength( 0 ); + expect( unlockCliConfig ).toHaveBeenCalled(); expect( deleteSnapshot ).not.toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); @@ -207,9 +213,9 @@ describe( 'CLI: studio site delete', () => { expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); expect( stopWordPressServer ).toHaveBeenCalledWith( testSite.id ); - expect( saveAppdata ).toHaveBeenCalled(); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites ).toHaveLength( 0 ); + expect( saveCliConfig ).toHaveBeenCalled(); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites ).toHaveLength( 0 ); expect( stopProxyIfNoSitesNeedIt ).toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); @@ -219,9 +225,9 @@ describe( 'CLI: studio site delete', () => { await runCommand( testSiteFolder, true ); - expect( saveAppdata ).toHaveBeenCalled(); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites ).toHaveLength( 0 ); + expect( saveCliConfig ).toHaveBeenCalled(); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites ).toHaveLength( 0 ); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); @@ -263,9 +269,9 @@ describe( 'CLI: studio site delete', () => { it( 'should remove custom domain from hosts file if present', async () => { testSite = createTestSite( { customDomain: 'example.local' } ); vi.mocked( getSiteByFolder ).mockResolvedValue( testSite ); - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ testSite ], - snapshots: [], } ); vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); @@ -279,9 +285,9 @@ describe( 'CLI: studio site delete', () => { it( 'should delete SSL certificate if custom domain and HTTPS are enabled', async () => { testSite = createTestSite( { customDomain: 'example.local', enableHttps: true } ); vi.mocked( getSiteByFolder ).mockResolvedValue( testSite ); - vi.mocked( readAppdata, { partial: true } ).mockResolvedValue( { + vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { + version: 1, sites: [ testSite ], - snapshots: [], } ); vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); @@ -298,9 +304,9 @@ describe( 'CLI: studio site delete', () => { await runCommand( testSiteFolder, true ); - expect( saveAppdata ).toHaveBeenCalled(); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites ).toHaveLength( 0 ); + expect( saveCliConfig ).toHaveBeenCalled(); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites ).toHaveLength( 0 ); expect( trash ).not.toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); diff --git a/apps/cli/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index 566e44790a..d81b49fbc2 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -1,15 +1,14 @@ import { vi } from 'vitest'; -import { readAppdata } from 'cli/lib/appdata'; +import { readCliConfig } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { mockReportKeyValuePair } from 'cli/tests/test-utils'; import { runCommand } from '../list'; -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); return { ...actual, - readAppdata: vi.fn(), - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), + readCliConfig: vi.fn(), }; } ); vi.mock( 'cli/lib/daemon-client' ); @@ -29,8 +28,8 @@ vi.mock( 'cli/logger', () => ( { } ) ); describe( 'CLI: studio site list', () => { - // Simple test data - const testAppdata = { + const testCliConfig = { + version: 1, sites: [ { id: 'site-1', @@ -48,18 +47,17 @@ describe( 'CLI: studio site list', () => { customDomain: 'my-site.wp.local', }, ], - snapshots: [], }; - const emptyAppdata = { + const emptyCliConfig = { + version: 1, sites: [], - snapshots: [], }; beforeEach( () => { vi.clearAllMocks(); - vi.mocked( readAppdata ).mockResolvedValue( testAppdata ); + vi.mocked( readCliConfig ).mockResolvedValue( testCliConfig ); vi.mocked( connectToDaemon ).mockResolvedValue( undefined ); vi.mocked( disconnectFromDaemon ).mockResolvedValue( undefined ); vi.mocked( isServerRunning ).mockResolvedValue( undefined ); @@ -70,10 +68,10 @@ describe( 'CLI: studio site list', () => { } ); describe( 'Error Cases', () => { - it( 'should throw when appdata read fails', async () => { - vi.mocked( readAppdata ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); + it( 'should throw when config read fails', async () => { + vi.mocked( readCliConfig ).mockRejectedValue( new Error( 'Failed to read config' ) ); - await expect( runCommand( 'table' ) ).rejects.toThrow( 'Failed to read appdata' ); + await expect( runCommand( 'table' ) ).rejects.toThrow( 'Failed to read config' ); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); } ); @@ -84,7 +82,7 @@ describe( 'CLI: studio site list', () => { await runCommand( 'table' ); - expect( readAppdata ).toHaveBeenCalled(); + expect( readCliConfig ).toHaveBeenCalled(); expect( consoleSpy ).toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); @@ -122,11 +120,11 @@ describe( 'CLI: studio site list', () => { } ); it( 'should handle no sites found', async () => { - vi.mocked( readAppdata ).mockResolvedValue( emptyAppdata ); + vi.mocked( readCliConfig ).mockResolvedValue( emptyCliConfig ); await runCommand( 'table' ); - expect( readAppdata ).toHaveBeenCalled(); + expect( readCliConfig ).toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index 2d72ae6d6a..b709707c5c 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -5,11 +5,11 @@ import { encodePassword } from '@studio/common/lib/passwords'; import { vi } from 'vitest'; import { getSiteByFolder, - unlockAppdata, - readAppdata, - saveAppdata, + unlockCliConfig, + readCliConfig, + saveCliConfig, SiteData, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { updateDomainInHosts } from 'cli/lib/hosts-file'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; @@ -30,15 +30,15 @@ vi.mock( '@studio/common/lib/fs-utils', async () => { arePathsEqual: vi.fn(), }; } ); -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); return { ...actual, getSiteByFolder: vi.fn(), - lockAppdata: vi.fn().mockResolvedValue( undefined ), - unlockAppdata: vi.fn().mockResolvedValue( undefined ), - readAppdata: vi.fn(), - saveAppdata: vi.fn().mockResolvedValue( undefined ), + lockCliConfig: vi.fn().mockResolvedValue( undefined ), + unlockCliConfig: vi.fn().mockResolvedValue( undefined ), + readCliConfig: vi.fn(), + saveCliConfig: vi.fn().mockResolvedValue( undefined ), updateSiteLatestCliPid: vi.fn().mockResolvedValue( undefined ), }; } ); @@ -78,11 +78,11 @@ describe( 'CLI: studio site set', () => { vi.clearAllMocks(); const testSite = getTestSite(); - const testAppdata = { sites: [ testSite ], snapshots: [] }; + const testCliConfig = { version: 1, sites: [ testSite ] }; vi.mocked( arePathsEqual ).mockReturnValue( true ); vi.mocked( getSiteByFolder ).mockResolvedValue( getTestSite() ); - vi.mocked( readAppdata ).mockResolvedValue( testAppdata ); + vi.mocked( readCliConfig ).mockResolvedValue( testCliConfig ); vi.mocked( connectToDaemon ).mockResolvedValue( undefined ); vi.mocked( disconnectFromDaemon ).mockResolvedValue( undefined ); vi.mocked( isServerRunning ).mockResolvedValue( undefined ); @@ -134,23 +134,23 @@ describe( 'CLI: studio site set', () => { it( 'should allow enabling HTTPS when domain is being set', async () => { await runCommand( testSitePath, { domain: 'new.local', https: true } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].customDomain ).toBe( 'new.local' ); - expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].customDomain ).toBe( 'new.local' ); + expect( savedCliConfig.sites[ 0 ].enableHttps ).toBe( true ); } ); it( 'should allow enabling HTTPS when site already has domain', async () => { const siteWithDomain = getTestSiteWithDomain(); vi.mocked( getSiteByFolder ).mockResolvedValue( siteWithDomain ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], - snapshots: [], + version: 1, } ); await runCommand( testSitePath, { https: true } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].enableHttps ).toBe( true ); } ); } ); @@ -160,8 +160,8 @@ describe( 'CLI: studio site set', () => { await runCommand( testSitePath, { name: 'New Name' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].name ).toBe( 'New Name' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].name ).toBe( 'New Name' ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); } ); @@ -171,8 +171,8 @@ describe( 'CLI: studio site set', () => { it( 'should update domain and hosts file', async () => { await runCommand( testSitePath, { domain: 'new.local' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].customDomain ).toBe( 'new.local' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].customDomain ).toBe( 'new.local' ); expect( updateDomainInHosts ).toHaveBeenCalledWith( undefined, 'new.local', 8080 ); } ); @@ -197,15 +197,15 @@ describe( 'CLI: studio site set', () => { it( 'should update HTTPS setting', async () => { const siteWithDomain = getTestSiteWithDomain(); vi.mocked( getSiteByFolder ).mockResolvedValue( siteWithDomain ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], - snapshots: [], + version: 1, } ); await runCommand( testSitePath, { https: true } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].enableHttps ).toBe( true ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].enableHttps ).toBe( true ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); } ); @@ -213,9 +213,9 @@ describe( 'CLI: studio site set', () => { it( 'should restart running site when HTTPS changes', async () => { const siteWithDomain = getTestSiteWithDomain(); vi.mocked( getSiteByFolder ).mockResolvedValue( siteWithDomain ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], - snapshots: [], + version: 1, } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); @@ -230,8 +230,8 @@ describe( 'CLI: studio site set', () => { it( 'should update PHP version', async () => { await runCommand( testSitePath, { php: '8.2' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '8.2' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].phpVersion ).toBe( '8.2' ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); } ); @@ -294,7 +294,7 @@ describe( 'CLI: studio site set', () => { it( 'should update isWpAutoUpdating to false when using specific version', async () => { await runCommand( testSitePath, { wp: '6.8' } ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { isWpAutoUpdating: false } ), @@ -312,10 +312,10 @@ describe( 'CLI: studio site set', () => { php: '8.2', } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].name ).toBe( 'New Name' ); - expect( savedAppdata.sites[ 0 ].customDomain ).toBe( 'new.local' ); - expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '8.2' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].name ).toBe( 'New Name' ); + expect( savedCliConfig.sites[ 0 ].customDomain ).toBe( 'new.local' ); + expect( savedCliConfig.sites[ 0 ].phpVersion ).toBe( '8.2' ); } ); it( 'should only restart once when multiple changes need restart', async () => { @@ -356,9 +356,9 @@ describe( 'CLI: studio site set', () => { enableXdebug: true, }; vi.mocked( getSiteByFolder ).mockResolvedValue( testSite ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ testSite, otherSite ], - snapshots: [], + version: 1, } ); await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow( @@ -369,8 +369,8 @@ describe( 'CLI: studio site set', () => { it( 'should update xdebug setting without restart when site is stopped', async () => { await runCommand( testSitePath, { xdebug: true } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( true ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].enableXdebug ).toBe( true ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); } ); @@ -387,23 +387,23 @@ describe( 'CLI: studio site set', () => { it( 'should disable xdebug', async () => { const siteWithXdebug = { ...getTestSite(), enableXdebug: true }; vi.mocked( getSiteByFolder ).mockResolvedValue( siteWithXdebug ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebug ], - snapshots: [], + version: 1, } ); await runCommand( testSitePath, { xdebug: false } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( false ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].enableXdebug ).toBe( false ); } ); it( 'should throw when xdebug is already enabled', async () => { const siteWithXdebug = { ...getTestSite(), enableXdebug: true }; vi.mocked( getSiteByFolder ).mockResolvedValue( siteWithXdebug ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebug ], - snapshots: [], + version: 1, } ); await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow( @@ -414,9 +414,9 @@ describe( 'CLI: studio site set', () => { it( 'should throw when xdebug is already disabled', async () => { const siteWithXdebugDisabled = { ...getTestSite(), enableXdebug: false }; vi.mocked( getSiteByFolder ).mockResolvedValue( siteWithXdebugDisabled ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebugDisabled ], - snapshots: [], + version: 1, } ); await expect( runCommand( testSitePath, { xdebug: false } ) ).rejects.toThrow( @@ -431,8 +431,8 @@ describe( 'CLI: studio site set', () => { await runCommand( testSitePath, { adminUsername: 'newadmin' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); expect( startWordPressServer ).toHaveBeenCalled(); } ); @@ -442,8 +442,8 @@ describe( 'CLI: studio site set', () => { await runCommand( testSitePath, { adminPassword: 'newpass123' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminPassword ).toBe( encodePassword( 'newpass123' ) ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminPassword ).toBe( encodePassword( 'newpass123' ) ); expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); expect( startWordPressServer ).toHaveBeenCalled(); } ); @@ -451,8 +451,8 @@ describe( 'CLI: studio site set', () => { it( 'should not restart stopped site when credentials change', async () => { await runCommand( testSitePath, { adminUsername: 'newadmin' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); } ); @@ -491,9 +491,9 @@ describe( 'CLI: studio site set', () => { it( 'should update both credentials at once', async () => { await runCommand( testSitePath, { adminUsername: 'newadmin', adminPassword: 'newpass' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); - expect( savedAppdata.sites[ 0 ].adminPassword ).toBe( encodePassword( 'newpass' ) ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminUsername ).toBe( 'newadmin' ); + expect( savedCliConfig.sites[ 0 ].adminPassword ).toBe( encodePassword( 'newpass' ) ); } ); } ); @@ -503,8 +503,8 @@ describe( 'CLI: studio site set', () => { await runCommand( testSitePath, { adminEmail: 'test@example.com' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminEmail ).toBe( 'test@example.com' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminEmail ).toBe( 'test@example.com' ); expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); expect( startWordPressServer ).toHaveBeenCalled(); } ); @@ -512,17 +512,17 @@ describe( 'CLI: studio site set', () => { it( 'should not restart stopped site when email changes', async () => { await runCommand( testSitePath, { adminEmail: 'test@example.com' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminEmail ).toBe( 'test@example.com' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminEmail ).toBe( 'test@example.com' ); expect( stopWordPressServer ).not.toHaveBeenCalled(); expect( startWordPressServer ).not.toHaveBeenCalled(); } ); it( 'should ignore whitespace-only admin email', async () => { await runCommand( testSitePath, { adminEmail: ' ', name: 'New Name' } ); - const savedAppdata = vi.mocked( saveAppdata ).mock.calls[ 0 ][ 0 ]; - expect( savedAppdata.sites[ 0 ].adminEmail ).toBeUndefined(); - expect( savedAppdata.sites[ 0 ].name ).toBe( 'New Name' ); + const savedCliConfig = vi.mocked( saveCliConfig ).mock.calls[ 0 ][ 0 ]; + expect( savedCliConfig.sites[ 0 ].adminEmail ).toBeUndefined(); + expect( savedCliConfig.sites[ 0 ].name ).toBe( 'New Name' ); } ); it( 'should throw when admin email is invalid', async () => { @@ -543,7 +543,7 @@ describe( 'CLI: studio site set', () => { } ); it( 'should always disconnect process manager on error', async () => { - vi.mocked( saveAppdata ).mockRejectedValue( new Error( 'Save failed' ) ); + vi.mocked( saveCliConfig ).mockRejectedValue( new Error( 'Save failed' ) ); await expect( runCommand( testSitePath, { name: 'New Name' } ) ).rejects.toThrow( 'Save failed' @@ -552,10 +552,10 @@ describe( 'CLI: studio site set', () => { } ); it( 'should always unlock appdata on error', async () => { - vi.mocked( saveAppdata ).mockRejectedValue( new Error( 'Save failed' ) ); + vi.mocked( saveCliConfig ).mockRejectedValue( new Error( 'Save failed' ) ); await expect( runCommand( testSitePath, { name: 'New Name' } ) ).rejects.toThrow(); - expect( unlockAppdata ).toHaveBeenCalled(); + expect( unlockCliConfig ).toHaveBeenCalled(); } ); } ); } ); diff --git a/apps/cli/commands/site/tests/start.test.ts b/apps/cli/commands/site/tests/start.test.ts index 1a32648346..abfbedae11 100644 --- a/apps/cli/commands/site/tests/start.test.ts +++ b/apps/cli/commands/site/tests/start.test.ts @@ -4,7 +4,7 @@ import { updateSiteLatestCliPid, updateSiteAutoStart, SiteData, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; @@ -13,12 +13,11 @@ import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server- import { Logger } from 'cli/logger'; import { runCommand } from '../start'; -vi.mock( 'cli/lib/appdata', async () => ( { - ...( await vi.importActual( 'cli/lib/appdata' ) ), +vi.mock( 'cli/lib/cli-config', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config' ) ), getSiteByFolder: vi.fn(), updateSiteLatestCliPid: vi.fn(), updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), } ) ); vi.mock( 'cli/lib/daemon-client' ); vi.mock( 'cli/lib/site-utils' ); diff --git a/apps/cli/commands/site/tests/status.test.ts b/apps/cli/commands/site/tests/status.test.ts index bf7df3a585..4e6770494f 100644 --- a/apps/cli/commands/site/tests/status.test.ts +++ b/apps/cli/commands/site/tests/status.test.ts @@ -1,16 +1,15 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { vi } from 'vitest'; -import { getSiteByFolder, getSiteUrl } from 'cli/lib/appdata'; +import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { runCommand } from '../status'; -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); return { ...actual, getSiteByFolder: vi.fn(), getSiteUrl: vi.fn(), - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), }; } ); vi.mock( 'cli/lib/daemon-client' ); diff --git a/apps/cli/commands/site/tests/stop.test.ts b/apps/cli/commands/site/tests/stop.test.ts index 9afa8c0fee..ae9aec153b 100644 --- a/apps/cli/commands/site/tests/stop.test.ts +++ b/apps/cli/commands/site/tests/stop.test.ts @@ -3,10 +3,10 @@ import { SiteData, clearSiteLatestCliPid, getSiteByFolder, - readAppdata, - saveAppdata, + readCliConfig, + saveCliConfig, updateSiteAutoStart, -} from 'cli/lib/appdata'; +} from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon, @@ -17,16 +17,15 @@ import { ProcessDescription } from 'cli/lib/types/process-manager-ipc'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Mode, runCommand } from '../stop'; -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); return { ...actual, getSiteByFolder: vi.fn(), - readAppdata: vi.fn(), + readCliConfig: vi.fn(), clearSiteLatestCliPid: vi.fn(), updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - saveAppdata: vi.fn().mockResolvedValue( undefined ), + saveCliConfig: vi.fn().mockResolvedValue( undefined ), }; } ); vi.mock( 'cli/lib/daemon-client' ); @@ -230,7 +229,7 @@ describe( 'CLI: studio site stop --all', () => { describe( 'Error Cases', () => { it( 'should throw when appdata cannot be read', async () => { - vi.mocked( readAppdata ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); + vi.mocked( readCliConfig ).mockRejectedValue( new Error( 'Failed to read appdata' ) ); await expect( runCommand( Mode.STOP_ALL_SITES, undefined, false ) ).rejects.toThrow( 'Failed to read appdata' @@ -239,7 +238,7 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should throw when process manager connection fails', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: testSites, snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); vi.mocked( connectToDaemon ).mockRejectedValue( new Error( 'process manager connection failed' ) ); @@ -251,7 +250,7 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should throw when killDaemonAndAllChildren fails', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: testSites, snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); vi.mocked( killDaemonAndChildren ).mockRejectedValue( new Error( 'Failed to kill daemon' ) ); @@ -264,7 +263,7 @@ describe( 'CLI: studio site stop --all', () => { describe( 'Success Cases', () => { it( 'should kill daemon even with empty sites list', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: [], snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [] } ); await runCommand( Mode.STOP_ALL_SITES, undefined, false ); @@ -273,7 +272,7 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should kill daemon even if no sites are running', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: testSites, snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); vi.mocked( isServerRunning ).mockResolvedValue( undefined ); @@ -286,14 +285,14 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should handle single site', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: [ testSites[ 0 ] ], snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [ testSites[ 0 ] ] } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); await runCommand( Mode.STOP_ALL_SITES, undefined, false ); expect( killDaemonAndChildren ).toHaveBeenCalledTimes( 1 ); - expect( saveAppdata ).toHaveBeenCalledTimes( 1 ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledTimes( 1 ); + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -306,12 +305,12 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should stop all running sites', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: testSites, snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); await runCommand( Mode.STOP_ALL_SITES, undefined, false ); - expect( readAppdata ).toHaveBeenCalled(); + expect( readCliConfig ).toHaveBeenCalled(); expect( connectToDaemon ).toHaveBeenCalled(); expect( isServerRunning ).toHaveBeenCalledTimes( 3 ); expect( isServerRunning ).toHaveBeenCalledWith( 'site-1' ); @@ -320,8 +319,8 @@ describe( 'CLI: studio site stop --all', () => { expect( killDaemonAndChildren ).toHaveBeenCalledTimes( 1 ); - expect( saveAppdata ).toHaveBeenCalledTimes( 1 ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledTimes( 1 ); + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -342,7 +341,7 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should stop only running sites (mixed state)', async () => { - vi.mocked( readAppdata ).mockResolvedValue( { sites: testSites, snapshots: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); vi.mocked( isServerRunning ) .mockResolvedValueOnce( testProcessDescription ) // site-1 running @@ -355,8 +354,8 @@ describe( 'CLI: studio site stop --all', () => { expect( killDaemonAndChildren ).toHaveBeenCalledTimes( 1 ); - expect( saveAppdata ).toHaveBeenCalledTimes( 1 ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledTimes( 1 ); + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ expect.objectContaining( { @@ -370,7 +369,7 @@ describe( 'CLI: studio site stop --all', () => { ] ), } ) ); - expect( saveAppdata ).toHaveBeenCalledWith( + expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.not.arrayContaining( [ expect.objectContaining( { diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index f3ac17e948..2a7854b904 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -2,7 +2,7 @@ import { StreamedPHPResponse } from '@php-wasm/universal'; import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; import yargsParser from 'yargs-parser'; -import { getSiteByFolder } from 'cli/lib/appdata'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand, runGlobalWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; diff --git a/apps/cli/lib/appdata.ts b/apps/cli/lib/appdata.ts index 255f08d488..fe0405f789 100644 --- a/apps/cli/lib/appdata.ts +++ b/apps/cli/lib/appdata.ts @@ -2,10 +2,8 @@ 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 { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { getAuthenticationUrl } from '@studio/common/lib/oauth'; -import { siteDetailsSchema } from '@studio/common/lib/site-events'; import { snapshotSchema } from '@studio/common/types/snapshot'; import { StatsMetric } from '@studio/common/types/stats'; import { __, sprintf } from '@wordpress/i18n'; @@ -15,21 +13,11 @@ import { validateAccessToken } from 'cli/lib/api'; import { LoggerError } from 'cli/logger'; import type { AiProviderId } from 'cli/ai/providers'; -const siteSchema = siteDetailsSchema - .extend( { - running: z.boolean().optional(), - url: z.string().optional(), - latestCliPid: z.number().optional(), - enableXdebug: z.boolean().optional(), - } ) - .loose(); - const betaFeaturesSchema = z.object( {} ).loose(); const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-key' ] ); const userDataSchema = z .object( { - sites: z.array( siteSchema ).default( () => [] ), snapshots: z.array( snapshotSchema ).default( () => [] ), locale: z.string().optional(), aiProvider: aiProviderSchema.optional(), @@ -54,7 +42,6 @@ const userDataSchema = z type UserData = z.infer< typeof userDataSchema > & { anthropicApiKey?: string; }; -export type SiteData = z.infer< typeof siteSchema >; export type ValidatedAuthToken = Required< NonNullable< UserData[ 'authToken' ] > >; export function getAppdataDirectory(): string { @@ -168,87 +155,6 @@ export async function getAuthToken(): Promise< ValidatedAuthToken > { } } -export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > { - const userData = await readAppdata(); - const site = userData.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) ); - - if ( ! site ) { - if ( isWordPressDirectory( siteFolder ) ) { - throw new LoggerError( - __( 'The specified directory is not added to Studio. Use `studio site create` to add it.' ) - ); - } - - throw new LoggerError( __( 'The specified directory is not added to Studio.' ) ); - } - - return site; -} - -export function getSiteUrl( site: SiteData ): string { - if ( site.url ) { - return site.url; - } - - if ( site.customDomain ) { - const protocol = site.enableHttps ? 'https' : 'http'; - return `${ protocol }://${ site.customDomain }`; - } - - return `http://localhost:${ site.port }`; -} - -export async function updateSiteLatestCliPid( siteId: string, pid: number ): Promise< void > { - try { - await lockAppdata(); - const userData = await readAppdata(); - const site = userData.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - site.latestCliPid = pid; - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} - -export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { - try { - await lockAppdata(); - const userData = await readAppdata(); - const site = userData.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - delete site.latestCliPid; - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} - -export async function updateSiteAutoStart( siteId: string, autoStart: boolean ): Promise< void > { - try { - await lockAppdata(); - const userData = await readAppdata(); - const site = userData.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - site.autoStart = autoStart; - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} - export async function getAnthropicApiKey(): Promise< string | undefined > { const userData = await readAppdata(); return userData.anthropicApiKey; @@ -280,14 +186,3 @@ export async function saveAiProvider( provider: AiProviderId ): Promise< void > await unlockAppdata(); } } - -export async function removeSiteFromAppdata( siteId: string ): Promise< void > { - try { - await lockAppdata(); - const userData = await readAppdata(); - userData.sites = userData.sites.filter( ( s ) => s.id !== siteId ); - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} diff --git a/apps/cli/lib/cli-config.ts b/apps/cli/lib/cli-config.ts new file mode 100644 index 0000000000..c9a5a8771b --- /dev/null +++ b/apps/cli/lib/cli-config.ts @@ -0,0 +1,211 @@ +import fs from 'fs'; +import path from 'path'; +import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; +import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; +import { siteDetailsSchema } from '@studio/common/lib/site-events'; +import { __ } from '@wordpress/i18n'; +import { readFile, writeFile } from 'atomically'; +import { z } from 'zod'; +import { STUDIO_CLI_HOME } from 'cli/lib/paths'; +import { LoggerError } from 'cli/logger'; + +const siteSchema = siteDetailsSchema + .extend( { + url: z.string().optional(), + latestCliPid: z.number().optional(), + } ) + .loose(); + +const cliConfigWithJustVersion = z.object( { + version: z.number().default( 1 ), +} ); +// 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 cliConfigSchema = cliConfigWithJustVersion.extend( { + sites: z.array( siteSchema ).default( () => [] ), +} ); + +type CliConfig = z.infer< typeof cliConfigSchema >; +export type SiteData = z.infer< typeof siteSchema >; + +const DEFAULT_CLI_CONFIG: CliConfig = { + version: 1, + sites: [], +}; + +export function getCliConfigDirectory(): string { + if ( process.env.E2E && process.env.E2E_CLI_CONFIG_PATH ) { + return process.env.E2E_CLI_CONFIG_PATH; + } + + return STUDIO_CLI_HOME; +} + +export function getCliConfigPath(): string { + return path.join( getCliConfigDirectory(), 'cli.json' ); +} + +export async function readCliConfig(): Promise< CliConfig > { + const configPath = getCliConfigPath(); + + if ( ! fs.existsSync( configPath ) ) { + return structuredClone( DEFAULT_CLI_CONFIG ); + } + + try { + const fileContent = await readFile( configPath, { encoding: 'utf8' } ); + // eslint-disable-next-line no-var + var data = JSON.parse( fileContent ); + } catch ( error ) { + throw new LoggerError( __( 'Failed to read CLI config file.' ), error ); + } + + try { + return cliConfigSchema.parse( data ); + } catch ( error ) { + if ( error instanceof z.ZodError ) { + try { + cliConfigWithJustVersion.parse( data ); + } catch ( versionError ) { + throw new LoggerError( + __( + 'Invalid CLI config version. It looks like you have a different version of the `studio` CLI installed on your system. Please modify your $PATH environment variable to use the correct version.' + ), + error + ); + } + + throw new LoggerError( __( 'Invalid CLI config file format.' ), error ); + } + + if ( error instanceof SyntaxError ) { + throw new LoggerError( __( 'CLI config file is corrupted.' ), error ); + } + + throw new LoggerError( __( 'Failed to read CLI config file.' ), error ); + } +} + +export async function saveCliConfig( config: CliConfig ): Promise< void > { + try { + config.version = 1; + + const configDir = getCliConfigDirectory(); + if ( ! fs.existsSync( configDir ) ) { + fs.mkdirSync( configDir, { recursive: true } ); + } + + const configPath = getCliConfigPath(); + const fileContent = JSON.stringify( config, null, 2 ) + '\n'; + + await writeFile( configPath, fileContent, { encoding: 'utf8' } ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + throw error; + } + throw new LoggerError( __( 'Failed to save CLI config file' ), error ); + } +} + +const LOCKFILE_PATH = path.join( getCliConfigDirectory(), 'cli.json.lock' ); + +export async function lockCliConfig(): Promise< void > { + await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); +} + +export async function unlockCliConfig(): Promise< void > { + await unlockFileAsync( LOCKFILE_PATH ); +} + +export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > { + const config = await readCliConfig(); + const site = config.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) ); + + if ( ! site ) { + if ( isWordPressDirectory( siteFolder ) ) { + throw new LoggerError( + __( 'The specified directory is not added to Studio. Use `studio site create` to add it.' ) + ); + } + + throw new LoggerError( __( 'The specified directory is not added to Studio.' ) ); + } + + return site; +} + +export function getSiteUrl( site: SiteData ): string { + if ( site.url ) { + return site.url; + } + + if ( site.customDomain ) { + const protocol = site.enableHttps ? 'https' : 'http'; + return `${ protocol }://${ site.customDomain }`; + } + + return `http://localhost:${ site.port }`; +} + +export async function updateSiteLatestCliPid( siteId: string, pid: number ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + site.latestCliPid = pid; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + delete site.latestCliPid; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function updateSiteAutoStart( siteId: string, autoStart: boolean ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + site.autoStart = autoStart; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function removeSiteFromConfig( siteId: string ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + config.sites = config.sites.filter( ( s ) => s.id !== siteId ); + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 2106f2db6b..c40afcb20b 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -13,7 +13,7 @@ import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, PROCESS_MANAGER_CONTROL_SOCKET_PATH, PROCESS_MANAGER_HOME, -} from 'cli/lib/daemon-paths'; +} from 'cli/lib/paths'; import { SocketStreamClient, SocketMessageDecoder, SocketRequestClient } from 'cli/lib/socket'; import { ProcessDescription, diff --git a/apps/cli/lib/generate-site-name.ts b/apps/cli/lib/generate-site-name.ts index 9ef3cd2d27..b3ea80d4b3 100644 --- a/apps/cli/lib/generate-site-name.ts +++ b/apps/cli/lib/generate-site-name.ts @@ -2,7 +2,7 @@ import os from 'os'; import path from 'path'; import { generateSiteName as generateSiteNameShared } from '@studio/common/lib/generate-site-name'; import { sanitizeFolderName } from '@studio/common/lib/sanitize-folder-name'; -import { readAppdata } from 'cli/lib/appdata'; +import { readCliConfig } from 'cli/lib/cli-config'; const DEFAULT_SITES_DIR = path.join( os.homedir(), 'Studio' ); @@ -12,7 +12,7 @@ export function getDefaultSitePath( siteName: string ): string { } export async function generateSiteName(): Promise< string > { - const appdata = await readAppdata(); - const usedNames = appdata.sites.map( ( site ) => site.name ); + const cliConfig = await readCliConfig(); + const usedNames = cliConfig.sites.map( ( site ) => site.name ); return generateSiteNameShared( usedNames, DEFAULT_SITES_DIR ); } diff --git a/apps/cli/lib/daemon-paths.ts b/apps/cli/lib/paths.ts similarity index 77% rename from apps/cli/lib/daemon-paths.ts rename to apps/cli/lib/paths.ts index 6f891abe21..59f81a0475 100644 --- a/apps/cli/lib/daemon-paths.ts +++ b/apps/cli/lib/paths.ts @@ -1,8 +1,10 @@ import os from 'os'; import path from 'path'; +export const STUDIO_CLI_HOME = path.join( os.homedir(), '.studio' ); + export const PROCESS_MANAGER_HOME = - process.env.STUDIO_PROCESS_MANAGER_HOME ?? path.join( os.homedir(), '.studio', 'pm2' ); + process.env.STUDIO_PROCESS_MANAGER_HOME ?? path.join( STUDIO_CLI_HOME, 'pm2' ); export const PROCESS_MANAGER_LOGS_DIR = path.join( PROCESS_MANAGER_HOME, 'logs' ); export const PROCESS_MANAGER_CONTROL_SOCKET_PATH = process.platform === 'win32' diff --git a/apps/cli/lib/proxy-server.ts b/apps/cli/lib/proxy-server.ts index 75912af568..fbbeabfdea 100644 --- a/apps/cli/lib/proxy-server.ts +++ b/apps/cli/lib/proxy-server.ts @@ -3,8 +3,8 @@ import https from 'https'; import { createSecureContext } from 'node:tls'; import { domainToASCII } from 'node:url'; import httpProxy from 'http-proxy'; -import { readAppdata } from 'cli/lib/appdata'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; +import { readCliConfig } from 'cli/lib/cli-config'; let httpProxyServer: http.Server | null = null; let httpsProxyServer: https.Server | null = null; @@ -27,8 +27,8 @@ proxy.on( 'error', ( err, req, res ) => { */ async function getSiteByHost( domain: string ) { try { - const appdata = await readAppdata(); - const site = appdata.sites.find( + const cliConfig = await readCliConfig(); + const site = cliConfig.sites.find( ( site ) => domainToASCII( site.customDomain ?? '' ) === domainToASCII( domain ) ); return site ?? null; diff --git a/apps/cli/lib/site-utils.ts b/apps/cli/lib/site-utils.ts index af48c12d54..8cc3f2baea 100644 --- a/apps/cli/lib/site-utils.ts +++ b/apps/cli/lib/site-utils.ts @@ -1,9 +1,9 @@ import { decodePassword } from '@studio/common/lib/passwords'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; -import { getSiteUrl, readAppdata, SiteData } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; +import { getSiteUrl, readCliConfig, SiteData } from 'cli/lib/cli-config'; import { isProxyProcessRunning, startProxyProcess, stopProxyProcess } from 'cli/lib/daemon-client'; import { addDomainToHosts } from 'cli/lib/hosts-file'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; @@ -101,9 +101,9 @@ export async function stopProxyIfNoSitesNeedIt( return; } - const appdata = await readAppdata(); + const cliConfig = await readCliConfig(); - const remainingSitesWithCustomDomains = appdata.sites.filter( + const remainingSitesWithCustomDomains = cliConfig.sites.filter( ( site ) => ! stoppedSiteIdsArray.includes( site.id ) && site.customDomain ); diff --git a/apps/cli/lib/snapshots.ts b/apps/cli/lib/snapshots.ts index 9205ab9b08..f4d2753ef5 100644 --- a/apps/cli/lib/snapshots.ts +++ b/apps/cli/lib/snapshots.ts @@ -4,12 +4,12 @@ import { __, sprintf } from '@wordpress/i18n'; import { addDays, addHours, DurationUnit, formatDuration, intervalToDuration } from 'date-fns'; import { getAuthToken, - getSiteByFolder, readAppdata, lockAppdata, unlockAppdata, saveAppdata, } from 'cli/lib/appdata'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { LoggerError } from 'cli/logger'; export async function getSnapshotsFromAppdata( diff --git a/apps/cli/lib/tests/site-utils.test.ts b/apps/cli/lib/tests/site-utils.test.ts index b041c246ef..018f8840de 100644 --- a/apps/cli/lib/tests/site-utils.test.ts +++ b/apps/cli/lib/tests/site-utils.test.ts @@ -1,17 +1,16 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { vi, type Mock } from 'vitest'; -import { SiteData, readAppdata } from 'cli/lib/appdata'; +import { SiteData, readCliConfig } from 'cli/lib/cli-config'; import { isProxyProcessRunning, stopProxyProcess } from 'cli/lib/daemon-client'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { Logger } from 'cli/logger'; -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config' ); return { ...actual, - getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), - readAppdata: vi.fn(), + readCliConfig: vi.fn(), }; } ); vi.mock( 'cli/lib/daemon-client' ); @@ -48,7 +47,7 @@ describe( 'stopProxyIfNoSitesNeedIt', () => { ( isProxyProcessRunning as Mock ).mockResolvedValue( undefined ); ( stopProxyProcess as Mock ).mockResolvedValue( undefined ); ( isServerRunning as Mock ).mockResolvedValue( undefined ); - ( readAppdata as Mock ).mockResolvedValue( { sites: [], snapshots: [] } ); + ( readCliConfig as Mock ).mockResolvedValue( { version: 1, sites: [] } ); } ); afterEach( () => { @@ -60,15 +59,15 @@ describe( 'stopProxyIfNoSitesNeedIt', () => { await stopProxyIfNoSitesNeedIt( 'site-1', mockLogger ); - expect( readAppdata ).not.toHaveBeenCalled(); + expect( readCliConfig ).not.toHaveBeenCalled(); expect( stopProxyProcess ).not.toHaveBeenCalled(); } ); it( 'should stop proxy if no other sites exist', async () => { ( isProxyProcessRunning as Mock ).mockResolvedValue( mockProcessDescription ); - ( readAppdata as Mock ).mockResolvedValue( { + ( readCliConfig as Mock ).mockResolvedValue( { sites: [ createSiteData( { id: 'stopped-site' } ) ], - snapshots: [], + version: 1, } ); await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); @@ -83,13 +82,13 @@ describe( 'stopProxyIfNoSitesNeedIt', () => { it( 'should stop proxy if other sites exist but none have custom domains', async () => { ( isProxyProcessRunning as Mock ).mockResolvedValue( mockProcessDescription ); - ( readAppdata as Mock ).mockResolvedValue( { + ( readCliConfig as Mock ).mockResolvedValue( { sites: [ createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), createSiteData( { id: 'other-site-1' } ), createSiteData( { id: 'other-site-2' } ), ], - snapshots: [], + version: 1, } ); await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); @@ -99,12 +98,12 @@ describe( 'stopProxyIfNoSitesNeedIt', () => { it( 'should stop proxy if other sites have custom domains but are not running', async () => { ( isProxyProcessRunning as Mock ).mockResolvedValue( mockProcessDescription ); - ( readAppdata as Mock ).mockResolvedValue( { + ( readCliConfig as Mock ).mockResolvedValue( { sites: [ createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), createSiteData( { id: 'other-site', customDomain: 'other.local' } ), ], - snapshots: [], + version: 1, } ); ( isServerRunning as Mock ).mockResolvedValue( undefined ); @@ -116,12 +115,12 @@ describe( 'stopProxyIfNoSitesNeedIt', () => { it( 'should not stop proxy if another site with custom domain is running', async () => { ( isProxyProcessRunning as Mock ).mockResolvedValue( mockProcessDescription ); - ( readAppdata as Mock ).mockResolvedValue( { + ( readCliConfig as Mock ).mockResolvedValue( { sites: [ createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ), createSiteData( { id: 'running-site', customDomain: 'running.local' } ), ], - snapshots: [], + version: 1, } ); ( isServerRunning as Mock ).mockResolvedValue( mockProcessDescription ); @@ -133,9 +132,9 @@ describe( 'stopProxyIfNoSitesNeedIt', () => { it( 'should not check if the stopped site is running', async () => { ( isProxyProcessRunning as Mock ).mockResolvedValue( mockProcessDescription ); - ( readAppdata as Mock ).mockResolvedValue( { + ( readCliConfig as Mock ).mockResolvedValue( { sites: [ createSiteData( { id: 'stopped-site', customDomain: 'stopped.local' } ) ], - snapshots: [], + version: 1, } ); await stopProxyIfNoSitesNeedIt( 'stopped-site', mockLogger ); diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index 8264441009..deb10303a2 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import { vi } from 'vitest'; -import { SiteData } from 'cli/lib/appdata'; +import { SiteData } from 'cli/lib/cli-config'; import * as daemonClient from 'cli/lib/daemon-client'; import { DaemonBus } from 'cli/lib/daemon-client'; import { diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 588e4b363c..1d7a9c66c9 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -12,7 +12,7 @@ import { } from '@studio/common/constants'; import { SITE_EVENTS } from '@studio/common/lib/site-events'; import { z } from 'zod'; -import { SiteData } from 'cli/lib/appdata'; +import { SiteData } from 'cli/lib/cli-config'; import { isProcessRunning, startProcess, diff --git a/apps/cli/process-manager-daemon.ts b/apps/cli/process-manager-daemon.ts index 4036dd8901..43be099979 100644 --- a/apps/cli/process-manager-daemon.ts +++ b/apps/cli/process-manager-daemon.ts @@ -7,7 +7,7 @@ import { PROCESS_MANAGER_LOGS_DIR, PROCESS_MANAGER_CONTROL_SOCKET_PATH, PROCESS_MANAGER_EVENTS_SOCKET_PATH, -} from 'cli/lib/daemon-paths'; +} from 'cli/lib/paths'; import { SocketServer } from 'cli/lib/socket'; import { ProcessDescription, diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index e080b8da99..a284f30a73 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -15,6 +15,7 @@ export class E2ESession { sessionPath: string; appDataPath: string; homePath: string; + cliConfigPath: string; private mainProcessLogs: string[] = []; private readonly maxMainProcessLogChunks = 500; private stdoutListener?: ( chunk: Buffer | string ) => void; @@ -25,11 +26,13 @@ export class E2ESession { this.sessionPath = path.join( tmpdir(), `studio-app-e2e-session-${ randomUUID() }` ); this.appDataPath = path.join( this.sessionPath, 'appData' ); this.homePath = path.join( this.sessionPath, 'home' ); + this.cliConfigPath = path.join( this.sessionPath, 'cliConfig' ); } async launch( testEnv: NodeJS.ProcessEnv = {} ) { await fs.mkdir( this.appDataPath, { recursive: true } ); await fs.mkdir( this.homePath, { recursive: true } ); + await fs.mkdir( this.cliConfigPath, { recursive: true } ); // Pre-create appdata file with beta features enabled for CLI testing // Path must include 'Studio' subfolder to match Electron app's path structure @@ -111,6 +114,7 @@ export class E2ESession { E2E: 'true', E2E_APP_DATA_PATH: this.appDataPath, E2E_HOME_PATH: this.homePath, + E2E_CLI_CONFIG_PATH: this.cliConfigPath, }, timeout: 60_000, } ); diff --git a/apps/studio/e2e/sites.test.ts b/apps/studio/e2e/sites.test.ts index deb007e020..24ac36adc3 100644 --- a/apps/studio/e2e/sites.test.ts +++ b/apps/studio/e2e/sites.test.ts @@ -221,9 +221,9 @@ test.describe( 'Sites without cleanup in-between', () => { const siteContent = new SiteContent( session.mainWindow, siteName ); await expect( siteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); - const appDataFile = path.join( session.appDataPath, 'Studio', 'appdata-v1.json' ); - const appData = await fs.readJson( appDataFile ); - const site = appData.sites.find( ( s: { name: string } ) => s.name === siteName ); + const cliConfigFile = path.join( session.cliConfigPath, 'cli.json' ); + const cliConfig = await fs.readJson( cliConfigFile ); + const site = cliConfig.sites.find( ( s: { name: string } ) => s.name === siteName ); const siteId = site.id; const thumbnailsDir = path.join( session.appDataPath, 'Studio', 'thumbnails' ); @@ -252,8 +252,8 @@ test.describe( 'Sites without cleanup in-between', () => { const copiedSiteContent = new SiteContent( session.mainWindow, expectedCopyName ); await expect( copiedSiteContent.runningButton ).toBeAttached( { timeout: 120_000 } ); - const updatedAppData = await fs.readJson( appDataFile ); - const copiedSite = updatedAppData.sites.find( + const updatedCliConfig = await fs.readJson( cliConfigFile ); + const copiedSite = updatedCliConfig.sites.find( ( s: { name: string } ) => s.name === expectedCopyName ); expect( copiedSite ).toBeDefined(); diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 9cbdf970ac..6032d633ce 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -599,6 +599,19 @@ export async function deleteSite( event: IpcMainInvokeEvent, id: string, deleteF throw new Error( 'Site not found.' ); } await server.delete( deleteFiles ); + + // Clean up Studio-only data (sortOrder, themeDetails) from appdata + try { + await lockAppdata(); + const userData = await loadUserData(); + const siteIndex = userData.sites.findIndex( ( s ) => s.id === id ); + if ( siteIndex !== -1 ) { + userData.sites.splice( siteIndex, 1 ); + await saveUserData( userData ); + } + } finally { + await unlockAppdata(); + } } export async function copySite( diff --git a/eslint.config.mjs b/eslint.config.mjs index b7e9e612ea..628e30873d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -80,7 +80,23 @@ export default defineConfig( }, ], 'react-hooks/set-state-in-effect': 'off', - 'studio/require-lock-before-save': 'error', + 'studio/require-lock-before-save': [ + 'error', + { + pairs: [ + { + save: [ 'saveUserData', 'saveAppdata' ], + lock: 'lockAppdata', + unlock: 'unlockAppdata', + }, + { + save: 'saveCliConfig', + lock: 'lockCliConfig', + unlock: 'unlockCliConfig', + }, + ], + }, + ], }, }, { diff --git a/tools/eslint-plugin-studio/src/rules/require-lock-before-save.js b/tools/eslint-plugin-studio/src/rules/require-lock-before-save.js index 1ee3c0b5e7..3ce31d7d7a 100644 --- a/tools/eslint-plugin-studio/src/rules/require-lock-before-save.js +++ b/tools/eslint-plugin-studio/src/rules/require-lock-before-save.js @@ -1,22 +1,74 @@ +const DEFAULT_PAIRS = [ + { + save: [ 'saveUserData', 'saveAppdata' ], + lock: 'lockAppdata', + unlock: 'unlockAppdata', + }, +]; + /** @type {import('eslint').Rule.RuleModule} */ export default { meta: { type: 'problem', docs: { - description: 'Enforce locking when calling saveUserData or saveAppdata', + description: 'Enforce locking when calling save functions that require file locks', category: 'Possible Errors', recommended: true, }, fixable: null, - schema: [], + schema: [ + { + type: 'object', + properties: { + pairs: { + type: 'array', + items: { + type: 'object', + properties: { + save: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + ], + }, + lock: { type: 'string' }, + unlock: { type: 'string' }, + }, + required: [ 'save', 'lock', 'unlock' ], + }, + }, + }, + additionalProperties: false, + }, + ], messages: { missingLock: - 'Function that calls saveUserData() or saveAppdata() must be wrapped with lockAppdata() and unlockAppdata().', - missingUnlock: 'lockAppdata() must be followed by unlockAppdata() in a try/finally block.', + 'Function that calls {{ saveFn }}() must be wrapped with {{ lockFn }}() and {{ unlockFn }}().', + missingUnlock: + '{{ lockFn }}() must be followed by {{ unlockFn }}() in a try/finally block.', }, }, create( context ) { - const saveFunctions = [ 'saveUserData', 'saveAppdata' ]; + const options = context.options[ 0 ] || {}; + const pairs = options.pairs || DEFAULT_PAIRS; + + // Build lookup maps from pairs config + const saveToLockMap = new Map(); + const lockFunctions = new Set(); + const unlockFunctions = new Set(); + + for ( const pair of pairs ) { + const saveNames = Array.isArray( pair.save ) ? pair.save : [ pair.save ]; + for ( const saveName of saveNames ) { + saveToLockMap.set( saveName, { + lock: pair.lock, + unlock: pair.unlock, + } ); + } + lockFunctions.add( pair.lock ); + unlockFunctions.add( pair.unlock ); + } + const functionStack = []; function getCurrentFunction() { @@ -25,23 +77,25 @@ export default { function enterFunction() { functionStack.push( { - hasLockCall: false, - hasUnlockCall: false, + locks: new Set(), + unlocks: new Set(), hasTryFinally: false, } ); } function exitFunction( node ) { const current = getCurrentFunction(); - if ( - current && - current.hasLockCall && - ( ! current.hasTryFinally || ! current.hasUnlockCall ) - ) { - context.report( { - node, - messageId: 'missingUnlock', - } ); + if ( current ) { + for ( const lockFn of current.locks ) { + const pair = pairs.find( ( p ) => p.lock === lockFn ); + if ( pair && ( ! current.hasTryFinally || ! current.unlocks.has( pair.unlock ) ) ) { + context.report( { + node, + messageId: 'missingUnlock', + data: { lockFn, unlockFn: pair.unlock }, + } ); + } + } } functionStack.pop(); } @@ -51,21 +105,26 @@ export default { const current = getCurrentFunction(); if ( ! current ) return; - if ( node.callee.name === 'lockAppdata' ) { - current.hasLockCall = true; + const calleeName = node.callee.name; + + if ( lockFunctions.has( calleeName ) ) { + current.locks.add( calleeName ); } - if ( node.callee.name === 'unlockAppdata' ) { - current.hasUnlockCall = true; + if ( unlockFunctions.has( calleeName ) ) { + current.unlocks.add( calleeName ); } - // Any call to saveUserData or saveAppdata requires lock - if ( saveFunctions.includes( node.callee.name ) ) { - if ( ! current.hasLockCall ) { - context.report( { - node, - messageId: 'missingLock', - } ); - } + const lockInfo = saveToLockMap.get( calleeName ); + if ( lockInfo && ! current.locks.has( lockInfo.lock ) ) { + context.report( { + node, + messageId: 'missingLock', + data: { + saveFn: calleeName, + lockFn: lockInfo.lock, + unlockFn: lockInfo.unlock, + }, + } ); } }, TryStatement( node ) { diff --git a/tools/eslint-plugin-studio/tests/require-lock-before-save.test.ts b/tools/eslint-plugin-studio/tests/require-lock-before-save.test.ts index 60394ed65d..6b2f6d7c5f 100644 --- a/tools/eslint-plugin-studio/tests/require-lock-before-save.test.ts +++ b/tools/eslint-plugin-studio/tests/require-lock-before-save.test.ts @@ -10,9 +10,9 @@ const ruleTester = new RuleTester( { } ); describe( 'require-lock-before-save', () => { - ruleTester.run( 'require-lock-before-save', rule, { + describe( 'default pairs (appdata)', () => { + ruleTester.run( 'require-lock-before-save', rule, { valid: [ - // Functions not calling save functions (allowed) { code: ` async function updateUserData() { @@ -20,7 +20,6 @@ describe( 'require-lock-before-save', () => { } `, }, - // saveUserData with lock (allowed) { code: ` async function updateUserData() { @@ -35,7 +34,6 @@ describe( 'require-lock-before-save', () => { } `, }, - // saveAppdata with lock (allowed) { code: ` const updateUserData = async () => { @@ -52,7 +50,6 @@ describe( 'require-lock-before-save', () => { }, ], invalid: [ - // saveUserData without lock (not allowed) { code: ` const updateUserData = async () => { @@ -63,7 +60,6 @@ describe( 'require-lock-before-save', () => { `, errors: [ { messageId: 'missingLock' } ], }, - // saveAppdata without lock (not allowed) { code: ` async function updateUserData() { @@ -74,7 +70,6 @@ describe( 'require-lock-before-save', () => { `, errors: [ { messageId: 'missingLock' } ], }, - // Lock without try/finally block (not allowed) { code: ` async function updateUserData() { @@ -87,7 +82,6 @@ describe( 'require-lock-before-save', () => { `, errors: [ { messageId: 'missingUnlock' } ], }, - // Lock without unlock (not allowed) { code: ` async function updateUserData() { @@ -105,4 +99,96 @@ describe( 'require-lock-before-save', () => { }, ], } ); + } ); + + describe( 'custom pairs (cli-config)', () => { + const options = [ + { + pairs: [ + { + save: [ 'saveUserData', 'saveAppdata' ], + lock: 'lockAppdata', + unlock: 'unlockAppdata', + }, + { + save: 'saveCliConfig', + lock: 'lockCliConfig', + unlock: 'unlockCliConfig', + }, + ], + }, + ]; + + ruleTester.run( 'require-lock-before-save (custom pairs)', rule, { + valid: [ + { + code: ` + async function updateSite() { + await lockCliConfig(); + try { + const config = await readCliConfig(); + config.sites.push(newSite); + await saveCliConfig(config); + } finally { + await unlockCliConfig(); + } + } + `, + options, + }, + { + code: ` + async function updateAppAndCli() { + await lockAppdata(); + try { + await saveAppdata(data); + } finally { + await unlockAppdata(); + } + } + `, + options, + }, + ], + invalid: [ + { + code: ` + async function updateSite() { + const config = await readCliConfig(); + config.sites.push(newSite); + await saveCliConfig(config); + } + `, + options, + errors: [ { messageId: 'missingLock' } ], + }, + { + code: ` + async function updateSite() { + await lockCliConfig(); + const config = await readCliConfig(); + await saveCliConfig(config); + await unlockCliConfig(); + } + `, + options, + errors: [ { messageId: 'missingUnlock' } ], + }, + { + code: ` + async function updateSite() { + await lockAppdata(); + try { + await saveCliConfig(config); + } finally { + await unlockAppdata(); + } + } + `, + options, + errors: [ { messageId: 'missingLock' } ], + }, + ], + } ); + } ); } ); From 163b9718a71b15566b83826cd26577a4330b0632 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 16:53:00 +0000 Subject: [PATCH 03/11] Fix AI tools test mocks to use cli-config instead of appdata --- apps/cli/ai/tests/tools.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 54378ce17b..640448a769 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -3,7 +3,7 @@ import { runCommand as runCreatePreviewCommand } from 'cli/commands/preview/crea import { runCommand as runDeletePreviewCommand } from 'cli/commands/preview/delete'; import { runCommand as runListPreviewCommand } from 'cli/commands/preview/list'; import { runCommand as runUpdatePreviewCommand } from 'cli/commands/preview/update'; -import { getSiteByFolder, readAppdata } from 'cli/lib/appdata'; +import { getSiteByFolder, readCliConfig } from 'cli/lib/cli-config'; import { getProgressCallback, setProgressCallback } from 'cli/logger'; import { studioToolDefinitions } from '../tools'; @@ -56,10 +56,10 @@ vi.mock( 'cli/commands/site/stop', () => ( { runCommand: vi.fn(), } ) ); -vi.mock( 'cli/lib/appdata', async () => ( { - ...( await vi.importActual( 'cli/lib/appdata' ) ), +vi.mock( 'cli/lib/cli-config', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config' ) ), getSiteByFolder: vi.fn(), - readAppdata: vi.fn(), + readCliConfig: vi.fn(), } ) ); vi.mock( 'cli/lib/daemon-client', () => ( { @@ -97,9 +97,9 @@ describe( 'Studio AI MCP tools', () => { vi.resetAllMocks(); process.exitCode = undefined; setProgressCallback( null ); - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ mockSite ], - } as Awaited< ReturnType< typeof readAppdata > > ); + } as Awaited< ReturnType< typeof readCliConfig > > ); vi.mocked( getSiteByFolder ).mockResolvedValue( mockSite ); } ); From ae5be29a1399bac7f0ad35de7cbaf4ca56704daa Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Tue, 17 Mar 2026 14:27:04 +0000 Subject: [PATCH 04/11] Apply decoupled config strategy to preview sites (#2807) * Apply decoupled config strategy to preview sites (STU-1350) - CLI stores/reads preview site data from cli.json instead of appdata - Studio reads preview sites via CLI commands (preview list --format json) - Added preview set command for updating snapshot settings (--name) - Added --name option to preview create command - Moved refreshSnapshots to store initialization with test guard - Fixed snapshot-slice test mock setup to avoid module-load timing issues * Emit snapshot events to Studio via _events for realtime updates * Move snapshot name generation from cli-config to preview create command * Split cli-config.ts into folder structure (core, sites, snapshots) * Rename site-events to cli-events and enrich snapshot events with inline data (STU-1350) * Fix snapshot update infinite loop and preview set error handling * Unify CLI event emitter, share event schemas in cli-events, and clean up daemon-client (STU-1350) * trigger ci * Fix import order lint errors in core.ts and site-server.ts * Address PR review: add stdout json output, addSnapshot action, reusable usage count helper, fix listener state bug * Remove barrel file, replace z.nativeEnum with z.enum, update imports to direct paths * Fix import order in cli-events.ts and remove unused test imports --- apps/cli/__mocks__/lib/daemon-client.ts | 2 +- apps/cli/ai/tests/tools.test.ts | 12 +- apps/cli/ai/tools.ts | 3 +- apps/cli/ai/ui.ts | 3 +- apps/cli/commands/_events.ts | 41 ++++-- apps/cli/commands/preview/create.ts | 36 ++++- apps/cli/commands/preview/delete.ts | 12 +- apps/cli/commands/preview/list.ts | 24 ++-- apps/cli/commands/preview/set.ts | 68 +++++++++ .../cli/commands/preview/tests/create.test.ts | 30 ++-- .../cli/commands/preview/tests/delete.test.ts | 16 +-- apps/cli/commands/preview/tests/list.test.ts | 26 ++-- .../cli/commands/preview/tests/update.test.ts | 22 +-- apps/cli/commands/preview/update.ts | 11 +- apps/cli/commands/site/create.ts | 12 +- apps/cli/commands/site/delete.ts | 16 +-- apps/cli/commands/site/list.ts | 9 +- apps/cli/commands/site/set.ts | 11 +- apps/cli/commands/site/start.ts | 6 +- apps/cli/commands/site/status.ts | 2 +- apps/cli/commands/site/stop.ts | 10 +- apps/cli/commands/site/tests/create.test.ts | 15 +- apps/cli/commands/site/tests/delete.test.ts | 52 ++++--- apps/cli/commands/site/tests/list.test.ts | 8 +- apps/cli/commands/site/tests/set.test.ts | 30 ++-- apps/cli/commands/site/tests/start.test.ts | 10 +- apps/cli/commands/site/tests/status.test.ts | 6 +- apps/cli/commands/site/tests/stop.test.ts | 58 ++++++-- apps/cli/commands/wp.ts | 2 +- apps/cli/index.ts | 3 + apps/cli/lib/appdata.ts | 2 - .../lib/{cli-config.ts => cli-config/core.ts} | 98 +------------ apps/cli/lib/cli-config/sites.ts | 102 ++++++++++++++ apps/cli/lib/cli-config/snapshots.ts | 130 ++++++++++++++++++ apps/cli/lib/daemon-client.ts | 18 ++- apps/cli/lib/proxy-server.ts | 2 +- apps/cli/lib/site-name.ts | 2 +- apps/cli/lib/site-utils.ts | 3 +- apps/cli/lib/snapshots.ts | 119 +--------------- apps/cli/lib/tests/site-utils.test.ts | 6 +- apps/cli/lib/tests/snapshots.test.ts | 52 ++++--- .../tests/wordpress-server-manager.test.ts | 2 +- apps/cli/lib/wordpress-server-manager.ts | 4 +- .../src/components/tests/header.test.tsx | 8 +- apps/studio/src/hooks/use-site-details.tsx | 2 +- apps/studio/src/ipc-handlers.ts | 20 +-- apps/studio/src/ipc-utils.ts | 3 +- .../src/lib/tests/windows-helpers.test.ts | 27 ++-- .../modules/cli/lib/cli-events-subscriber.ts | 23 ++-- .../preview-action-buttons-menu.test.tsx | 2 +- .../modules/preview-site/lib/ipc-handlers.ts | 62 ++++++++- .../components/user-settings.tsx | 1 - apps/studio/src/preload.ts | 6 +- apps/studio/src/site-server.ts | 2 +- apps/studio/src/storage/storage-types.ts | 2 - .../src/storage/tests/user-data.test.ts | 4 - apps/studio/src/storage/user-data.ts | 18 --- apps/studio/src/stores/index.ts | 25 ++-- apps/studio/src/stores/snapshot-slice.ts | 93 ++++++++++--- .../src/stores/tests/snapshot-slice.test.ts | 16 +-- package-lock.json | 56 ++++++-- tools/common/lib/cli-events.ts | 107 ++++++++++++++ tools/common/lib/site-events.ts | 54 -------- tools/common/logger-actions.ts | 1 + 64 files changed, 1013 insertions(+), 615 deletions(-) create mode 100644 apps/cli/commands/preview/set.ts rename apps/cli/lib/{cli-config.ts => cli-config/core.ts} (58%) create mode 100644 apps/cli/lib/cli-config/sites.ts create mode 100644 apps/cli/lib/cli-config/snapshots.ts create mode 100644 tools/common/lib/cli-events.ts delete mode 100644 tools/common/lib/site-events.ts diff --git a/apps/cli/__mocks__/lib/daemon-client.ts b/apps/cli/__mocks__/lib/daemon-client.ts index 1973ef05de..055f303f17 100644 --- a/apps/cli/__mocks__/lib/daemon-client.ts +++ b/apps/cli/__mocks__/lib/daemon-client.ts @@ -2,7 +2,7 @@ import { vi } from 'vitest'; export const connectToDaemon = vi.fn().mockResolvedValue( undefined ); export const disconnectFromDaemon = vi.fn().mockResolvedValue( undefined ); -export const emitSiteEvent = vi.fn().mockResolvedValue( undefined ); +export const emitCliEvent = vi.fn().mockResolvedValue( undefined ); export const killDaemonAndChildren = vi.fn().mockResolvedValue( undefined ); export const listProcesses = vi.fn().mockResolvedValue( [] ); export const getDaemonBus = vi.fn().mockResolvedValue( {} ); diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 640448a769..fbee4a0215 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -3,7 +3,8 @@ import { runCommand as runCreatePreviewCommand } from 'cli/commands/preview/crea import { runCommand as runDeletePreviewCommand } from 'cli/commands/preview/delete'; import { runCommand as runListPreviewCommand } from 'cli/commands/preview/list'; import { runCommand as runUpdatePreviewCommand } from 'cli/commands/preview/update'; -import { getSiteByFolder, readCliConfig } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { getProgressCallback, setProgressCallback } from 'cli/logger'; import { studioToolDefinitions } from '../tools'; @@ -56,11 +57,14 @@ vi.mock( 'cli/commands/site/stop', () => ( { runCommand: vi.fn(), } ) ); -vi.mock( 'cli/lib/cli-config', async () => ( { - ...( await vi.importActual( 'cli/lib/cli-config' ) ), - getSiteByFolder: vi.fn(), +vi.mock( 'cli/lib/cli-config/core', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config/core' ) ), readCliConfig: vi.fn(), } ) ); +vi.mock( 'cli/lib/cli-config/sites', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config/sites' ) ), + getSiteByFolder: vi.fn(), +} ) ); vi.mock( 'cli/lib/daemon-client', () => ( { connectToDaemon: vi.fn(), diff --git a/apps/cli/ai/tools.ts b/apps/cli/ai/tools.ts index 6b530ad02e..8ecb9bfd57 100644 --- a/apps/cli/ai/tools.ts +++ b/apps/cli/ai/tools.ts @@ -15,7 +15,8 @@ import { runCommand as runListSitesCommand } from 'cli/commands/site/list'; import { runCommand as runStartSiteCommand } from 'cli/commands/site/start'; import { runCommand as runStatusCommand } from 'cli/commands/site/status'; import { runCommand as runStopSiteCommand, Mode as StopMode } from 'cli/commands/site/stop'; -import { getSiteByFolder, getSiteUrl, readCliConfig, type SiteData } from 'cli/lib/cli-config'; +import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; +import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; import { normalizeHostname } from 'cli/lib/utils'; diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index 9cdcf2aec6..a9c4442430 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -26,7 +26,8 @@ import { diffTodoSnapshot, type TodoDiff, type TodoEntry } from 'cli/ai/todo-str import { getWpComSites } from 'cli/lib/api'; import { getAuthToken } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; -import { getSiteUrl, readCliConfig, type SiteData } from 'cli/lib/cli-config'; +import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { isSiteRunning } from 'cli/lib/site-utils'; import type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; import type { TodoWriteInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools'; diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index 7ca4fb1ce3..02ee934a9b 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -6,12 +6,20 @@ * stdout key-value pairs that Studio parses. */ +import { + SITE_EVENTS, + SNAPSHOT_EVENTS, + siteDetailsSchema, + siteSocketEventSchema, + snapshotSocketEventSchema, + SiteEvent, + SnapshotEvent, +} from '@studio/common/lib/cli-events'; import { sequential } from '@studio/common/lib/sequential'; -import { SITE_EVENTS, siteDetailsSchema, SiteEvent } from '@studio/common/lib/site-events'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; -import { z } from 'zod'; -import { getSiteUrl, readCliConfig, SiteData } from 'cli/lib/cli-config'; +import { readCliConfig, SiteData } from 'cli/lib/cli-config/core'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, @@ -68,18 +76,31 @@ async function emitAllSitesStopped(): Promise< void > { } } -const siteEventSchema = z.object( { - event: z.string(), - data: z.object( { - siteId: z.string(), - } ), -} ); +const emitSnapshotEvent = sequential( + async ( event: SNAPSHOT_EVENTS, snapshotUrl: string ): Promise< void > => { + const cliConfig = await readCliConfig(); + const snapshot = cliConfig.snapshots.find( ( s ) => s.url === snapshotUrl ); + const payload: SnapshotEvent = { + event, + snapshotUrl, + snapshot: snapshot ?? undefined, + }; + + logger.reportKeyValuePair( 'snapshot-event', JSON.stringify( payload ) ); + } +); export async function runCommand(): Promise< void > { const eventsSocketServer = new SocketServer( SITE_EVENTS_SOCKET_PATH, 2500 ); eventsSocketServer.on( 'message', ( { message: packet } ) => { try { - const parsedPacket = siteEventSchema.parse( packet ); + const snapshotParsed = snapshotSocketEventSchema.safeParse( packet ); + if ( snapshotParsed.success ) { + void emitSnapshotEvent( snapshotParsed.data.event, snapshotParsed.data.data.snapshotUrl ); + return; + } + + const parsedPacket = siteSocketEventSchema.parse( packet ); if ( parsedPacket.event === SITE_EVENTS.CREATED || parsedPacket.event === SITE_EVENTS.UPDATED || diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index 91e053365d..1ffcf284cf 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -1,18 +1,21 @@ 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 { 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'; -import { saveSnapshotToAppdata } from 'cli/lib/snapshots'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { getNextSnapshotSequence } from 'cli/lib/cli-config/snapshots'; +import { emitCliEvent } from 'cli/lib/daemon-client'; +import { getSnapshotsFromConfig, saveSnapshotToConfig } from 'cli/lib/snapshots'; import { validateSiteSize } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -export async function runCommand( siteFolder: string ): Promise< void > { +export async function runCommand( siteFolder: string, name?: string ): Promise< void > { const archivePath = path.join( os.tmpdir(), `${ path.basename( siteFolder ) }-${ Date.now() }.zip` @@ -42,12 +45,27 @@ export async function runCommand( siteFolder: string ): Promise< void > { ); logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); - const snapshot = await saveSnapshotToAppdata( + let snapshotName = name; + if ( ! snapshotName ) { + const site = await getSiteByFolder( siteFolder ); + const snapshots = await getSnapshotsFromConfig( token.id ); + const sequence = getNextSnapshotSequence( site.id, snapshots, token.id ); + snapshotName = sprintf( + /* translators: 1: Site name 2: Sequence number (e.g. "My Site Name Preview 1") */ + __( '%1$s Preview %2$d' ), + site.name, + sequence + ); + } + const snapshot = await saveSnapshotToConfig( siteFolder, uploadResponse.site_id, - uploadResponse.site_url + uploadResponse.site_url, + token.id, + snapshotName ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); + await emitCliEvent( { event: SNAPSHOT_EVENTS.CREATED, data: { snapshotUrl: snapshot.url } } ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); logger.reportKeyValuePair( 'url', snapshot.url ); @@ -67,8 +85,14 @@ export const registerCommand = ( yargs: StudioArgv ) => { return yargs.command( { command: 'create', describe: __( 'Create a preview site' ), + builder: ( yargs ) => { + return yargs.option( 'name', { + type: 'string', + description: __( 'Preview site name' ), + } ); + }, handler: async ( argv ) => { - await runCommand( argv.path ); + await runCommand( argv.path, argv.name ); }, } ); }; diff --git a/apps/cli/commands/preview/delete.ts b/apps/cli/commands/preview/delete.ts index 5fd43dd21e..d6ed51338b 100644 --- a/apps/cli/commands/preview/delete.ts +++ b/apps/cli/commands/preview/delete.ts @@ -1,8 +1,10 @@ +import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; 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 { deleteSnapshotFromAppdata, getSnapshotsFromAppdata } from 'cli/lib/snapshots'; +import { emitCliEvent } from 'cli/lib/daemon-client'; +import { deleteSnapshotFromConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -13,7 +15,7 @@ export async function runCommand( host: string ): Promise< void > { try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); const token = await getAuthToken(); - const snapshots = await getSnapshotsFromAppdata( token.id ); + const snapshots = await getSnapshotsFromConfig( token.id ); const snapshotToDelete = snapshots.find( ( s ) => s.url === host ); if ( ! snapshotToDelete ) { throw new LoggerError( @@ -27,7 +29,11 @@ export async function runCommand( host: string ): Promise< void > { logger.reportStart( LoggerAction.DELETE, __( 'Deleting…' ) ); await deleteSnapshot( snapshotToDelete.atomicSiteId, token.accessToken ); - await deleteSnapshotFromAppdata( snapshotToDelete.url ); + await deleteSnapshotFromConfig( snapshotToDelete.url ); + await emitCliEvent( { + event: SNAPSHOT_EVENTS.DELETED, + data: { snapshotUrl: snapshotToDelete.url }, + } ); logger.reportSuccess( __( 'Deletion successful' ) ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/preview/list.ts b/apps/cli/commands/preview/list.ts index 61324dd8a7..6c9027a53b 100644 --- a/apps/cli/commands/preview/list.ts +++ b/apps/cli/commands/preview/list.ts @@ -3,10 +3,10 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import CliTable3 from 'cli-table3'; import { format } from 'date-fns'; import { getAuthToken } from 'cli/lib/appdata'; -import { getSiteByFolder } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { formatDurationUntilExpiry, - getSnapshotsFromAppdata, + getSnapshotsFromConfig, isSnapshotExpired, } from 'cli/lib/snapshots'; import { getColumnWidths } from 'cli/lib/utils'; @@ -20,13 +20,20 @@ export async function runCommand( const logger = new Logger< LoggerAction >(); try { + if ( outputFormat === 'json' ) { + const config = await readCliConfig(); + const json = JSON.stringify( config.snapshots ); + console.log( json ); + logger.reportKeyValuePair( 'snapshots', json ); + return; + } + logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); - await getSiteByFolder( siteFolder ); const token = await getAuthToken(); logger.reportSuccess( __( 'Validation successful' ), true ); logger.reportStart( LoggerAction.LOAD, __( 'Loading preview sites…' ) ); - const snapshots = await getSnapshotsFromAppdata( token.id, siteFolder ); + const snapshots = await getSnapshotsFromConfig( token.id, siteFolder ); if ( snapshots.length === 0 ) { logger.reportSuccess( __( 'No preview sites found' ) ); @@ -77,15 +84,6 @@ export async function runCommand( } console.log( table.toString() ); - } else { - const output = snapshots.map( ( snapshot ) => ( { - url: `https://${ snapshot.url }`, - name: snapshot.name, - date: format( snapshot.date, 'yyyy-MM-dd HH:mm' ), - expiresIn: formatDurationUntilExpiry( snapshot.date ), - } ) ); - - console.log( JSON.stringify( output, null, 2 ) ); } } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/preview/set.ts b/apps/cli/commands/preview/set.ts new file mode 100644 index 0000000000..2dcb1689bb --- /dev/null +++ b/apps/cli/commands/preview/set.ts @@ -0,0 +1,68 @@ +import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; +import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; +import { __ } from '@wordpress/i18n'; +import { setSnapshotInConfig } from 'cli/lib/cli-config/snapshots'; +import { emitCliEvent } from 'cli/lib/daemon-client'; +import { normalizeHostname } from 'cli/lib/utils'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +export interface SetCommandOptions { + name?: string; +} + +export async function runCommand( host: string, options: SetCommandOptions ): Promise< void > { + const { name } = options; + const logger = new Logger< LoggerAction >(); + + if ( name === undefined ) { + throw new LoggerError( __( 'At least one option (--name) is required.' ) ); + } + + if ( name !== undefined && ! name.trim() ) { + throw new LoggerError( __( 'Preview site name cannot be empty.' ) ); + } + + logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); + await setSnapshotInConfig( host, { name } ); + await emitCliEvent( { event: SNAPSHOT_EVENTS.UPDATED, data: { snapshotUrl: host } } ); + logger.reportSuccess( __( 'Preview site updated' ) ); +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'set ', + describe: __( 'Configure preview site settings' ), + builder: ( yargs ) => { + return yargs + .positional( 'host', { + type: 'string', + description: __( 'Hostname of the preview site to configure' ), + demandOption: true, + } ) + .option( 'name', { + type: 'string', + description: __( 'Preview site name' ), + } ) + .option( 'path', { + hidden: true, + } ); + }, + handler: async ( argv ) => { + try { + const normalizedHost = normalizeHostname( argv.host ); + await runCommand( normalizedHost, { name: argv.name } ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to configure preview site' ), error ); + logger.reportError( loggerError ); + } + process.exit( 1 ); + } + }, + } ); +}; + +const logger = new Logger< LoggerAction >(); diff --git a/apps/cli/commands/preview/tests/create.test.ts b/apps/cli/commands/preview/tests/create.test.ts index bed082406d..2da3192ea9 100644 --- a/apps/cli/commands/preview/tests/create.test.ts +++ b/apps/cli/commands/preview/tests/create.test.ts @@ -5,8 +5,8 @@ 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'; -import { saveSnapshotToAppdata } from 'cli/lib/snapshots'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { saveSnapshotToConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { runCommand } from '../create'; @@ -24,8 +24,12 @@ vi.mock( 'cli/lib/appdata', async () => ( { getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), getAuthToken: vi.fn(), } ) ); -vi.mock( 'cli/lib/cli-config', async () => ( { - ...( await vi.importActual( 'cli/lib/cli-config' ) ), +vi.mock( 'cli/lib/cli-config/snapshots', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config/snapshots' ) ), + getNextSnapshotSequence: vi.fn().mockReturnValue( 1 ), +} ) ); +vi.mock( 'cli/lib/cli-config/sites', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config/sites' ) ), getSiteByFolder: vi.fn(), } ) ); vi.mock( 'cli/lib/validation', () => ( { @@ -33,7 +37,11 @@ vi.mock( 'cli/lib/validation', () => ( { } ) ); vi.mock( 'cli/lib/archive' ); vi.mock( 'cli/lib/api' ); -vi.mock( 'cli/lib/snapshots' ); +vi.mock( 'cli/lib/snapshots', async () => ( { + ...( await vi.importActual( 'cli/lib/snapshots' ) ), + getSnapshotsFromConfig: vi.fn().mockResolvedValue( [] ), + saveSnapshotToConfig: vi.fn(), +} ) ); vi.mock( 'cli/logger', () => ( { Logger: class { reportStart = mockReportStart; @@ -92,7 +100,7 @@ describe( 'Preview Create Command', () => { site_id: mockAtomicSiteId, } ); vi.mocked( waitForSiteReady ).mockResolvedValue( true ); - vi.mocked( saveSnapshotToAppdata ).mockResolvedValue( { + vi.mocked( saveSnapshotToConfig ).mockResolvedValue( { url: mockSiteUrl, atomicSiteId: mockAtomicSiteId, localSiteId: 'site-123', @@ -133,10 +141,12 @@ describe( 'Preview Create Command', () => { `Preview site available at: https://${ mockSiteUrl }`, ] ); - expect( saveSnapshotToAppdata ).toHaveBeenCalledWith( + expect( saveSnapshotToConfig ).toHaveBeenCalledWith( mockFolder, mockAtomicSiteId, - mockSiteUrl + mockSiteUrl, + mockAuthToken.id, + 'Test Site Preview 1' ); expect( mockReportStart.mock.calls[ 4 ] ).toEqual( [ 'appdata', @@ -217,12 +227,12 @@ describe( 'Preview Create Command', () => { expect( mockReportError ).toHaveBeenCalled(); expect( mockReportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( saveSnapshotToAppdata ).not.toHaveBeenCalled(); + expect( saveSnapshotToConfig ).not.toHaveBeenCalled(); } ); it( 'should handle appdata errors', async () => { const errorMessage = 'Failed to save to appdata'; - vi.mocked( saveSnapshotToAppdata ).mockImplementation( () => { + vi.mocked( saveSnapshotToConfig ).mockImplementation( () => { throw new LoggerError( errorMessage ); } ); diff --git a/apps/cli/commands/preview/tests/delete.test.ts b/apps/cli/commands/preview/tests/delete.test.ts index 5f0d09c00f..625ba19643 100644 --- a/apps/cli/commands/preview/tests/delete.test.ts +++ b/apps/cli/commands/preview/tests/delete.test.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; import { deleteSnapshot } from 'cli/lib/api'; import { getAuthToken } from 'cli/lib/appdata'; -import { getSnapshotsFromAppdata, deleteSnapshotFromAppdata } from 'cli/lib/snapshots'; +import { getSnapshotsFromConfig, deleteSnapshotFromConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { mockReportStart, @@ -61,9 +61,9 @@ describe( 'Preview Delete Command', () => { vi.clearAllMocks(); vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [ mockSnapshot ] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ mockSnapshot ] ); vi.mocked( deleteSnapshot ).mockResolvedValue( undefined ); - vi.mocked( deleteSnapshotFromAppdata ).mockResolvedValue( undefined ); + vi.mocked( deleteSnapshotFromConfig ).mockResolvedValue( undefined ); } ); afterEach( () => { @@ -74,9 +74,9 @@ describe( 'Preview Delete Command', () => { await runCommand( mockSiteUrl ); expect( getAuthToken ).toHaveBeenCalled(); - expect( getSnapshotsFromAppdata ).toHaveBeenCalledWith( mockAuthToken.id ); + expect( getSnapshotsFromConfig ).toHaveBeenCalledWith( mockAuthToken.id ); expect( deleteSnapshot ).toHaveBeenCalledWith( mockAtomicSiteId, mockAuthToken.accessToken ); - expect( deleteSnapshotFromAppdata ).toHaveBeenCalledWith( mockSiteUrl ); + expect( deleteSnapshotFromConfig ).toHaveBeenCalledWith( mockSiteUrl ); expect( mockReportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating…' ] ); expect( mockReportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful', true ] ); @@ -99,7 +99,7 @@ describe( 'Preview Delete Command', () => { } ); it( 'should handle snapshot not found errors', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( mockSiteUrl ); @@ -118,12 +118,12 @@ describe( 'Preview Delete Command', () => { expect( mockReportError ).toHaveBeenCalled(); expect( mockReportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( deleteSnapshotFromAppdata ).not.toHaveBeenCalled(); + expect( deleteSnapshotFromConfig ).not.toHaveBeenCalled(); } ); it( 'should handle delete snapshot errors', async () => { const errorMessage = 'Failed to delete snapshot'; - vi.mocked( deleteSnapshotFromAppdata ).mockImplementation( () => { + vi.mocked( deleteSnapshotFromConfig ).mockImplementation( () => { throw new LoggerError( errorMessage ); } ); diff --git a/apps/cli/commands/preview/tests/list.test.ts b/apps/cli/commands/preview/tests/list.test.ts index 01db1dd9e4..e74938e98c 100644 --- a/apps/cli/commands/preview/tests/list.test.ts +++ b/apps/cli/commands/preview/tests/list.test.ts @@ -1,7 +1,7 @@ import { vi } from 'vitest'; import { getAuthToken } from 'cli/lib/appdata'; -import { getSiteByFolder } from 'cli/lib/cli-config'; -import { getSnapshotsFromAppdata } from 'cli/lib/snapshots'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { mockReportStart, mockReportSuccess, @@ -20,8 +20,15 @@ vi.mock( 'cli/lib/appdata', async () => { getAuthToken: vi.fn(), }; } ); -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); + return { + ...actual, + readCliConfig: vi.fn(), + }; +} ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); return { ...actual, getSiteByFolder: vi.fn(), @@ -85,7 +92,7 @@ describe( 'Preview List Command', () => { vi.mocked( getSiteByFolder ).mockResolvedValue( mockSite ); vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( mockSnapshots ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( mockSnapshots ); } ); afterEach( () => { @@ -95,8 +102,7 @@ describe( 'Preview List Command', () => { it( 'should list preview sites successfully', async () => { await runCommand( mockFolder, 'table' ); - expect( getSiteByFolder ).toHaveBeenCalledWith( mockFolder ); - expect( getSnapshotsFromAppdata ).toHaveBeenCalledWith( mockAuthToken.id, mockFolder ); + expect( getSnapshotsFromConfig ).toHaveBeenCalledWith( mockAuthToken.id, mockFolder ); expect( mockReportStart.mock.calls[ 0 ] ).toEqual( [ 'validate', 'Validating…' ] ); expect( mockReportSuccess.mock.calls[ 0 ] ).toEqual( [ 'Validation successful', true ] ); expect( mockReportStart.mock.calls[ 1 ] ).toEqual( [ 'load', 'Loading preview sites…' ] ); @@ -104,9 +110,7 @@ describe( 'Preview List Command', () => { } ); it( 'should handle validation errors', async () => { - vi.mocked( getSiteByFolder ).mockImplementation( () => { - throw new Error( 'Invalid site folder' ); - } ); + vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'Authentication required' ) ); await runCommand( mockFolder, 'table' ); @@ -114,7 +118,7 @@ describe( 'Preview List Command', () => { } ); it( 'should handle no snapshots found', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); 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 7b6d12afb1..dd5447c1fa 100644 --- a/apps/cli/commands/preview/tests/update.test.ts +++ b/apps/cli/commands/preview/tests/update.test.ts @@ -7,8 +7,8 @@ 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'; -import { updateSnapshotInAppdata, getSnapshotsFromAppdata } from 'cli/lib/snapshots'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { updateSnapshotInConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { mockReportStart, mockReportSuccess, mockReportError } from 'cli/tests/test-utils'; import { runCommand } from '../update'; @@ -22,8 +22,8 @@ vi.mock( 'cli/lib/appdata', async () => { getAuthToken: vi.fn(), }; } ); -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); return { ...actual, getSiteByFolder: vi.fn(), @@ -80,14 +80,14 @@ describe( 'Preview Update Command', () => { vi.spyOn( process, 'cwd' ).mockReturnValue( mockFolder ); vi.mocked( getAuthToken ).mockResolvedValue( mockAuthToken ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [ mockSnapshot ] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ mockSnapshot ] ); vi.mocked( archiveSiteContent ).mockResolvedValue( mockArchiver as Archiver ); vi.mocked( uploadArchive ).mockResolvedValue( { site_url: mockSiteUrl, site_id: mockAtomicSiteId, } ); vi.mocked( waitForSiteReady ).mockResolvedValue( true ); - vi.mocked( updateSnapshotInAppdata ).mockResolvedValue( mockSnapshot ); + vi.mocked( updateSnapshotInConfig ).mockResolvedValue( mockSnapshot ); vi.mocked( getSiteByFolder ).mockResolvedValue( { id: mockSnapshot.localSiteId, path: mockFolder, @@ -127,7 +127,7 @@ describe( 'Preview Update Command', () => { `Preview site available at: https://${ mockSiteUrl }`, ] ); - expect( updateSnapshotInAppdata ).toHaveBeenCalledWith( mockAtomicSiteId, mockFolder ); + expect( updateSnapshotInConfig ).toHaveBeenCalledWith( mockAtomicSiteId, mockFolder ); expect( mockReportStart.mock.calls[ 4 ] ).toEqual( [ 'appdata', 'Saving preview site to Studio…', @@ -156,7 +156,7 @@ describe( 'Preview Update Command', () => { } ); it( 'should handle snapshot not found errors', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( mockFolder, mockSiteUrl, false ); @@ -201,12 +201,12 @@ describe( 'Preview Update Command', () => { expect( mockReportError ).toHaveBeenCalled(); expect( mockReportError ).toHaveBeenCalledWith( expect.any( LoggerError ) ); - expect( updateSnapshotInAppdata ).not.toHaveBeenCalled(); + expect( updateSnapshotInConfig ).not.toHaveBeenCalled(); } ); it( 'should handle appdata errors', async () => { const errorMessage = 'Failed to save to appdata'; - vi.mocked( updateSnapshotInAppdata ).mockImplementation( () => { + vi.mocked( updateSnapshotInConfig ).mockImplementation( () => { throw new LoggerError( errorMessage ); } ); @@ -229,7 +229,7 @@ describe( 'Preview Update Command', () => { it( 'should not allow updating an expired preview site', async () => { const expiredDate = mockDate - ( DEMO_SITE_EXPIRATION_DAYS + 1 ) * 24 * 60 * 60 * 1000; const expiredSnapshot = { ...mockSnapshot, date: expiredDate }; - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [ expiredSnapshot ] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ expiredSnapshot ] ); await runCommand( mockFolder, mockSiteUrl, false ); diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index 3d17e24a8b..ad460280af 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -1,6 +1,7 @@ import os from 'node:os'; 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 { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { Snapshot } from '@studio/common/types/snapshot'; @@ -9,8 +10,9 @@ 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'; -import { getSnapshotsFromAppdata, updateSnapshotInAppdata } from 'cli/lib/snapshots'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { emitCliEvent } from 'cli/lib/daemon-client'; +import { getSnapshotsFromConfig, updateSnapshotInConfig } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -54,7 +56,7 @@ export async function runCommand( try { logger.reportStart( LoggerAction.VALIDATE, __( 'Validating…' ) ); const token = await getAuthToken(); - const snapshots = await getSnapshotsFromAppdata( token.id ); + const snapshots = await getSnapshotsFromConfig( token.id ); const snapshotToUpdate = await getSnapshotToUpdate( snapshots, host, siteFolder, overwrite ); const now = new Date(); @@ -86,7 +88,8 @@ export async function runCommand( ); logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); - const snapshot = await updateSnapshotInAppdata( uploadResponse.site_id, siteFolder ); + const snapshot = await updateSnapshotInConfig( uploadResponse.site_id, siteFolder ); + await emitCliEvent( { event: SNAPSHOT_EVENTS.UPDATED, data: { snapshotUrl: snapshot.url } } ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 05405929c9..27efb5b948 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -15,6 +15,7 @@ import { filterUnsupportedBlueprintFeatures, validateBlueprintData, } from '@studio/common/lib/blueprint-validation'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual, @@ -36,7 +37,6 @@ import { hasDefaultDbBlock, removeDbConstants, } from '@studio/common/lib/remove-default-db-constants'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; import { sortSites } from '@studio/common/lib/sort-sites'; import { isValidWordPressVersion, @@ -53,14 +53,16 @@ import { import { lockCliConfig, readCliConfig, - removeSiteFromConfig, saveCliConfig, SiteData, unlockCliConfig, +} from 'cli/lib/cli-config/core'; +import { + removeSiteFromConfig, updateSiteAutoStart, updateSiteLatestCliPid, -} from 'cli/lib/cli-config'; -import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; +} from 'cli/lib/cli-config/sites'; +import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { getAgentSkillsPath, getServerFilesPath } from 'cli/lib/server-files'; import { getPreferredSiteLanguage } from 'cli/lib/site-language'; @@ -451,7 +453,7 @@ export async function runCommand( logger.reportKeyValuePair( 'id', siteDetails.id ); logger.reportKeyValuePair( 'running', String( siteDetails.running ) ); - await emitSiteEvent( SITE_EVENTS.CREATED, { siteId: siteDetails.id } ); + await emitCliEvent( { event: SITE_EVENTS.CREATED, data: { siteId: siteDetails.id } } ); } finally { await disconnectFromDaemon(); } diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 72453a14e0..55846535ba 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -1,22 +1,22 @@ import fs from 'fs'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; 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 { - getSiteByFolder, lockCliConfig, readCliConfig, saveCliConfig, unlockCliConfig, -} from 'cli/lib/cli-config'; -import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; +} from 'cli/lib/cli-config/core'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; -import { getSnapshotsFromAppdata, deleteSnapshotFromAppdata } from 'cli/lib/snapshots'; +import { getSnapshotsFromConfig, deleteSnapshotFromConfig } from 'cli/lib/snapshots'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -25,7 +25,7 @@ const logger = new Logger< LoggerAction >(); async function deletePreviewSites( authToken: ValidatedAuthToken, siteFolder: string ) { try { - const snapshots = await getSnapshotsFromAppdata( authToken.id, siteFolder ); + const snapshots = await getSnapshotsFromConfig( authToken.id, siteFolder ); if ( snapshots.length > 0 ) { logger.reportStart( @@ -44,7 +44,7 @@ async function deletePreviewSites( authToken: ValidatedAuthToken, siteFolder: st await Promise.all( snapshots.map( async ( snapshot ) => { await deleteSnapshot( snapshot.atomicSiteId, authToken.accessToken ); - await deleteSnapshotFromAppdata( snapshot.url ); + await deleteSnapshotFromConfig( snapshot.url ); } ) ); @@ -131,7 +131,7 @@ export async function runCommand( } } - await emitSiteEvent( SITE_EVENTS.DELETED, { siteId: site.id } ); + await emitCliEvent( { event: SITE_EVENTS.DELETED, data: { siteId: site.id } } ); } finally { await disconnectFromDaemon(); } diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index 5014b944ba..db55d4f426 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -1,8 +1,9 @@ -import { type SiteDetails } from '@studio/common/lib/site-events'; +import { type SiteDetails } from '@studio/common/lib/cli-events'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import CliTable3 from 'cli-table3'; -import { getSiteUrl, readCliConfig, type SiteData } from 'cli/lib/cli-config'; +import { readCliConfig, type SiteData } from 'cli/lib/cli-config/core'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isSiteRunning } from 'cli/lib/site-utils'; import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; @@ -80,7 +81,9 @@ function displaySiteList( console.log( table.toString() ); } else { - logger.reportKeyValuePair( 'sites', JSON.stringify( data.jsonEntries ) ); + const json = JSON.stringify( data.jsonEntries ); + console.log( json ); + logger.reportKeyValuePair( 'sites', json ); } } diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index d70e8351ea..3febcf2581 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -1,5 +1,6 @@ import { SupportedPHPVersions } from '@php-wasm/universal'; import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { @@ -7,7 +8,6 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; import { getWordPressVersionUrl, @@ -17,14 +17,13 @@ import { import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { - getSiteByFolder, lockCliConfig, readCliConfig, saveCliConfig, unlockCliConfig, - updateSiteLatestCliPid, -} from 'cli/lib/cli-config'; -import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; +} from 'cli/lib/cli-config/core'; +import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; +import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { updateDomainInHosts } from 'cli/lib/hosts-file'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { setupCustomDomain } from 'cli/lib/site-utils'; @@ -315,7 +314,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) logger.reportSuccess( __( 'Site configuration updated' ) ); - await emitSiteEvent( SITE_EVENTS.UPDATED, { siteId: site.id } ); + await emitCliEvent( { event: SITE_EVENTS.UPDATED, data: { siteId: site.id } } ); return; } finally { diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts index d2d13d145e..a300bddbe7 100644 --- a/apps/cli/commands/site/start.ts +++ b/apps/cli/commands/site/start.ts @@ -1,6 +1,10 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; -import { getSiteByFolder, updateSiteAutoStart, updateSiteLatestCliPid } from 'cli/lib/cli-config'; +import { + getSiteByFolder, + updateSiteAutoStart, + updateSiteLatestCliPid, +} from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; diff --git a/apps/cli/commands/site/status.ts b/apps/cli/commands/site/status.ts index 01078d1f6e..16ea40c2ba 100644 --- a/apps/cli/commands/site/status.ts +++ b/apps/cli/commands/site/status.ts @@ -3,7 +3,7 @@ import { decodePassword } from '@studio/common/lib/passwords'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n } from '@wordpress/i18n'; import CliTable3 from 'cli-table3'; -import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config'; +import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getPrettyPath } from 'cli/lib/utils'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; diff --git a/apps/cli/commands/site/stop.ts b/apps/cli/commands/site/stop.ts index 4ca486ccec..2f9ab82b0e 100644 --- a/apps/cli/commands/site/stop.ts +++ b/apps/cli/commands/site/stop.ts @@ -1,15 +1,17 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import { - clearSiteLatestCliPid, - getSiteByFolder, lockCliConfig, readCliConfig, saveCliConfig, unlockCliConfig, - updateSiteAutoStart, type SiteData, -} from 'cli/lib/cli-config'; +} from 'cli/lib/cli-config/core'; +import { + clearSiteLatestCliPid, + getSiteByFolder, + updateSiteAutoStart, +} from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index 56632a4947..61e58d9fd2 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -18,12 +18,11 @@ import { vi, type MockInstance } from 'vitest'; import { lockCliConfig, readCliConfig, - removeSiteFromConfig, saveCliConfig, unlockCliConfig, - updateSiteAutoStart, SiteData, -} from 'cli/lib/cli-config'; +} from 'cli/lib/cli-config/core'; +import { removeSiteFromConfig, updateSiteAutoStart } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { getServerFilesPath } from 'cli/lib/server-files'; @@ -47,14 +46,20 @@ vi.mock( '@studio/common/lib/passwords', () => ( { createPassword: vi.fn().mockReturnValue( 'generated-password-123' ), } ) ); vi.mock( '@studio/common/lib/blueprint-validation' ); -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { ...actual, readCliConfig: vi.fn(), saveCliConfig: vi.fn(), lockCliConfig: vi.fn(), unlockCliConfig: vi.fn(), + }; +} ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); + return { + ...actual, updateSiteLatestCliPid: vi.fn(), updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), removeSiteFromConfig: vi.fn(), diff --git a/apps/cli/commands/site/tests/delete.test.ts b/apps/cli/commands/site/tests/delete.test.ts index 4dceb44446..9f11894893 100644 --- a/apps/cli/commands/site/tests/delete.test.ts +++ b/apps/cli/commands/site/tests/delete.test.ts @@ -7,16 +7,16 @@ import { getAuthToken } from 'cli/lib/appdata'; import { deleteSiteCertificate } from 'cli/lib/certificate-manager'; import { SiteData, - getSiteByFolder, lockCliConfig, readCliConfig, saveCliConfig, unlockCliConfig, -} from 'cli/lib/cli-config'; +} from 'cli/lib/cli-config/core'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; -import { getSnapshotsFromAppdata, deleteSnapshotFromAppdata } from 'cli/lib/snapshots'; +import { getSnapshotsFromConfig, deleteSnapshotFromConfig } from 'cli/lib/snapshots'; import { ProcessDescription } from 'cli/lib/types/process-manager-ipc'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { runCommand } from '../delete'; @@ -30,17 +30,23 @@ vi.mock( 'cli/lib/appdata', async () => { getAuthToken: vi.fn(), }; } ); -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { ...actual, - getSiteByFolder: vi.fn(), lockCliConfig: vi.fn(), readCliConfig: vi.fn(), saveCliConfig: vi.fn(), unlockCliConfig: vi.fn(), }; } ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); + return { + ...actual, + getSiteByFolder: vi.fn(), + }; +} ); vi.mock( 'cli/lib/certificate-manager' ); vi.mock( 'cli/lib/hosts-file' ); vi.mock( 'cli/lib/daemon-client' ); @@ -113,6 +119,7 @@ describe( 'CLI: studio site delete', () => { vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { version: 1, sites: [ testSite ], + snapshots: [], } ); vi.mocked( saveCliConfig ).mockResolvedValue( undefined ); vi.mocked( unlockCliConfig ).mockResolvedValue( undefined ); @@ -120,9 +127,9 @@ describe( 'CLI: studio site delete', () => { vi.mocked( stopWordPressServer ).mockResolvedValue( undefined ); vi.mocked( removeDomainFromHosts ).mockResolvedValue( undefined ); vi.mocked( deleteSiteCertificate ).mockReturnValue( true ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); vi.mocked( deleteSnapshot ).mockResolvedValue( undefined ); - vi.mocked( deleteSnapshotFromAppdata ).mockResolvedValue( undefined ); + vi.mocked( deleteSnapshotFromConfig ).mockResolvedValue( undefined ); vi.mocked( stopProxyIfNoSitesNeedIt ).mockResolvedValue( undefined ); vi.mocked( arePathsEqual ).mockImplementation( ( a: string, b: string ) => a === b ); vi.spyOn( fs, 'existsSync' ).mockReturnValue( true ); @@ -153,6 +160,7 @@ describe( 'CLI: studio site delete', () => { vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { version: 1, sites: [], + snapshots: [], } ); await expect( runCommand( testSiteFolder ) ).rejects.toThrow( @@ -178,7 +186,7 @@ describe( 'CLI: studio site delete', () => { it( 'should proceed when getAuthToken fails', async () => { vi.mocked( getAuthToken ).mockRejectedValue( new Error( 'Auth failed' ) ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await expect( runCommand( testSiteFolder, false ) ).resolves.not.toThrow(); expect( saveCliConfig ).toHaveBeenCalled(); @@ -188,7 +196,7 @@ describe( 'CLI: studio site delete', () => { describe( 'Success Cases', () => { it( 'should delete a stopped site without removing files and no preview sites', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); @@ -207,7 +215,7 @@ describe( 'CLI: studio site delete', () => { it( 'should delete a running site and stop it first', async () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); @@ -221,7 +229,7 @@ describe( 'CLI: studio site delete', () => { } ); it( 'should delete a site and remove files when files flag is set', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, true ); @@ -232,11 +240,11 @@ describe( 'CLI: studio site delete', () => { } ); it( 'should delete associated preview sites', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [ testSnapshot1, testSnapshot2 ] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ testSnapshot1, testSnapshot2 ] ); await runCommand( testSiteFolder ); - expect( getSnapshotsFromAppdata ).toHaveBeenCalledWith( testAuthToken.id, testSiteFolder ); + expect( getSnapshotsFromConfig ).toHaveBeenCalledWith( testAuthToken.id, testSiteFolder ); expect( deleteSnapshot ).toHaveBeenCalledWith( testSnapshot1.atomicSiteId, testAuthToken.accessToken @@ -245,14 +253,14 @@ describe( 'CLI: studio site delete', () => { testSnapshot2.atomicSiteId, testAuthToken.accessToken ); - expect( deleteSnapshotFromAppdata ).toHaveBeenCalledWith( testSnapshot1.url ); - expect( deleteSnapshotFromAppdata ).toHaveBeenCalledWith( testSnapshot2.url ); + expect( deleteSnapshotFromConfig ).toHaveBeenCalledWith( testSnapshot1.url ); + expect( deleteSnapshotFromConfig ).toHaveBeenCalledWith( testSnapshot2.url ); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); it( 'should delete a running site and remove files along with preview sites', async () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [ testSnapshot1 ] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [ testSnapshot1 ] ); await runCommand( testSiteFolder, true ); @@ -261,7 +269,7 @@ describe( 'CLI: studio site delete', () => { testSnapshot1.atomicSiteId, testAuthToken.accessToken ); - expect( deleteSnapshotFromAppdata ).toHaveBeenCalledWith( testSnapshot1.url ); + expect( deleteSnapshotFromConfig ).toHaveBeenCalledWith( testSnapshot1.url ); expect( stopProxyIfNoSitesNeedIt ).toHaveBeenCalled(); expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); @@ -273,7 +281,7 @@ describe( 'CLI: studio site delete', () => { version: 1, sites: [ testSite ], } ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); @@ -289,7 +297,7 @@ describe( 'CLI: studio site delete', () => { version: 1, sites: [ testSite ], } ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); @@ -300,7 +308,7 @@ describe( 'CLI: studio site delete', () => { it( 'should skip file deletion when site directory no longer exists', async () => { vi.spyOn( fs, 'existsSync' ).mockReturnValue( false ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, true ); @@ -312,7 +320,7 @@ describe( 'CLI: studio site delete', () => { } ); it( 'should not remove domain or certificate if no custom domain', async () => { - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); diff --git a/apps/cli/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index d81b49fbc2..5f9d4ca484 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -1,11 +1,11 @@ import { vi } from 'vitest'; -import { readCliConfig } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { mockReportKeyValuePair } from 'cli/tests/test-utils'; import { runCommand } from '../list'; -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { ...actual, readCliConfig: vi.fn(), @@ -47,11 +47,13 @@ describe( 'CLI: studio site list', () => { customDomain: 'my-site.wp.local', }, ], + snapshots: [], }; const emptyCliConfig = { version: 1, sites: [], + snapshots: [], }; beforeEach( () => { diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index b709707c5c..30f81448d9 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -3,13 +3,8 @@ import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { encodePassword } from '@studio/common/lib/passwords'; import { vi } from 'vitest'; -import { - getSiteByFolder, - unlockCliConfig, - readCliConfig, - saveCliConfig, - SiteData, -} from 'cli/lib/cli-config'; +import { readCliConfig, saveCliConfig, unlockCliConfig, SiteData } from 'cli/lib/cli-config/core'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { updateDomainInHosts } from 'cli/lib/hosts-file'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; @@ -30,15 +25,21 @@ vi.mock( '@studio/common/lib/fs-utils', async () => { arePathsEqual: vi.fn(), }; } ); -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { ...actual, - getSiteByFolder: vi.fn(), lockCliConfig: vi.fn().mockResolvedValue( undefined ), unlockCliConfig: vi.fn().mockResolvedValue( undefined ), readCliConfig: vi.fn(), saveCliConfig: vi.fn().mockResolvedValue( undefined ), + }; +} ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); + return { + ...actual, + getSiteByFolder: vi.fn(), updateSiteLatestCliPid: vi.fn().mockResolvedValue( undefined ), }; } ); @@ -78,7 +79,7 @@ describe( 'CLI: studio site set', () => { vi.clearAllMocks(); const testSite = getTestSite(); - const testCliConfig = { version: 1, sites: [ testSite ] }; + const testCliConfig = { version: 1, sites: [ testSite ], snapshots: [] }; vi.mocked( arePathsEqual ).mockReturnValue( true ); vi.mocked( getSiteByFolder ).mockResolvedValue( getTestSite() ); @@ -145,6 +146,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], version: 1, + snapshots: [], } ); await runCommand( testSitePath, { https: true } ); @@ -200,6 +202,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], version: 1, + snapshots: [], } ); await runCommand( testSitePath, { https: true } ); @@ -216,6 +219,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], version: 1, + snapshots: [], } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); @@ -359,6 +363,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ testSite, otherSite ], version: 1, + snapshots: [], } ); await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow( @@ -390,6 +395,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebug ], version: 1, + snapshots: [], } ); await runCommand( testSitePath, { xdebug: false } ); @@ -404,6 +410,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebug ], version: 1, + snapshots: [], } ); await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow( @@ -417,6 +424,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebugDisabled ], version: 1, + snapshots: [], } ); await expect( runCommand( testSitePath, { xdebug: false } ) ).rejects.toThrow( diff --git a/apps/cli/commands/site/tests/start.test.ts b/apps/cli/commands/site/tests/start.test.ts index abfbedae11..8b9a96913b 100644 --- a/apps/cli/commands/site/tests/start.test.ts +++ b/apps/cli/commands/site/tests/start.test.ts @@ -1,10 +1,10 @@ import { vi } from 'vitest'; +import { SiteData } from 'cli/lib/cli-config/core'; import { getSiteByFolder, - updateSiteLatestCliPid, updateSiteAutoStart, - SiteData, -} from 'cli/lib/cli-config'; + updateSiteLatestCliPid, +} from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { logSiteDetails, openSiteInBrowser, setupCustomDomain } from 'cli/lib/site-utils'; import { keepSqliteIntegrationUpdated } from 'cli/lib/sqlite-integration'; @@ -13,8 +13,8 @@ import { isServerRunning, startWordPressServer } from 'cli/lib/wordpress-server- import { Logger } from 'cli/logger'; import { runCommand } from '../start'; -vi.mock( 'cli/lib/cli-config', async () => ( { - ...( await vi.importActual( 'cli/lib/cli-config' ) ), +vi.mock( 'cli/lib/cli-config/sites', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config/sites' ) ), getSiteByFolder: vi.fn(), updateSiteLatestCliPid: vi.fn(), updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), diff --git a/apps/cli/commands/site/tests/status.test.ts b/apps/cli/commands/site/tests/status.test.ts index 4e6770494f..8351a6a8d5 100644 --- a/apps/cli/commands/site/tests/status.test.ts +++ b/apps/cli/commands/site/tests/status.test.ts @@ -1,11 +1,11 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { vi } from 'vitest'; -import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config'; +import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { runCommand } from '../status'; -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); return { ...actual, getSiteByFolder: vi.fn(), diff --git a/apps/cli/commands/site/tests/stop.test.ts b/apps/cli/commands/site/tests/stop.test.ts index ae9aec153b..2df7e4a7ea 100644 --- a/apps/cli/commands/site/tests/stop.test.ts +++ b/apps/cli/commands/site/tests/stop.test.ts @@ -1,12 +1,10 @@ import { vi } from 'vitest'; +import { SiteData, readCliConfig, saveCliConfig } from 'cli/lib/cli-config/core'; import { - SiteData, clearSiteLatestCliPid, getSiteByFolder, - readCliConfig, - saveCliConfig, updateSiteAutoStart, -} from 'cli/lib/cli-config'; +} from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, @@ -17,15 +15,21 @@ import { ProcessDescription } from 'cli/lib/types/process-manager-ipc'; import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager'; import { Mode, runCommand } from '../stop'; -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { ...actual, - getSiteByFolder: vi.fn(), readCliConfig: vi.fn(), + saveCliConfig: vi.fn().mockResolvedValue( undefined ), + }; +} ); +vi.mock( 'cli/lib/cli-config/sites', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); + return { + ...actual, + getSiteByFolder: vi.fn(), clearSiteLatestCliPid: vi.fn(), updateSiteAutoStart: vi.fn().mockResolvedValue( undefined ), - saveCliConfig: vi.fn().mockResolvedValue( undefined ), }; } ); vi.mock( 'cli/lib/daemon-client' ); @@ -238,7 +242,11 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should throw when process manager connection fails', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: testSites, + snapshots: [], + } ); vi.mocked( connectToDaemon ).mockRejectedValue( new Error( 'process manager connection failed' ) ); @@ -250,7 +258,11 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should throw when killDaemonAndAllChildren fails', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: testSites, + snapshots: [], + } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); vi.mocked( killDaemonAndChildren ).mockRejectedValue( new Error( 'Failed to kill daemon' ) ); @@ -263,7 +275,7 @@ describe( 'CLI: studio site stop --all', () => { describe( 'Success Cases', () => { it( 'should kill daemon even with empty sites list', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [], snapshots: [] } ); await runCommand( Mode.STOP_ALL_SITES, undefined, false ); @@ -272,7 +284,11 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should kill daemon even if no sites are running', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: testSites, + snapshots: [], + } ); vi.mocked( isServerRunning ).mockResolvedValue( undefined ); @@ -285,7 +301,11 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should handle single site', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: [ testSites[ 0 ] ] } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: [ testSites[ 0 ] ], + snapshots: [], + } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); await runCommand( Mode.STOP_ALL_SITES, undefined, false ); @@ -305,7 +325,11 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should stop all running sites', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: testSites, + snapshots: [], + } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); await runCommand( Mode.STOP_ALL_SITES, undefined, false ); @@ -341,7 +365,11 @@ describe( 'CLI: studio site stop --all', () => { } ); it( 'should stop only running sites (mixed state)', async () => { - vi.mocked( readCliConfig ).mockResolvedValue( { version: 1, sites: testSites } ); + vi.mocked( readCliConfig ).mockResolvedValue( { + version: 1, + sites: testSites, + snapshots: [], + } ); vi.mocked( isServerRunning ) .mockResolvedValueOnce( testProcessDescription ) // site-1 running diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index 2a7854b904..3ef1cc6258 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -2,7 +2,7 @@ import { StreamedPHPResponse } from '@php-wasm/universal'; import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; import yargsParser from 'yargs-parser'; -import { getSiteByFolder } from 'cli/lib/cli-config'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { runWpCliCommand, runGlobalWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3004d9a839..866f55a95e 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -83,17 +83,20 @@ async function main() { { registerCommand: registerPreviewListCommand }, { registerCommand: registerPreviewDeleteCommand }, { registerCommand: registerPreviewUpdateCommand }, + { registerCommand: registerPreviewSetCommand }, ] = await Promise.all( [ import( 'cli/commands/preview/create' ), import( 'cli/commands/preview/list' ), import( 'cli/commands/preview/delete' ), import( 'cli/commands/preview/update' ), + import( 'cli/commands/preview/set' ), ] ); registerPreviewCreateCommand( previewYargs ); registerPreviewListCommand( previewYargs ); registerPreviewDeleteCommand( previewYargs ); registerPreviewUpdateCommand( previewYargs ); + registerPreviewSetCommand( previewYargs ); previewYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .command( 'site', __( 'Manage sites' ), async ( sitesYargs ) => { diff --git a/apps/cli/lib/appdata.ts b/apps/cli/lib/appdata.ts index 6d368d01fe..8c37818fc9 100644 --- a/apps/cli/lib/appdata.ts +++ b/apps/cli/lib/appdata.ts @@ -4,7 +4,6 @@ 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 { snapshotSchema } from '@studio/common/types/snapshot'; import { __, sprintf } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; @@ -17,7 +16,6 @@ const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-k const userDataSchema = z .object( { - snapshots: z.array( snapshotSchema ).default( () => [] ), locale: z.string().optional(), aiProvider: aiProviderSchema.optional(), authToken: z diff --git a/apps/cli/lib/cli-config.ts b/apps/cli/lib/cli-config/core.ts similarity index 58% rename from apps/cli/lib/cli-config.ts rename to apps/cli/lib/cli-config/core.ts index c9a5a8771b..bdae5c5ea6 100644 --- a/apps/cli/lib/cli-config.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -1,9 +1,9 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; -import { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; +import { siteDetailsSchema } from '@studio/common/lib/cli-events'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { siteDetailsSchema } from '@studio/common/lib/site-events'; +import { snapshotSchema } from '@studio/common/types/snapshot'; import { __ } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; @@ -24,6 +24,7 @@ const cliConfigWithJustVersion = z.object( { // read this file, and any updates to this schema may require updating the `version` field. const cliConfigSchema = cliConfigWithJustVersion.extend( { sites: z.array( siteSchema ).default( () => [] ), + snapshots: z.array( snapshotSchema ).default( () => [] ), } ); type CliConfig = z.infer< typeof cliConfigSchema >; @@ -32,6 +33,7 @@ export type SiteData = z.infer< typeof siteSchema >; const DEFAULT_CLI_CONFIG: CliConfig = { version: 1, sites: [], + snapshots: [], }; export function getCliConfigDirectory(): string { @@ -117,95 +119,3 @@ export async function lockCliConfig(): Promise< void > { export async function unlockCliConfig(): Promise< void > { await unlockFileAsync( LOCKFILE_PATH ); } - -export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > { - const config = await readCliConfig(); - const site = config.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) ); - - if ( ! site ) { - if ( isWordPressDirectory( siteFolder ) ) { - throw new LoggerError( - __( 'The specified directory is not added to Studio. Use `studio site create` to add it.' ) - ); - } - - throw new LoggerError( __( 'The specified directory is not added to Studio.' ) ); - } - - return site; -} - -export function getSiteUrl( site: SiteData ): string { - if ( site.url ) { - return site.url; - } - - if ( site.customDomain ) { - const protocol = site.enableHttps ? 'https' : 'http'; - return `${ protocol }://${ site.customDomain }`; - } - - return `http://localhost:${ site.port }`; -} - -export async function updateSiteLatestCliPid( siteId: string, pid: number ): Promise< void > { - try { - await lockCliConfig(); - const config = await readCliConfig(); - const site = config.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - site.latestCliPid = pid; - await saveCliConfig( config ); - } finally { - await unlockCliConfig(); - } -} - -export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { - try { - await lockCliConfig(); - const config = await readCliConfig(); - const site = config.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - delete site.latestCliPid; - await saveCliConfig( config ); - } finally { - await unlockCliConfig(); - } -} - -export async function updateSiteAutoStart( siteId: string, autoStart: boolean ): Promise< void > { - try { - await lockCliConfig(); - const config = await readCliConfig(); - const site = config.sites.find( ( s ) => s.id === siteId ); - - if ( ! site ) { - throw new LoggerError( __( 'Site not found' ) ); - } - - site.autoStart = autoStart; - await saveCliConfig( config ); - } finally { - await unlockCliConfig(); - } -} - -export async function removeSiteFromConfig( siteId: string ): Promise< void > { - try { - await lockCliConfig(); - const config = await readCliConfig(); - config.sites = config.sites.filter( ( s ) => s.id !== siteId ); - await saveCliConfig( config ); - } finally { - await unlockCliConfig(); - } -} diff --git a/apps/cli/lib/cli-config/sites.ts b/apps/cli/lib/cli-config/sites.ts new file mode 100644 index 0000000000..0fb0ffb6ef --- /dev/null +++ b/apps/cli/lib/cli-config/sites.ts @@ -0,0 +1,102 @@ +import { arePathsEqual, isWordPressDirectory } from '@studio/common/lib/fs-utils'; +import { __ } from '@wordpress/i18n'; +import { LoggerError } from 'cli/logger'; +import { + lockCliConfig, + readCliConfig, + saveCliConfig, + type SiteData, + unlockCliConfig, +} from './core'; + +export async function getSiteByFolder( siteFolder: string ): Promise< SiteData > { + const config = await readCliConfig(); + const site = config.sites.find( ( site ) => arePathsEqual( site.path, siteFolder ) ); + + if ( ! site ) { + if ( isWordPressDirectory( siteFolder ) ) { + throw new LoggerError( + __( 'The specified directory is not added to Studio. Use `studio site create` to add it.' ) + ); + } + + throw new LoggerError( __( 'The specified directory is not added to Studio.' ) ); + } + + return site; +} + +export function getSiteUrl( site: SiteData ): string { + if ( site.url ) { + return site.url; + } + + if ( site.customDomain ) { + const protocol = site.enableHttps ? 'https' : 'http'; + return `${ protocol }://${ site.customDomain }`; + } + + return `http://localhost:${ site.port }`; +} + +export async function updateSiteLatestCliPid( siteId: string, pid: number ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + site.latestCliPid = pid; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function clearSiteLatestCliPid( siteId: string ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + delete site.latestCliPid; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function updateSiteAutoStart( siteId: string, autoStart: boolean ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const site = config.sites.find( ( s ) => s.id === siteId ); + + if ( ! site ) { + throw new LoggerError( __( 'Site not found' ) ); + } + + site.autoStart = autoStart; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function removeSiteFromConfig( siteId: string ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + config.sites = config.sites.filter( ( s ) => s.id !== siteId ); + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} diff --git a/apps/cli/lib/cli-config/snapshots.ts b/apps/cli/lib/cli-config/snapshots.ts new file mode 100644 index 0000000000..010879ff22 --- /dev/null +++ b/apps/cli/lib/cli-config/snapshots.ts @@ -0,0 +1,130 @@ +import { type Snapshot } from '@studio/common/types/snapshot'; +import { __ } from '@wordpress/i18n'; +import { LoggerError } from 'cli/logger'; +import { lockCliConfig, readCliConfig, saveCliConfig, unlockCliConfig } from './core'; +import { getSiteByFolder } from './sites'; + +export function getNextSnapshotSequence( + siteId: string, + snapshots: Snapshot[], + userId: number +): number { + const siteSnapshots = snapshots.filter( + ( s ) => s.localSiteId === siteId && s.userId === userId + ); + + const existingSequences = siteSnapshots + .map( ( s ) => s.sequence ?? 0 ) + .filter( ( n ) => ! isNaN( n ) ); + + return existingSequences.length > 0 + ? Math.max( ...existingSequences ) + 1 + : siteSnapshots.length + 1; +} + +export async function getSnapshotsFromConfig( + userId: number, + siteFolder?: string +): Promise< Snapshot[] > { + const config = await readCliConfig(); + let snapshots = config.snapshots.filter( ( snapshot ) => snapshot.userId === userId ); + + if ( siteFolder ) { + const site = await getSiteByFolder( siteFolder ); + snapshots = snapshots.filter( ( snapshot ) => snapshot.localSiteId === site.id ); + } + + return snapshots; +} + +export async function saveSnapshotToConfig( + siteFolder: string, + atomicSiteId: number, + previewUrl: string, + userId: number, + name: string +): Promise< Snapshot > { + try { + const site = await getSiteByFolder( siteFolder ); + await lockCliConfig(); + const config = await readCliConfig(); + + const nextSequenceNumber = getNextSnapshotSequence( site.id, config.snapshots, userId ); + const snapshot: Snapshot = { + url: previewUrl, + atomicSiteId, + localSiteId: site.id, + date: Date.now(), + name, + sequence: nextSequenceNumber, + userId, + }; + + config.snapshots.push( snapshot ); + await saveCliConfig( config ); + return snapshot; + } finally { + await unlockCliConfig(); + } +} + +export async function updateSnapshotInConfig( + atomicSiteId: number, + siteFolder: string +): Promise< Snapshot > { + try { + const site = await getSiteByFolder( siteFolder ); + await lockCliConfig(); + const config = await readCliConfig(); + const snapshot = config.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); + if ( ! snapshot ) { + throw new LoggerError( __( 'Failed to find existing preview site in config' ) ); + } + + snapshot.localSiteId = site.id; + snapshot.date = Date.now(); + + await saveCliConfig( config ); + return snapshot; + } finally { + await unlockCliConfig(); + } +} + +export async function deleteSnapshotFromConfig( snapshotUrl: string ): Promise< void > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const filtered = config.snapshots.filter( ( s ) => s.url !== snapshotUrl ); + if ( filtered.length === config.snapshots.length ) { + return; + } + config.snapshots = filtered; + await saveCliConfig( config ); + } finally { + await unlockCliConfig(); + } +} + +export async function setSnapshotInConfig( + snapshotUrl: string, + updates: { name?: string } +): Promise< Snapshot > { + try { + await lockCliConfig(); + const config = await readCliConfig(); + const snapshot = config.snapshots.find( ( s ) => s.url === snapshotUrl ); + if ( ! snapshot ) { + throw new LoggerError( __( 'Preview site not found in config' ) ); + } + + if ( updates.name !== undefined ) { + snapshot.name = updates.name; + } + + await saveCliConfig( config ); + return snapshot; + } finally { + await unlockCliConfig(); + } +} diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 8c0b7d3b3a..ba7aea1fe8 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -5,9 +5,9 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; import { cacheFunctionTTL } from '@studio/common/lib/cache-function-ttl'; +import { type SITE_EVENTS, type SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; import { z } from 'zod'; import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, @@ -331,18 +331,16 @@ export async function stopProcess( processName: string ): Promise< void > { const eventsSocketClient = new SocketRequestClient( SITE_EVENTS_SOCKET_PATH ); +type CliEventPayload = + | { event: SITE_EVENTS; data: { siteId: string } } + | { event: SNAPSHOT_EVENTS; data: { snapshotUrl: string } }; + /** - * Emit a site event via the events socket, for the `_events` command server to receive. - * - * @param event - The event topic (e.g., 'site-created', 'site-updated', 'site-deleted') - * @param data - The event data (must include siteId) + * Emit a CLI event via the events socket, for the `_events` command server to receive. */ -export async function emitSiteEvent( - event: SITE_EVENTS, - data: { siteId: string } -): Promise< void > { +export async function emitCliEvent( payload: CliEventPayload ): Promise< void > { try { - await eventsSocketClient.send( { event, data } ); + await eventsSocketClient.send( payload ); } catch { // Do nothing } diff --git a/apps/cli/lib/proxy-server.ts b/apps/cli/lib/proxy-server.ts index fbbeabfdea..3fc99980f7 100644 --- a/apps/cli/lib/proxy-server.ts +++ b/apps/cli/lib/proxy-server.ts @@ -4,7 +4,7 @@ import { createSecureContext } from 'node:tls'; import { domainToASCII } from 'node:url'; import httpProxy from 'http-proxy'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; -import { readCliConfig } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; let httpProxyServer: http.Server | null = null; let httpsProxyServer: https.Server | null = null; diff --git a/apps/cli/lib/site-name.ts b/apps/cli/lib/site-name.ts index 817404a58e..56f83a3f94 100644 --- a/apps/cli/lib/site-name.ts +++ b/apps/cli/lib/site-name.ts @@ -1,5 +1,5 @@ import { generateSiteName as generateSiteNameShared } from '@studio/common/lib/generate-site-name'; -import { readCliConfig } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { STUDIO_SITES_ROOT } from 'cli/lib/site-paths'; export async function generateSiteName(): Promise< string > { diff --git a/apps/cli/lib/site-utils.ts b/apps/cli/lib/site-utils.ts index 8cc3f2baea..143c4b2708 100644 --- a/apps/cli/lib/site-utils.ts +++ b/apps/cli/lib/site-utils.ts @@ -3,7 +3,8 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-a import { __ } from '@wordpress/i18n'; import { openBrowser } from 'cli/lib/browser'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; -import { getSiteUrl, readCliConfig, SiteData } from 'cli/lib/cli-config'; +import { readCliConfig, SiteData } from 'cli/lib/cli-config/core'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; import { isProxyProcessRunning, startProxyProcess, stopProxyProcess } from 'cli/lib/daemon-client'; import { addDomainToHosts } from 'cli/lib/hosts-file'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; diff --git a/apps/cli/lib/snapshots.ts b/apps/cli/lib/snapshots.ts index f4d2753ef5..86ce500d5f 100644 --- a/apps/cli/lib/snapshots.ts +++ b/apps/cli/lib/snapshots.ts @@ -1,119 +1,14 @@ import { HOUR_MS, DAY_MS, DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; import { Snapshot } from '@studio/common/types/snapshot'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { addDays, addHours, DurationUnit, formatDuration, intervalToDuration } from 'date-fns'; -import { - getAuthToken, - readAppdata, - lockAppdata, - unlockAppdata, - saveAppdata, -} from 'cli/lib/appdata'; -import { getSiteByFolder } from 'cli/lib/cli-config'; -import { LoggerError } from 'cli/logger'; -export async function getSnapshotsFromAppdata( - userId: number, - siteFolder?: string -): Promise< Snapshot[] > { - const userData = await readAppdata(); - let snapshots = userData.snapshots; - snapshots = snapshots.filter( ( snapshot ) => snapshot.userId === userId ); - - if ( siteFolder ) { - const site = await getSiteByFolder( siteFolder ); - snapshots = snapshots.filter( ( snapshot ) => snapshot.localSiteId === site.id ); - } - - return snapshots; -} - -export async function updateSnapshotInAppdata( - atomicSiteId: number, - siteFolder: string -): Promise< Snapshot > { - try { - const site = await getSiteByFolder( siteFolder ); - await lockAppdata(); - const userData = await readAppdata(); - const snapshot = userData.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); - if ( ! snapshot ) { - throw new LoggerError( __( 'Failed to find existing preview site in appdata' ) ); - } - - snapshot.localSiteId = site.id; - snapshot.date = Date.now(); - - await saveAppdata( userData ); - return snapshot; - } finally { - await unlockAppdata(); - } -} - -const getNextSequenceNumber = ( siteId: string, snapshots: Snapshot[], userId: number ): number => { - const siteSnapshots = snapshots.filter( - ( s ) => s.localSiteId === siteId && s.userId === userId - ); - - const existingSequences = siteSnapshots - .map( ( s ) => s.sequence ?? 0 ) - .filter( ( n ) => ! isNaN( n ) ); - - return existingSequences.length > 0 - ? Math.max( ...existingSequences ) + 1 - : siteSnapshots.length + 1; -}; - -export async function saveSnapshotToAppdata( - siteFolder: string, - atomicSiteId: number, - previewUrl: string -): Promise< Snapshot > { - try { - const site = await getSiteByFolder( siteFolder ); - await lockAppdata(); - const userData = await readAppdata(); - const authToken = await getAuthToken(); - - const nextSequenceNumber = getNextSequenceNumber( site.id, userData.snapshots, authToken.id ); - const snapshot: Snapshot = { - url: previewUrl, - atomicSiteId, - localSiteId: site.id, - date: Date.now(), - name: sprintf( - /* translators: 1: Site name 2: Sequence number (e.g. "My Site Name Preview 1") */ - __( '%1$s Preview %2$d' ), - site.name, - nextSequenceNumber - ), - sequence: nextSequenceNumber, - userId: authToken.id, - }; - - userData.snapshots.push( snapshot ); - await saveAppdata( userData ); - return snapshot; - } finally { - await unlockAppdata(); - } -} - -export async function deleteSnapshotFromAppdata( snapshotUrl: string ) { - try { - await lockAppdata(); - const userData = await readAppdata(); - const snapshotIndex = userData.snapshots.findIndex( ( s ) => s.url === snapshotUrl ); - if ( snapshotIndex === -1 ) { - return; - } - userData.snapshots.splice( snapshotIndex, 1 ); - await saveAppdata( userData ); - } finally { - await unlockAppdata(); - } -} +export { + getSnapshotsFromConfig, + saveSnapshotToConfig, + updateSnapshotInConfig, + deleteSnapshotFromConfig, +} from 'cli/lib/cli-config/snapshots'; export function isSnapshotExpired( snapshot: Snapshot ) { const now = new Date(); diff --git a/apps/cli/lib/tests/site-utils.test.ts b/apps/cli/lib/tests/site-utils.test.ts index 018f8840de..1b980e07dd 100644 --- a/apps/cli/lib/tests/site-utils.test.ts +++ b/apps/cli/lib/tests/site-utils.test.ts @@ -1,13 +1,13 @@ import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { vi, type Mock } from 'vitest'; -import { SiteData, readCliConfig } from 'cli/lib/cli-config'; +import { SiteData, readCliConfig } from 'cli/lib/cli-config/core'; import { isProxyProcessRunning, stopProxyProcess } from 'cli/lib/daemon-client'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { Logger } from 'cli/logger'; -vi.mock( 'cli/lib/cli-config', async () => { - const actual = await vi.importActual( 'cli/lib/cli-config' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { ...actual, readCliConfig: vi.fn(), diff --git a/apps/cli/lib/tests/snapshots.test.ts b/apps/cli/lib/tests/snapshots.test.ts index 7c824d090b..289a0d6303 100644 --- a/apps/cli/lib/tests/snapshots.test.ts +++ b/apps/cli/lib/tests/snapshots.test.ts @@ -1,10 +1,10 @@ import { writeFile } from 'atomically'; import { vi } from 'vitest'; import { - deleteSnapshotFromAppdata, - getSnapshotsFromAppdata, - saveSnapshotToAppdata, - updateSnapshotInAppdata, + deleteSnapshotFromConfig, + getSnapshotsFromConfig, + saveSnapshotToConfig, + updateSnapshotInConfig, } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; @@ -77,7 +77,7 @@ describe( 'Snapshots Module', () => { mocks.writeFile.mockResolvedValue( undefined ); } ); - describe( 'saveSnapshotToAppdata', () => { + describe( 'saveSnapshotToConfig', () => { it( 'should add a new preview site to appdata with sequence number', async () => { const mockSiteId = 'abc123'; const mockUserData = { @@ -100,7 +100,13 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId, mockSiteUrl ); + await saveSnapshotToConfig( + mockSiteFolder, + mockAtomicSiteId, + mockSiteUrl, + mockUserId, + 'Test Site Preview 1' + ); expect( writeFile ).toHaveBeenCalled(); const savedData = JSON.parse( mocks.writeFile.mock.calls[ 0 ][ 1 ] ); @@ -149,7 +155,13 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId + 1, mockSiteUrl ); + await saveSnapshotToConfig( + mockSiteFolder, + mockAtomicSiteId + 1, + mockSiteUrl, + mockUserId, + 'Test Site Preview 2' + ); expect( writeFile ).toHaveBeenCalled(); const savedData = JSON.parse( mocks.writeFile.mock.calls[ 0 ][ 1 ] ); @@ -189,7 +201,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); await expect( - saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId, mockSiteUrl ) + saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId, 'Test' ) ).rejects.toThrow( LoggerError ); expect( writeFile ).not.toHaveBeenCalled(); @@ -199,12 +211,12 @@ describe( 'Snapshots Module', () => { mocks.existsSync.mockReturnValueOnce( false ); await expect( - saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId, mockSiteUrl ) + saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId, 'Test' ) ).rejects.toThrow( LoggerError ); } ); } ); - describe( 'updateSnapshotInAppdata', () => { + describe( 'updateSnapshotInConfig', () => { it( 'should update the date of an existing snapshot', async () => { const mockSiteId = 'abc123'; const mockUserData = { @@ -233,7 +245,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - const updatedSnapshot = await updateSnapshotInAppdata( mockAtomicSiteId, mockSiteFolder ); + const updatedSnapshot = await updateSnapshotInConfig( mockAtomicSiteId, mockSiteFolder ); expect( writeFile ).toHaveBeenCalled(); const savedData = JSON.parse( mocks.writeFile.mock.calls[ 0 ][ 1 ] ); @@ -251,13 +263,13 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await expect( updateSnapshotInAppdata( mockAtomicSiteId, mockSiteFolder ) ).rejects.toThrow( + await expect( updateSnapshotInConfig( mockAtomicSiteId, mockSiteFolder ) ).rejects.toThrow( LoggerError ); } ); } ); - describe( 'getSnapshotsFromAppdata', () => { + describe( 'getSnapshotsFromConfig', () => { it( 'should return snapshots filtered by userId', async () => { const mockUserData = { version: 1, @@ -285,7 +297,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - const snapshots = await getSnapshotsFromAppdata( 9876 ); + const snapshots = await getSnapshotsFromConfig( 9876 ); expect( snapshots ).toHaveLength( 1 ); expect( snapshots[ 0 ] ).toEqual( mockUserData.snapshots[ 0 ] ); @@ -321,7 +333,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - const snapshots = await getSnapshotsFromAppdata( 9876, mockSiteFolder ); + const snapshots = await getSnapshotsFromConfig( 9876, mockSiteFolder ); expect( snapshots ).toHaveLength( 1 ); expect( snapshots[ 0 ] ).toEqual( mockUserData.snapshots[ 0 ] ); @@ -335,13 +347,13 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - const snapshots = await getSnapshotsFromAppdata( 9876 ); + const snapshots = await getSnapshotsFromConfig( 9876 ); expect( snapshots ).toHaveLength( 0 ); } ); } ); - describe( 'deleteSnapshotFromAppdata', () => { + describe( 'deleteSnapshotFromConfig', () => { it( 'should delete snapshot by url', async () => { const mockUserData = { version: 1, @@ -367,7 +379,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await deleteSnapshotFromAppdata( 'test1.com' ); + await deleteSnapshotFromConfig( 'test1.com' ); expect( writeFile ).toHaveBeenCalled(); const savedData = JSON.parse( mocks.writeFile.mock.calls[ 0 ][ 1 ] ); @@ -392,7 +404,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await deleteSnapshotFromAppdata( 'nonexistent.com' ); + await deleteSnapshotFromConfig( 'nonexistent.com' ); expect( writeFile ).not.toHaveBeenCalled(); } ); @@ -405,7 +417,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await deleteSnapshotFromAppdata( 'test1.com' ); + await deleteSnapshotFromConfig( 'test1.com' ); expect( writeFile ).not.toHaveBeenCalled(); } ); diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index deb10303a2..ee308565c8 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import { vi } from 'vitest'; -import { SiteData } from 'cli/lib/cli-config'; +import { SiteData } from 'cli/lib/cli-config/core'; import * as daemonClient from 'cli/lib/daemon-client'; import { DaemonBus } from 'cli/lib/daemon-client'; import { diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 1d7a9c66c9..879869091f 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -10,9 +10,9 @@ import { PLAYGROUND_CLI_INACTIVITY_TIMEOUT, PLAYGROUND_CLI_MAX_TIMEOUT, } from '@studio/common/constants'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { z } from 'zod'; -import { SiteData } from 'cli/lib/cli-config'; +import { SiteData } from 'cli/lib/cli-config/core'; import { isProcessRunning, startProcess, diff --git a/apps/studio/src/components/tests/header.test.tsx b/apps/studio/src/components/tests/header.test.tsx index 0c4ac0e631..50a586a1d6 100644 --- a/apps/studio/src/components/tests/header.test.tsx +++ b/apps/studio/src/components/tests/header.test.tsx @@ -30,15 +30,15 @@ const mockedSites: SiteDetails[] = [ ]; function mockGetIpcApi( mocks: Record< string, Mock > ) { - vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + vi.mocked( getIpcApi ).mockReturnValue( { getConnectedWpcomSites: vi.fn().mockResolvedValue( [] ), getSiteDetails: vi.fn( () => Promise.resolve( mockedSites ) ), - getSnapshots: vi.fn( () => Promise.resolve( [] ) ), - saveSnapshotsToStorage: vi.fn( () => Promise.resolve() ), + fetchSnapshots: vi.fn( () => Promise.resolve( [] ) ), + setSnapshot: vi.fn(), startServer: vi.fn( () => Promise.resolve() ), showErrorMessageBox: vi.fn(), ...mocks, - } ); + } as unknown as IpcApi ); } const renderWithProvider = ( children: React.ReactElement ) => { diff --git a/apps/studio/src/hooks/use-site-details.tsx b/apps/studio/src/hooks/use-site-details.tsx index d5b834e1d3..bfbf741ffe 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -1,4 +1,4 @@ -import { SITE_EVENTS, SiteEvent } from '@studio/common/lib/site-events'; +import { SITE_EVENTS, SiteEvent } from '@studio/common/lib/cli-events'; import { sortSites } from '@studio/common/lib/sort-sites'; import { __, sprintf } from '@wordpress/i18n'; import { diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 4a15d35ef3..7d68add851 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -34,7 +34,6 @@ 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 { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; -import { Snapshot } from '@studio/common/types/snapshot'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { MACOS_TRAFFIC_LIGHT_POSITION, MAIN_MIN_WIDTH, SIDEBAR_WIDTH } from 'src/constants'; import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; @@ -128,6 +127,8 @@ export { export { createSnapshot, deleteSnapshot, + fetchSnapshots, + setSnapshot, updateSnapshot, } from 'src/modules/preview-site/lib/ipc-handlers'; @@ -767,27 +768,10 @@ export async function exportSite( } } -export async function saveSnapshotsToStorage( event: IpcMainInvokeEvent, snapshots: Snapshot[] ) { - try { - await lockAppdata(); - const userData = await loadUserData(); - userData.snapshots = snapshots; - await saveUserData( userData ); - } finally { - await unlockAppdata(); - } -} - export async function saveLastSeenVersion( event: IpcMainInvokeEvent, version: string ) { await updateAppdata( { lastSeenVersion: version } ); } -export async function getSnapshots( _event: IpcMainInvokeEvent ): Promise< Snapshot[] > { - const userData = await loadUserData(); - const { snapshots = [] } = userData; - return snapshots; -} - export async function getLastSeenVersion( _event: IpcMainInvokeEvent ): Promise< string | undefined > { diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 3b30b915e9..0243dfbcfb 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -1,7 +1,7 @@ import crypto from 'crypto'; import { BrowserWindow } from 'electron'; import { BlueprintValidationWarning } from '@studio/common/lib/blueprint-validation'; -import { SiteEvent } from '@studio/common/lib/site-events'; +import { SiteEvent, SnapshotEvent } from '@studio/common/lib/cli-events'; import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; import { ImportExportEventData } from 'src/lib/import-export/handle-events'; import { StoredToken } from 'src/lib/oauth'; @@ -33,6 +33,7 @@ export interface IpcEvents { 'on-site-create-progress': [ { siteId: string; message: string } ]; 'site-context-menu-action': [ { action: string; siteId: string } ]; 'site-event': [ SiteEvent ]; + 'snapshot-changed': [ SnapshotEvent ]; 'sync-upload-network-paused': [ { error: string; selectedSiteId: string; remoteSiteId: number } ]; 'sync-upload-resumed': [ { selectedSiteId: string; remoteSiteId: number } ]; 'sync-upload-progress': [ { selectedSiteId: string; remoteSiteId: number; progress: number } ]; diff --git a/apps/studio/src/lib/tests/windows-helpers.test.ts b/apps/studio/src/lib/tests/windows-helpers.test.ts index 0c59088a52..380a5d4335 100644 --- a/apps/studio/src/lib/tests/windows-helpers.test.ts +++ b/apps/studio/src/lib/tests/windows-helpers.test.ts @@ -62,7 +62,7 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should return early on non-Windows platforms', async () => { Object.defineProperty( process, 'platform', { value: 'darwin' } ); - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -70,7 +70,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should show prompt on Windows platform', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -81,7 +81,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'version tracking', () => { it( 'should show prompt when no previous response exists', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); @@ -92,7 +92,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should skip prompt when user said "no" to the current version', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], promptWindowsSpeedUpResult: { response: 'no', appVersion: currentVersion, @@ -108,7 +107,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should show prompt again when user said "no" to a previous version', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], promptWindowsSpeedUpResult: { response: 'no', appVersion: '1.2.2', // Previous version @@ -125,7 +123,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should skip prompt when user said "yes" regardless of version', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], promptWindowsSpeedUpResult: { response: 'yes', appVersion: '1.2.2', // Previous version @@ -141,7 +138,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should always show prompt when skipIfAlreadyPrompted is false', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], promptWindowsSpeedUpResult: { response: 'no', appVersion: currentVersion, @@ -160,7 +156,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should handle legacy string format "yes" and skip prompt', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], // @ts-expect-error - Testing legacy string format for backward compatibility promptWindowsSpeedUpResult: 'yes', } ); @@ -173,7 +168,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should handle legacy string format "no" and show prompt', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], // @ts-expect-error - Testing legacy string format for backward compatibility promptWindowsSpeedUpResult: 'no', } ); @@ -187,7 +181,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'user response handling', () => { it( 'should save "yes" response with current app version and dontAskAgain false', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 0, checkboxChecked: false } ); // First button (yes) await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -202,7 +196,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should save "no" response with current app version and dontAskAgain false', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); // Second button (no) await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -219,7 +213,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'dialog content', () => { it( 'should show correct dialog title and message', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -236,7 +230,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should show checkbox when skipIfAlreadyPrompted is true', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); @@ -250,7 +244,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should not show checkbox when skipIfAlreadyPrompted is false', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -268,7 +262,6 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should skip prompt when dontAskAgain is true regardless of version', async () => { mockLoadUserData.mockResolvedValue( { sites: [], - snapshots: [], promptWindowsSpeedUpResult: { response: 'no', appVersion: '1.2.2', // Previous version @@ -282,7 +275,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should save dontAskAgain true when checkbox is checked with "yes" response', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 0, checkboxChecked: true } ); // First button (yes) with checkbox await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); @@ -297,7 +290,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should save dontAskAgain true when checkbox is checked with "no" response', async () => { - mockLoadUserData.mockResolvedValue( { sites: [], snapshots: [] } ); + mockLoadUserData.mockResolvedValue( { sites: [] } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: true } ); // Second button (no) with checkbox await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index be882b6a3a..c944730b11 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -1,11 +1,11 @@ -import { sequential } from '@studio/common/lib/sequential'; import { - siteEventSchema, + cliSiteEventSchema, + cliSnapshotEventSchema, SiteEvent, SITE_EVENTS, SiteDetails, -} from '@studio/common/lib/site-events'; -import { z } from 'zod'; +} from '@studio/common/lib/cli-events'; +import { sequential } from '@studio/common/lib/sequential'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; import { SiteServer } from 'src/site-server'; @@ -62,15 +62,6 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > void sendIpcEventToRenderer( 'site-event', event ); } ); -const cliSiteEventSchema = z.object( { - action: z.literal( 'keyValuePair' ), - key: z.literal( 'site-event' ), - value: z - .string() - .transform( ( val ) => JSON.parse( val ) ) - .pipe( siteEventSchema ), -} ); - let subscriber: ReturnType< typeof executeCliCommand > | null = null; export async function startCliEventsSubscriber(): Promise< void > { @@ -90,6 +81,12 @@ export async function startCliEventsSubscriber(): Promise< void > { } ); eventEmitter.on( 'data', ( { data } ) => { + const snapshotParsed = cliSnapshotEventSchema.safeParse( data ); + if ( snapshotParsed.success ) { + void sendIpcEventToRenderer( 'snapshot-changed', snapshotParsed.data.value ); + return; + } + const parsed = cliSiteEventSchema.safeParse( data ); if ( ! parsed.success ) { return; diff --git a/apps/studio/src/modules/preview-site/components/tests/preview-action-buttons-menu.test.tsx b/apps/studio/src/modules/preview-site/components/tests/preview-action-buttons-menu.test.tsx index 98744405f2..5a73895ff8 100644 --- a/apps/studio/src/modules/preview-site/components/tests/preview-action-buttons-menu.test.tsx +++ b/apps/studio/src/modules/preview-site/components/tests/preview-action-buttons-menu.test.tsx @@ -11,7 +11,7 @@ import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; vi.mock( 'src/lib/get-ipc-api', () => ( { getIpcApi: () => ( { - saveSnapshotsToStorage: vi.fn( () => Promise.resolve() ), + setSnapshot: vi.fn( () => Promise.resolve() ), } ), } ) ); diff --git a/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts b/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts index f613e87086..c887336cc0 100644 --- a/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts +++ b/apps/studio/src/modules/preview-site/lib/ipc-handlers.ts @@ -1,9 +1,54 @@ import { BrowserWindow, IpcMainInvokeEvent } from 'electron'; +import { snapshotSchema } from '@studio/common/types/snapshot'; +import { z } from 'zod'; +import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; import { executePreviewCliCommand } from 'src/modules/cli/lib/execute-preview-command'; +import type { Snapshot } from '@studio/common/types/snapshot'; -export async function createSnapshot( event: IpcMainInvokeEvent, siteFolder: string ) { +const snapshotListKeyValueSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'snapshots' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) ) + .pipe( z.array( snapshotSchema ) ), +} ); + +export async function fetchSnapshots(): Promise< Snapshot[] > { + try { + return await new Promise< Snapshot[] >( ( resolve, reject ) => { + const [ emitter ] = executeCliCommand( [ 'preview', 'list', '--format', 'json' ], { + output: 'capture', + } ); + + emitter.on( 'data', ( { data } ) => { + const parsed = snapshotListKeyValueSchema.safeParse( data ); + if ( parsed.success ) { + resolve( parsed.data.value ); + } + } ); + + emitter.on( 'success', () => resolve( [] ) ); + emitter.on( 'failure', ( { error } ) => reject( error ) ); + emitter.on( 'error', ( { error } ) => reject( error ) ); + } ); + } catch ( error ) { + console.error( 'Failed to fetch snapshots from CLI:', error ); + return []; + } +} + +export async function createSnapshot( + event: IpcMainInvokeEvent, + siteFolder: string, + name?: string +) { const parentWindow = BrowserWindow.fromWebContents( event.sender ); - return executePreviewCliCommand( [ 'preview', 'create', '--path', siteFolder ], parentWindow ); + const args = [ 'preview', 'create', '--path', siteFolder ]; + if ( name ) { + args.push( '--name', name ); + } + return executePreviewCliCommand( args, parentWindow ); } export async function updateSnapshot( @@ -22,3 +67,16 @@ export async function deleteSnapshot( event: IpcMainInvokeEvent, hostname: strin const parentWindow = BrowserWindow.fromWebContents( event.sender ); return executePreviewCliCommand( [ 'preview', 'delete', hostname ], parentWindow ); } + +export async function setSnapshot( + event: IpcMainInvokeEvent, + hostname: string, + options: { name?: string } +) { + const parentWindow = BrowserWindow.fromWebContents( event.sender ); + const args = [ 'preview', 'set', hostname ]; + if ( options.name !== undefined ) { + args.push( '--name', options.name ); + } + return executePreviewCliCommand( args, parentWindow ); +} diff --git a/apps/studio/src/modules/user-settings/components/user-settings.tsx b/apps/studio/src/modules/user-settings/components/user-settings.tsx index 7a6cf6108c..ca53ebd6be 100644 --- a/apps/studio/src/modules/user-settings/components/user-settings.tsx +++ b/apps/studio/src/modules/user-settings/components/user-settings.tsx @@ -60,7 +60,6 @@ export default function UserSettings() { if ( response === DELETE_BUTTON_INDEX ) { try { await deleteAllSnapshots().unwrap(); - await getIpcApi().saveSnapshotsToStorage( [] ); } catch ( error ) { await getIpcApi().showMessageBox( { type: 'warning', diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 279caeda86..90cc01595f 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -55,12 +55,12 @@ const api: IpcApi = { isAuthenticated: () => ipcRendererInvoke( 'isAuthenticated' ), getAuthenticationToken: () => ipcRendererInvoke( 'getAuthenticationToken' ), clearAuthenticationToken: () => ipcRendererInvoke( 'clearAuthenticationToken' ), - saveSnapshotsToStorage: ( snapshots ) => ipcRendererInvoke( 'saveSnapshotsToStorage', snapshots ), - getSnapshots: () => ipcRendererInvoke( 'getSnapshots' ), - createSnapshot: ( siteFolder ) => ipcRendererInvoke( 'createSnapshot', siteFolder ), + fetchSnapshots: () => ipcRendererInvoke( 'fetchSnapshots' ), + createSnapshot: ( siteFolder, name ) => ipcRendererInvoke( 'createSnapshot', siteFolder, name ), updateSnapshot: ( siteFolder, hostname ) => ipcRendererInvoke( 'updateSnapshot', siteFolder, hostname ), deleteSnapshot: ( hostname ) => ipcRendererInvoke( 'deleteSnapshot', hostname ), + setSnapshot: ( hostname, options ) => ipcRendererInvoke( 'setSnapshot', hostname, options ), getLastSeenVersion: () => ipcRendererInvoke( 'getLastSeenVersion' ), saveLastSeenVersion: ( version ) => ipcRendererInvoke( 'saveLastSeenVersion', version ), getSiteDetails: () => ipcRendererInvoke( 'getSiteDetails' ), diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index e46b9d8523..a2e098639d 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -2,8 +2,8 @@ import fs from 'fs'; import nodePath from 'path'; import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; +import { siteListSchema, type SiteListItem } from '@studio/common/lib/cli-events'; import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; -import { siteListSchema, type SiteListItem } from '@studio/common/lib/site-events'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index c7d53e2299..e1aa47d2bb 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -1,4 +1,3 @@ -import { Snapshot } from '@studio/common/types/snapshot'; import { StatsMetric } from 'src/lib/bump-stats'; import { StoredToken } from 'src/lib/oauth'; import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; @@ -15,7 +14,6 @@ export interface WindowBounds { export interface UserData { sites: SiteDetails[]; - snapshots: Snapshot[]; devToolsOpen?: boolean; windowBounds?: WindowBounds; authToken?: StoredToken; diff --git a/apps/studio/src/storage/tests/user-data.test.ts b/apps/studio/src/storage/tests/user-data.test.ts index d684fe4551..5288b5c32e 100644 --- a/apps/studio/src/storage/tests/user-data.test.ts +++ b/apps/studio/src/storage/tests/user-data.test.ts @@ -57,7 +57,6 @@ vi.mock( 'atomically', () => ( { { name: 'Arthur', path: '/to/arthur' }, { name: 'Lancelot', path: '/to/lancelot' }, ], - snapshots: [], } ) ), writeFile: vi.fn(), @@ -69,7 +68,6 @@ const mockedUserData: RecursivePartial< UserData > = { { name: 'Arthur', path: '/to/arthur' }, { name: 'Lancelot', path: '/to/lancelot' }, ], - snapshots: [], }; const defaultThemeDetails = { @@ -117,7 +115,6 @@ platformTestSuite( 'User data', () => { { name: 'Lancelot', path: '/to/lancelot', phpVersion: '8.1' }, { name: 'Tristan', path: '/to/tristan' }, ], - snapshots: [], } ) ) ); @@ -144,7 +141,6 @@ platformTestSuite( 'User data', () => { ...site, themeDetails: defaultThemeDetails, } ) ), - snapshots: [], }, null, 2 diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index 6ca0ca6985..4c0162b9a9 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -78,19 +78,6 @@ function populatePhpVersion( sites: SiteDetails[] ) { } ); } -function legacyPopulateSnapshotUserIds( data: UserData ): void { - const userId = data.authToken?.id; - - if ( userId && data.snapshots ) { - data.snapshots = data.snapshots.map( ( snapshot ) => { - if ( ! snapshot.userId ) { - return { ...snapshot, userId }; - } - return snapshot; - } ); - } -} - export async function loadUserData(): Promise< UserData > { migrateUserDataOldName(); const filePath = getUserDataFilePath(); @@ -101,9 +88,6 @@ export async function loadUserData(): Promise< UserData > { const parsed = JSON.parse( asString ); const data = fromDiskFormat( parsed ); - // Temporarily populate old snapshots with userId of authenticated user. - // See PR #937 for more context. - legacyPopulateSnapshotUserIds( data ); sortSites( data.sites ); populatePhpVersion( data.sites ); return data; @@ -123,7 +107,6 @@ export async function loadUserData(): Promise< UserData > { if ( isErrnoException( err ) && err.code === 'ENOENT' ) { return { sites: [], - snapshots: [], }; } console.error( `Failed to load file ${ sanitizeUserpath( filePath ) }: ${ err }` ); @@ -151,7 +134,6 @@ type UserDataSafeKeys = | 'devToolsOpen' | 'windowBounds' | 'authToken' - | 'snapshots' | 'onboardingCompleted' | 'locale' | 'promptWindowsSpeedUpResult' diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index eed7e4a25b..096ecaf1f6 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -1,9 +1,4 @@ -import { - combineReducers, - configureStore, - createListenerMiddleware, - isAnyOf, -} from '@reduxjs/toolkit'; +import { combineReducers, configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { setupListeners } from '@reduxjs/toolkit/query'; import { useDispatch, useSelector } from 'react-redux'; import { LOCAL_STORAGE_CHAT_API_IDS_KEY, LOCAL_STORAGE_CHAT_MESSAGES_KEY } from 'src/constants'; @@ -22,8 +17,8 @@ import { installedAppsApi } from 'src/stores/installed-apps-api'; import onboardingReducer from 'src/stores/onboarding-slice'; import { reducer as snapshotReducer, + refreshSnapshots, updateSnapshotLocally, - snapshotActions, } from 'src/stores/snapshot-slice'; import { syncReducer, syncOperationsActions } from 'src/stores/sync'; import { connectedSitesApi, connectedSitesReducer } from 'src/stores/sync/connected-sites'; @@ -89,12 +84,17 @@ startAppListening( { }, } ); -// Save snapshots to user config +// Save snapshot changes to CLI config via preview set command startAppListening( { - matcher: isAnyOf( updateSnapshotLocally, snapshotActions.deleteSnapshotLocally ), + actionCreator: updateSnapshotLocally, async effect( action, listenerApi ) { - const state = listenerApi.getState(); - await getIpcApi().saveSnapshotsToStorage( state.snapshot.snapshots ); + const { atomicSiteId, snapshot } = action.payload; + const previous = listenerApi + .getOriginalState() + .snapshot.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); + if ( previous?.url && snapshot.name !== undefined && snapshot.name !== previous.name ) { + await getIpcApi().setSnapshot( previous.url, { name: snapshot.name } ); + } }, } ); @@ -358,9 +358,10 @@ export const store = configureStore( { // Enable the refetchOnFocus behavior setupListeners( store.dispatch ); -// Initialize beta features on store initialization, but skip in test environment +// Initialize beta features and fetch snapshots on store initialization, but skip in test environment if ( process.env.NODE_ENV !== 'test' ) { void store.dispatch( loadBetaFeatures() ); + void refreshSnapshots(); } export type AppDispatch = typeof store.dispatch; diff --git a/apps/studio/src/stores/snapshot-slice.ts b/apps/studio/src/stores/snapshot-slice.ts index 715e802644..b06a154193 100644 --- a/apps/studio/src/stores/snapshot-slice.ts +++ b/apps/studio/src/stores/snapshot-slice.ts @@ -6,10 +6,10 @@ import { isAnyOf, PayloadAction, } from '@reduxjs/toolkit'; +import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; import { Snapshot } from '@studio/common/types/snapshot'; import { __, sprintf } from '@wordpress/i18n'; -import fastDeepEqual from 'fast-deep-equal'; import { LIMIT_OF_ZIP_SITES_PER_USER } from 'src/constants'; import { getIpcApi } from 'src/lib/get-ipc-api'; import { RootState, store } from 'src/stores/index'; @@ -145,6 +145,9 @@ const snapshotSlice = createSlice( { ( snapshot ) => snapshot.atomicSiteId !== action.payload.atomicSiteId ); }, + addSnapshot: ( state, action: PayloadAction< { snapshot: Snapshot } > ) => { + state.snapshots.push( action.payload.snapshot ); + }, setSnapshots: ( state, action: PayloadAction< { snapshots: Snapshot[] } > ) => { state.snapshots = action.payload.snapshots; }, @@ -295,30 +298,35 @@ const selectSnapshotsBySiteAndUser = createSelector( ) ); -window.ipcListener.subscribe( 'user-data-updated', ( _, payload ) => { - const state = store.getState(); - const snapshots = payload.snapshots; +// Optimistically update the snapshot usage count and schedule a re-fetch. +// There's a risk that more sites are deleted locally than the count returned by the +// API, because expired sites are preserved locally. Therefore, we need to ensure +// the count is non-negative. +function updateSnapshotUsageCount( countDiff: number ) { + if ( countDiff === 0 ) { + return; + } - if ( ! fastDeepEqual( state.snapshot.snapshots, snapshots ) ) { - store.dispatch( snapshotSlice.actions.setSnapshots( { snapshots } ) ); + store.dispatch( + wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { + data.siteCount = Math.max( 0, data.siteCount + countDiff ); + } ) + ); - // Optimistically update the snapshot usage count - const countDiff = snapshots.length - state.snapshot.snapshots.length; - store.dispatch( - wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { - // There's a risk that more sites are deleted locally than the count returned by the - // API, because expired sites are preserved locally. Therefore, we need to ensure - // the count is non-negative. - data.siteCount = Math.max( 0, data.siteCount + countDiff ); - } ) - ); + // Wait for changes to take effect on the back-end before invalidating the query + setTimeout( () => { + store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); + }, 8000 ); +} - // Wait for changes to take effect on the back-end before invalidating the query - setTimeout( () => { - store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); - }, 8000 ); - } -} ); +export async function refreshSnapshots() { + const snapshots = await getIpcApi().fetchSnapshots(); + const state = store.getState(); + const countDiff = snapshots.length - state.snapshot.snapshots.length; + + store.dispatch( snapshotSlice.actions.setSnapshots( { snapshots } ) ); + updateSnapshotUsageCount( countDiff ); +} function getOperationProgress( action: PreviewCommandLoggerAction ): [ string, number ] { switch ( action ) { @@ -363,6 +371,47 @@ function isBulkOperationSettled( bulkOperation: BulkOperation ) { } ); } +window.ipcListener.subscribe( 'snapshot-changed', ( _, snapshotEvent ) => { + const { event: eventType, snapshot, snapshotUrl } = snapshotEvent; + + if ( eventType === SNAPSHOT_EVENTS.DELETED ) { + const state = store.getState(); + const existing = state.snapshot.snapshots.find( ( s ) => s.url === snapshotUrl ); + if ( existing ) { + store.dispatch( + snapshotSlice.actions.deleteSnapshotLocally( { atomicSiteId: existing.atomicSiteId } ) + ); + updateSnapshotUsageCount( -1 ); + } + return; + } + + if ( ! snapshot ) { + // Fallback to full refresh if no snapshot data included + void refreshSnapshots(); + return; + } + + if ( eventType === SNAPSHOT_EVENTS.CREATED ) { + const state = store.getState(); + const existing = state.snapshot.snapshots.find( ( s ) => s.url === snapshot.url ); + if ( ! existing ) { + store.dispatch( snapshotSlice.actions.addSnapshot( { snapshot } ) ); + updateSnapshotUsageCount( 1 ); + } + return; + } + + if ( eventType === SNAPSHOT_EVENTS.UPDATED ) { + store.dispatch( + snapshotActions.updateSnapshotLocally( { + atomicSiteId: snapshot.atomicSiteId, + snapshot, + } ) + ); + } +} ); + window.ipcListener.subscribe( 'snapshot-output', ( event, payload ) => { const operation = getOperation( payload.operationId ); if ( ! operation ) { diff --git a/apps/studio/src/stores/tests/snapshot-slice.test.ts b/apps/studio/src/stores/tests/snapshot-slice.test.ts index 1d4fd5af7a..ea99d460d4 100644 --- a/apps/studio/src/stores/tests/snapshot-slice.test.ts +++ b/apps/studio/src/stores/tests/snapshot-slice.test.ts @@ -14,14 +14,14 @@ import { } from 'src/stores/snapshot-slice'; import { testActions, testReducer } from 'src/stores/tests/utils/test-reducer'; -const mockGetSnapshots = vi.fn(); -const mockCreateSnapshot = vi.fn(); - -vi.mock( 'src/lib/get-ipc-api' ); -vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { - getSnapshots: mockGetSnapshots, - createSnapshot: mockCreateSnapshot, -} ); +vi.mock( 'src/lib/get-ipc-api', () => ( { + getIpcApi: vi.fn().mockReturnValue( { + fetchSnapshots: vi.fn().mockResolvedValue( [] ), + createSnapshot: vi.fn(), + } ), +} ) ); + +const mockCreateSnapshot = vi.mocked( getIpcApi )().createSnapshot as ReturnType< typeof vi.fn >; function snapshotTestReducer( state: RootState | undefined, action: UnknownAction ) { if ( action.type === 'snapshot/addOperation' ) { diff --git a/package-lock.json b/package-lock.json index 26d06253b1..4762ea0337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -996,6 +996,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2729,6 +2730,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2750,6 +2752,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -4204,6 +4207,7 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -5601,6 +5605,7 @@ "node_modules/@inquirer/prompts": { "version": "7.10.1", "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^4.3.2", "@inquirer/confirm": "^5.1.21", @@ -6630,6 +6635,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -6971,6 +6977,7 @@ "node_modules/@opentelemetry/api": { "version": "1.9.0", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -6992,6 +6999,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.6.0.tgz", "integrity": "sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -7004,6 +7012,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.0.tgz", "integrity": "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -7427,6 +7436,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.0.tgz", "integrity": "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -7443,6 +7453,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.0.tgz", "integrity": "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", @@ -7460,6 +7471,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.40.0.tgz", "integrity": "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -9164,6 +9176,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.24" @@ -9549,8 +9562,7 @@ "node_modules/@types/aria-query": { "version": "5.0.4", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/aws-lambda": { "version": "8.10.161", @@ -9818,6 +9830,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -9863,6 +9876,7 @@ "node_modules/@types/react": { "version": "18.3.27", "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -9994,6 +10008,7 @@ "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.1", "@typescript-eslint/types": "8.57.1", @@ -10182,6 +10197,7 @@ "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.1", @@ -10641,6 +10657,7 @@ "integrity": "sha512-sTSDtVM1GOevRGsCNhp1mBUHKo9Qlc55+HCreFT4fe99AHxl1QQNXSL3uj4Pkjh5yEuWZIx8E2tVC94nnBZECQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.1.0", "fflate": "^0.8.2", @@ -12095,6 +12112,7 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -12809,6 +12827,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -14187,8 +14206,7 @@ "node_modules/dom-accessibility-api": { "version": "0.5.16", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dot-case": { "version": "3.0.4", @@ -15418,6 +15436,7 @@ "node_modules/eslint": { "version": "9.39.2", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -15478,6 +15497,7 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -15547,6 +15567,7 @@ "integrity": "sha512-J5gx7sN6DTm0LRT//eP3rVVQ2Yi4hrX0B+DbWxa5er8PZ6JjLo9GUBwogIFvEDdwJaSqZplpQT+haK/cXhb7VQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/types": "^8.34.0", "comment-parser": "^1.4.1", @@ -16030,6 +16051,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.0.tgz", "integrity": "sha512-c2iPh3xp5vvCLgaHK03+mWLFPhox7j1LwyxcZwFVApEv5i0X+IjPpbT50SJJwwLpdBVfp45AkK/v+AFgv/XlfQ==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -17345,6 +17367,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -18937,7 +18960,6 @@ "version": "1.5.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -21286,6 +21308,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -21555,6 +21578,7 @@ "version": "3.0.3", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -21582,7 +21606,6 @@ "version": "27.5.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -21596,7 +21619,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -21607,8 +21629,7 @@ "node_modules/pretty-format/node_modules/react-is": { "version": "17.0.2", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/proc-log": { "version": "2.0.1", @@ -21843,6 +21864,7 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -21888,6 +21910,7 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -21932,6 +21955,7 @@ "node_modules/react-redux": { "version": "9.2.0", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -22148,7 +22172,8 @@ }, "node_modules/redux": { "version": "5.0.1", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -22605,6 +22630,7 @@ "version": "4.50.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -22799,6 +22825,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24172,6 +24199,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -24400,6 +24428,7 @@ "node_modules/ts-node": { "version": "10.9.2", "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -24521,6 +24550,7 @@ "node_modules/typescript": { "version": "5.9.3", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -25018,6 +25048,7 @@ "version": "7.3.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -25653,6 +25684,7 @@ "version": "4.0.3", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -25666,6 +25698,7 @@ "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", @@ -25825,6 +25858,7 @@ "version": "5.104.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -26376,6 +26410,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -26534,6 +26569,7 @@ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.18.0" } diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts new file mode 100644 index 0000000000..124a7d1fae --- /dev/null +++ b/tools/common/lib/cli-events.ts @@ -0,0 +1,107 @@ +/** + * Shared types for CLI events between CLI and Studio app. + * + * The CLI emits these events via the `_events` command, and Studio + * subscribes to them to maintain its state without reading config files. + */ +import { z } from 'zod'; +import { snapshotSchema } from '@studio/common/types/snapshot'; + +/** + * Site data included in events. This is the data Studio needs to display sites. + */ +export const siteDetailsSchema = z.object( { + id: z.string(), + name: z.string(), + path: z.string(), + port: z.number(), + url: z.string(), + phpVersion: z.string(), + customDomain: z.string().optional(), + enableHttps: z.boolean().optional(), + adminUsername: z.string().optional(), + adminPassword: z.string().optional(), + adminEmail: z.string().optional(), + isWpAutoUpdating: z.boolean().optional(), + autoStart: z.boolean().optional(), + enableXdebug: z.boolean().optional(), + enableDebugLog: z.boolean().optional(), + enableDebugDisplay: z.boolean().optional(), +} ); + +export type SiteDetails = z.infer< typeof siteDetailsSchema >; + +export const siteListItemSchema = siteDetailsSchema.extend( { + running: z.boolean(), +} ); + +export type SiteListItem = z.infer< typeof siteListItemSchema >; + +export const siteListSchema = z.array( siteListItemSchema ); + +export enum SITE_EVENTS { + CREATED = 'site-created', + UPDATED = 'site-updated', + DELETED = 'site-deleted', +} + +export enum SNAPSHOT_EVENTS { + CREATED = 'snapshot-created', + UPDATED = 'snapshot-updated', + DELETED = 'snapshot-deleted', +} + +export const siteEventSchema = z.object( { + event: z.enum( SITE_EVENTS ), + siteId: z.string(), + site: siteDetailsSchema.optional(), + running: z.boolean(), +} ); + +export type SiteEvent = z.infer< typeof siteEventSchema >; + +export const snapshotEventSchema = z.object( { + event: z.enum( SNAPSHOT_EVENTS ), + snapshot: snapshotSchema.optional(), + snapshotUrl: z.string(), +} ); + +export type SnapshotEvent = z.infer< typeof snapshotEventSchema >; + +/** + * Socket-level schemas for events sent between daemon-client and the _events command. + */ +export const siteSocketEventSchema = z.object( { + event: z.string(), + data: z.object( { + siteId: z.string(), + } ), +} ); + +export const snapshotSocketEventSchema = z.object( { + event: z.enum( SNAPSHOT_EVENTS ), + data: z.object( { + snapshotUrl: z.string(), + } ), +} ); + +/** + * CLI stdout key-value pair schemas for events parsed by Studio's cli-events-subscriber. + */ +export const cliSiteEventSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'site-event' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) ) + .pipe( siteEventSchema ), +} ); + +export const cliSnapshotEventSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'snapshot-event' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) ) + .pipe( snapshotEventSchema ), +} ); diff --git a/tools/common/lib/site-events.ts b/tools/common/lib/site-events.ts deleted file mode 100644 index b5ae1a2bb4..0000000000 --- a/tools/common/lib/site-events.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Shared types for site events between CLI and Studio app. - * - * The CLI emits these events via the `_events` command, and Studio - * subscribes to them to maintain its site state without reading appdata. - */ -import { z } from 'zod'; - -/** - * Site data included in events. This is the data Studio needs to display sites. - */ -export const siteDetailsSchema = z.object( { - id: z.string(), - name: z.string(), - path: z.string(), - port: z.number(), - url: z.string(), - phpVersion: z.string(), - customDomain: z.string().optional(), - enableHttps: z.boolean().optional(), - adminUsername: z.string().optional(), - adminPassword: z.string().optional(), - adminEmail: z.string().optional(), - isWpAutoUpdating: z.boolean().optional(), - autoStart: z.boolean().optional(), - enableXdebug: z.boolean().optional(), - enableDebugLog: z.boolean().optional(), - enableDebugDisplay: z.boolean().optional(), -} ); - -export type SiteDetails = z.infer< typeof siteDetailsSchema >; - -export const siteListItemSchema = siteDetailsSchema.extend( { - running: z.boolean(), -} ); - -export type SiteListItem = z.infer< typeof siteListItemSchema >; - -export const siteListSchema = z.array( siteListItemSchema ); - -export enum SITE_EVENTS { - CREATED = 'site-created', - UPDATED = 'site-updated', - DELETED = 'site-deleted', -} - -export const siteEventSchema = z.object( { - event: z.enum( SITE_EVENTS ), - siteId: z.string(), - site: siteDetailsSchema.optional(), - running: z.boolean(), -} ); - -export type SiteEvent = z.infer< typeof siteEventSchema >; diff --git a/tools/common/logger-actions.ts b/tools/common/logger-actions.ts index c2e6868d20..0f0ea39db6 100644 --- a/tools/common/logger-actions.ts +++ b/tools/common/logger-actions.ts @@ -15,6 +15,7 @@ export enum PreviewCommandLoggerAction { UPLOAD = 'upload', READY = 'ready', APPDATA = 'appdata', + SET = 'set', } export enum SiteCommandLoggerAction { From 1081ea78f93a8d6ef17694bcfeebcb441611013d Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Wed, 18 Mar 2026 21:50:20 +0000 Subject: [PATCH 05/11] Wire auth and locale to shared.json, AI settings to cli.json (#2821) * Wire auth and locale to shared.json, AI settings to cli.json Move auth token reads/writes from appdata to shared-config for Desktop+CLI. Move locale reads/writes to shared-config (Desktop saves, CLI reads). Move aiProvider and anthropicApiKey from shared-config to cli-config (CLI-only). Remove auth and locale fields from appdata schema and types since they're now in separate files. Update all imports and tests accordingly. Co-Authored-By: Claude Haiku 4.5 * Move CLI telemetry stats from appdata to cli-config * Fix import order lint errors * Remove CLI appdata dependency and move infrastructure paths to ~/.studio * Revert server-files and certificate paths to appdata directory * Watch shared config for auth changes from CLI * Align shared config watcher with user-data watcher pattern * trigger ci * Address PR review feedback: events-based auth, rename types, deduplicate schemas * Fix prettier formatting in auth test * Fix shared-config mock in tests to export authTokenSchema * Use z.literal for config version validation, remove unused re-export, update test mocks * Extract authTokenSchema to avoid Node.js imports in renderer bundle * trigger ci * Fix daemon test to use os.tmpdir() for Windows compatibility * Make CLI event emission best-effort in auth commands * Throw on shared config version mismatch instead of silently returning defaults * Add line break before version mismatch error message * Handle version mismatch errors through standard Logger flow in auth commands * Show only display name in AI chat status bar --------- Co-authored-by: Claude Haiku 4.5 --- apps/cli/ai/auth.ts | 6 +- apps/cli/ai/providers.ts | 29 +- apps/cli/ai/tests/auth.test.ts | 103 ++++--- apps/cli/ai/ui.ts | 8 +- apps/cli/commands/_events.ts | 14 + apps/cli/commands/ai.ts | 20 +- apps/cli/commands/auth/login.ts | 55 ++-- apps/cli/commands/auth/logout.ts | 41 ++- apps/cli/commands/auth/status.ts | 15 +- apps/cli/commands/auth/tests/login.test.ts | 62 ++--- apps/cli/commands/auth/tests/logout.test.ts | 80 ++---- apps/cli/commands/auth/tests/status.test.ts | 12 +- apps/cli/commands/preview/create.ts | 9 +- apps/cli/commands/preview/delete.ts | 9 +- apps/cli/commands/preview/list.ts | 11 +- .../cli/commands/preview/tests/create.test.ts | 17 +- .../cli/commands/preview/tests/delete.test.ts | 24 +- apps/cli/commands/preview/tests/list.test.ts | 18 +- .../cli/commands/preview/tests/update.test.ts | 22 +- apps/cli/commands/preview/update.ts | 9 +- apps/cli/commands/site/delete.ts | 10 +- apps/cli/commands/site/tests/create.test.ts | 1 + apps/cli/commands/site/tests/delete.test.ts | 19 +- apps/cli/commands/site/tests/list.test.ts | 4 +- apps/cli/commands/site/tests/set.test.ts | 2 +- apps/cli/lib/appdata.ts | 183 ------------- apps/cli/lib/bump-stat.ts | 19 +- apps/cli/lib/certificate-manager.ts | 3 +- apps/cli/lib/cli-config/core.ts | 39 ++- apps/cli/lib/daemon-client.ts | 10 +- apps/cli/lib/i18n.ts | 13 +- apps/cli/lib/server-files.ts | 20 +- apps/cli/lib/tests/appdata.test.ts | 238 ---------------- apps/cli/tests/daemon.test.ts | 10 +- apps/studio/src/components/auth-provider.tsx | 9 + apps/studio/src/ipc-handlers.ts | 3 +- apps/studio/src/ipc-utils.ts | 4 +- apps/studio/src/lib/deeplink/handlers/auth.ts | 17 +- apps/studio/src/lib/locale-node.ts | 4 +- apps/studio/src/lib/oauth.ts | 32 +-- apps/studio/src/lib/tests/oauth.test.ts | 32 +-- .../modules/cli/lib/cli-events-subscriber.ts | 13 + .../src/modules/sync/lib/ipc-handlers.ts | 22 +- .../modules/user-settings/lib/ipc-handlers.ts | 3 +- apps/studio/src/storage/storage-types.ts | 3 - apps/studio/src/storage/user-data.ts | 2 - tools/common/constants.ts | 1 + tools/common/lib/auth-token-schema.ts | 12 + tools/common/lib/cli-events.ts | 29 ++ tools/common/lib/shared-config.ts | 142 ++++++++++ tools/common/lib/tests/shared-config.test.ts | 258 ++++++++++++++++++ 51 files changed, 879 insertions(+), 842 deletions(-) delete mode 100644 apps/cli/lib/appdata.ts delete mode 100644 apps/cli/lib/tests/appdata.test.ts create mode 100644 tools/common/lib/auth-token-schema.ts create mode 100644 tools/common/lib/shared-config.ts create mode 100644 tools/common/lib/tests/shared-config.test.ts diff --git a/apps/cli/ai/auth.ts b/apps/cli/ai/auth.ts index feaa885610..f62c634741 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, updateCliConfigWithPartial } 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 updateCliConfigWithPartial( { aiProvider: provider } ); } export async function prepareAiProvider( diff --git a/apps/cli/ai/providers.ts b/apps/cli/ai/providers.ts index c36001d1f6..42cb84dd77 100644 --- a/apps/cli/ai/providers.ts +++ b/apps/cli/ai/providers.ts @@ -1,8 +1,8 @@ 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, updateCliConfigWithPartial } from 'cli/lib/cli-config/core'; import { LoggerError } from 'cli/logger'; export const AI_PROVIDERS = { @@ -13,7 +13,6 @@ export const AI_PROVIDERS = { export type AiProviderId = keyof typeof AI_PROVIDERS; -export const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-key' ] ); export const DEFAULT_AI_PROVIDER: AiProviderId = 'anthropic-api-key'; export const AI_PROVIDER_PRIORITY: AiProviderId[] = [ 'wpcom', @@ -51,7 +50,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 +66,7 @@ async function resolveAnthropicApiKey( options?: { }, } ); - await saveAnthropicApiKey( apiKey ); + await updateCliConfigWithPartial( { anthropicApiKey: apiKey } ); return apiKey; } @@ -83,12 +82,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 +112,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 +154,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..ceafa2c196 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, updateCliConfigWithPartial } 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: [] } ), + updateCliConfigWithPartial: 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( updateCliConfigWithPartial ).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,30 @@ 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( updateCliConfigWithPartial ).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( updateCliConfigWithPartial ).toHaveBeenCalledWith( { anthropicApiKey: 'updated-key' } ); } ); it( 'lists Claude auth only when it is available', async () => { @@ -106,7 +116,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 +137,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 +165,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 +192,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 +202,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 +221,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 973e33e10e..e40440e56b 100644 --- a/apps/cli/ai/ui.ts +++ b/apps/cli/ai/ui.ts @@ -20,6 +20,7 @@ import { type MarkdownTheme, 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'; @@ -27,7 +28,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'; @@ -823,10 +823,8 @@ export class AiChatUI { this.sitePickerRemoteItems = []; this.renderSitePicker(); - 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/_events.ts b/apps/cli/commands/_events.ts index 02ee934a9b..5fee1a7fa7 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -7,13 +7,16 @@ */ import { + AUTH_EVENTS, SITE_EVENTS, SNAPSHOT_EVENTS, siteDetailsSchema, siteSocketEventSchema, snapshotSocketEventSchema, + authSocketEventSchema, SiteEvent, SnapshotEvent, + AuthEvent, } from '@studio/common/lib/cli-events'; import { sequential } from '@studio/common/lib/sequential'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; @@ -76,6 +79,11 @@ async function emitAllSitesStopped(): Promise< void > { } } +function emitAuthEvent( event: AUTH_EVENTS, token?: AuthEvent[ 'token' ] ): void { + const payload: AuthEvent = { event, token }; + logger.reportKeyValuePair( 'auth-event', JSON.stringify( payload ) ); +} + const emitSnapshotEvent = sequential( async ( event: SNAPSHOT_EVENTS, snapshotUrl: string ): Promise< void > => { const cliConfig = await readCliConfig(); @@ -94,6 +102,12 @@ export async function runCommand(): Promise< void > { const eventsSocketServer = new SocketServer( SITE_EVENTS_SOCKET_PATH, 2500 ); eventsSocketServer.on( 'message', ( { message: packet } ) => { try { + const authParsed = authSocketEventSchema.safeParse( packet ); + if ( authParsed.success ) { + emitAuthEvent( authParsed.data.event, authParsed.data.data.token ); + return; + } + const snapshotParsed = snapshotSocketEventSchema.safeParse( packet ); if ( snapshotParsed.success ) { void emitSnapshotEvent( snapshotParsed.data.event, snapshotParsed.data.data.snapshotUrl ); diff --git a/apps/cli/commands/ai.ts b/apps/cli/commands/ai.ts index c2479a8f3c..b7c7162b40 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 }` ); - } 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,9 +218,11 @@ export async function runCommand(): Promise< void > { await runLoginCommand(); ui.start(); if ( await isAiProviderReady( 'wpcom' ) ) { - const token = await getAuthToken(); - ui.showInfo( `Logged in as ${ token.displayName } (${ token.email })` ); - ui.setStatusMessage( `Logged in as ${ token.displayName }` ); + const token = await readAuthToken(); + if ( token ) { + ui.showInfo( `Logged in as ${ token.displayName } (${ token.email })` ); + ui.setStatusMessage( `Logged in as ${ token.displayName }` ); + } } 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..d90223c442 100644 --- a/apps/cli/commands/auth/login.ts +++ b/apps/cli/commands/auth/login.ts @@ -1,17 +1,13 @@ import { input } from '@inquirer/prompts'; import { DEFAULT_TOKEN_LIFETIME_MS } from '@studio/common/constants'; +import { AUTH_EVENTS } from '@studio/common/lib/cli-events'; 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 { emitCliEvent } from 'cli/lib/daemon-client'; import { getAppLocale } from 'cli/lib/i18n'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -22,11 +18,16 @@ export async function runCommand(): Promise< void > { const logger = new Logger< LoggerAction >(); try { - await getAuthToken(); - logger.reportSuccess( __( 'Already authenticated with WordPress.com' ) ); - return; + 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.reportError( + new LoggerError( error instanceof Error ? error.message : String( error ) ) + ); + return; } logger.reportStart( LoggerAction.LOGIN, __( 'Opening browser for authentication…' ) ); @@ -63,28 +64,30 @@ export async function runCommand(): Promise< void > { return; } - 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, - }; + const 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 ); + try { + await updateSharedConfig( { authToken } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); } else { logger.reportError( new LoggerError( __( 'Authentication failed' ), error ) ); } - } finally { - await unlockAppdata(); + return; + } + + try { + await emitCliEvent( { event: AUTH_EVENTS.LOGIN, data: { token: authToken } } ); + } catch { + // Best-effort: don't mask successful auth if event emission fails } } diff --git a/apps/cli/commands/auth/logout.ts b/apps/cli/commands/auth/logout.ts index a3d50613a1..ce4ab2d4d5 100644 --- a/apps/cli/commands/auth/logout.ts +++ b/apps/cli/commands/auth/logout.ts @@ -1,13 +1,9 @@ +import { AUTH_EVENTS } from '@studio/common/lib/cli-events'; +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 { emitCliEvent } from 'cli/lib/daemon-client'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -15,31 +11,32 @@ export async function runCommand(): Promise< void > { const logger = new Logger< LoggerAction >(); logger.reportStart( LoggerAction.LOGOUT, __( 'Logging out…' ) ); - let token: Awaited< ReturnType< typeof getAuthToken > >; try { - token = await getAuthToken(); - } catch ( error ) { - logger.reportSuccess( __( 'Already logged out' ) ); - return; - } + const token = await readAuthToken(); - try { - await lockAppdata(); - await revokeAuthToken( token.accessToken ); - const userData = await readAppdata(); - delete userData.authToken; - await saveAppdata( userData ); + if ( ! token ) { + logger.reportSuccess( __( 'Already logged out' ) ); + return; + } - logger.reportSuccess( __( 'Successfully logged out' ) ); + await revokeAuthToken( token.accessToken ); + await updateSharedConfig( { authToken: undefined } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); } else { logger.reportError( new LoggerError( __( 'Failed to log out' ), error ) ); } - } finally { - await unlockAppdata(); + return; + } + + logger.reportSuccess( __( 'Successfully logged out' ) ); + + try { + await emitCliEvent( { event: AUTH_EVENTS.LOGOUT, data: {} } ); + } catch { + // Best-effort: don't mask successful logout if event emission fails } } diff --git a/apps/cli/commands/auth/status.ts b/apps/cli/commands/auth/status.ts index f15227de8a..c0c81090e5 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,16 +9,15 @@ 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 > >; try { - token = await getAuthToken(); - } catch ( error ) { - logger.reportError( new LoggerError( __( 'Authentication token is invalid or expired' ) ) ); - return; - } + const token = await readAuthToken(); + + if ( ! token ) { + logger.reportError( new LoggerError( __( 'Authentication token is invalid or expired' ) ) ); + return; + } - try { const userData = await getUserInfo( token.accessToken ); logger.reportSuccess( sprintf( __( 'Authenticated with WordPress.com as `%s`' ), userData.username ) diff --git a/apps/cli/commands/auth/tests/login.test.ts b/apps/cli/commands/auth/tests/login.test.ts index e5da0948f3..3d4be5c0a2 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,9 +18,16 @@ import { runCommand } from '../login'; vi.mock( '@inquirer/prompts' ); vi.mock( '@studio/common/lib/oauth' ); +vi.mock( '@studio/common/lib/shared-config', async ( importOriginal ) => ( { + ...( await importOriginal< typeof import('@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/daemon-client', () => ( { + emitCliEvent: vi.fn(), +} ) ); vi.mock( 'cli/lib/i18n' ); vi.mock( 'cli/logger', () => ( { Logger: class { @@ -51,15 +52,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 +69,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 +78,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 +98,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 +108,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 +137,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..acc6679bda 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,8 +12,15 @@ import { } from 'cli/tests/test-utils'; import { runCommand } from '../logout'; -vi.mock( 'cli/lib/appdata' ); +vi.mock( '@studio/common/lib/shared-config', async ( importOriginal ) => ( { + ...( await importOriginal< typeof import('@studio/common/lib/shared-config') >() ), + readAuthToken: vi.fn(), + updateSharedConfig: vi.fn(), +} ) ); vi.mock( 'cli/lib/api' ); +vi.mock( 'cli/lib/daemon-client', () => ( { + emitCliEvent: vi.fn(), +} ) ); vi.mock( 'cli/logger', () => ( { Logger: class { reportStart = mockReportStart; @@ -35,30 +36,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 +60,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 +71,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..8480e2c5fe 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,9 @@ 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( import( '@studio/common/lib/shared-config' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + readAuthToken: vi.fn(), } ) ); vi.mock( 'cli/lib/cli-config/snapshots', async () => ( { ...( await vi.importActual( 'cli/lib/cli-config/snapshots' ) ), @@ -85,7 +84,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 +177,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..d4f2938ce1 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,10 @@ 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( import( '@studio/common/lib/shared-config' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/api' ); vi.mock( 'cli/lib/snapshots' ); vi.mock( 'cli/logger', () => ( { @@ -60,7 +56,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 +69,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 +81,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..58ddb87a4b 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,10 @@ 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( import( '@studio/common/lib/shared-config' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/cli-config/core', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { @@ -91,7 +87,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 +106,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..d1c4b785d7 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,10 @@ 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( import( '@studio/common/lib/shared-config' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/cli-config/sites', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/sites' ); return { @@ -79,7 +75,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 +138,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..2a9200f8db 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 StoredAuthToken } 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: StoredAuthToken, 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/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index 61e58d9fd2..afbfae8854 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -69,6 +69,7 @@ vi.mock( 'cli/lib/cli-config/sites', async () => { vi.mock( 'cli/lib/language-packs' ); vi.mock( 'cli/lib/daemon-client' ); vi.mock( 'cli/lib/server-files', () => ( { + getAppdataDirectory: vi.fn().mockReturnValue( '/test/appdata' ), getServerFilesPath: vi.fn().mockReturnValue( '/test/server-files' ), } ) ); vi.mock( 'cli/lib/site-language' ); diff --git a/apps/cli/commands/site/tests/delete.test.ts b/apps/cli/commands/site/tests/delete.test.ts index 9f11894893..d8673dbb05 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,10 @@ 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( import( '@studio/common/lib/shared-config' ), async ( importOriginal ) => ( { + ...( await importOriginal() ), + readAuthToken: vi.fn(), +} ) ); vi.mock( 'cli/lib/cli-config/core', async () => { const actual = await vi.importActual( 'cli/lib/cli-config/core' ); return { @@ -114,7 +111,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 +181,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/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index 5f9d4ca484..0fd6068dde 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -29,7 +29,7 @@ vi.mock( 'cli/logger', () => ( { describe( 'CLI: studio site list', () => { const testCliConfig = { - version: 1, + version: 1 as const, sites: [ { id: 'site-1', @@ -51,7 +51,7 @@ describe( 'CLI: studio site list', () => { }; const emptyCliConfig = { - version: 1, + version: 1 as const, sites: [], snapshots: [], }; diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index 30f81448d9..24b4f15b2b 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -79,7 +79,7 @@ describe( 'CLI: studio site set', () => { vi.clearAllMocks(); const testSite = getTestSite(); - const testCliConfig = { version: 1, sites: [ testSite ], snapshots: [] }; + const testCliConfig = { version: 1 as const, sites: [ testSite ], snapshots: [] }; vi.mocked( arePathsEqual ).mockReturnValue( true ); vi.mocked( getSiteByFolder ).mockResolvedValue( getTestSite() ); 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..072292546e 100644 --- a/apps/cli/lib/certificate-manager.ts +++ b/apps/cli/lib/certificate-manager.ts @@ -5,8 +5,9 @@ import path from 'node:path'; import { domainToASCII } from 'node:url'; import { promisify } from 'node:util'; import sudo from '@vscode/sudo-prompt'; +import { __ } from '@wordpress/i18n'; import forge from 'node-forge'; -import { getAppdataDirectory } from 'cli/lib/appdata'; +import { getAppdataDirectory } from 'cli/lib/server-files'; const execFilePromise = promisify( execFile ); diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index bdae5c5ea6..9f52833b6b 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 @@ -17,21 +18,30 @@ const siteSchema = siteDetailsSchema } ) .loose(); -const cliConfigWithJustVersion = z.object( { - version: z.number().default( 1 ), -} ); +// Schema updates must maintain backwards compatibility. If a breaking change is needed, +// increment CLI_CONFIG_VERSION and add a data migration function. +const CLI_CONFIG_VERSION = 1; + // 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 cliConfigSchema = cliConfigWithJustVersion.extend( { +export const aiProviderSchema = z.enum( [ 'wpcom', 'anthropic-claude', 'anthropic-api-key' ] ); + +const cliConfigSchema = z.object( { + version: z.literal( CLI_CONFIG_VERSION ), 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 >; export type SiteData = z.infer< typeof siteSchema >; const DEFAULT_CLI_CONFIG: CliConfig = { - version: 1, + version: CLI_CONFIG_VERSION, sites: [], snapshots: [], }; @@ -67,9 +77,7 @@ export async function readCliConfig(): Promise< CliConfig > { return cliConfigSchema.parse( data ); } catch ( error ) { if ( error instanceof z.ZodError ) { - try { - cliConfigWithJustVersion.parse( data ); - } catch ( versionError ) { + if ( typeof data?.version === 'number' && data.version !== CLI_CONFIG_VERSION ) { throw new LoggerError( __( 'Invalid CLI config version. It looks like you have a different version of the `studio` CLI installed on your system. Please modify your $PATH environment variable to use the correct version.' @@ -91,7 +99,7 @@ export async function readCliConfig(): Promise< CliConfig > { export async function saveCliConfig( config: CliConfig ): Promise< void > { try { - config.version = 1; + config.version = CLI_CONFIG_VERSION; const configDir = getCliConfigDirectory(); if ( ! fs.existsSync( configDir ) ) { @@ -119,3 +127,16 @@ export async function lockCliConfig(): Promise< void > { export async function unlockCliConfig(): Promise< void > { await unlockFileAsync( LOCKFILE_PATH ); } + +export async function updateCliConfigWithPartial( + 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/daemon-client.ts b/apps/cli/lib/daemon-client.ts index eb79eeeb7d..72284300b3 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -5,9 +5,14 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; import { cacheFunctionTTL } from '@studio/common/lib/cache-function-ttl'; -import { type SITE_EVENTS, type SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; +import { + type AUTH_EVENTS, + type SITE_EVENTS, + type SNAPSHOT_EVENTS, +} from '@studio/common/lib/cli-events'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; +import { type StoredAuthToken } from '@studio/common/lib/shared-config'; import { z } from 'zod'; import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, @@ -333,7 +338,8 @@ const eventsSocketClient = new SocketRequestClient( SITE_EVENTS_SOCKET_PATH ); type CliEventPayload = | { event: SITE_EVENTS; data: { siteId: string } } - | { event: SNAPSHOT_EVENTS; data: { snapshotUrl: string } }; + | { event: SNAPSHOT_EVENTS; data: { snapshotUrl: string } } + | { event: AUTH_EVENTS; data: { token?: StoredAuthToken } }; /** * Emit a CLI event via the events socket, for the `_events` command server to receive. diff --git a/apps/cli/lib/i18n.ts b/apps/cli/lib/i18n.ts index 0a0e21a791..626a438a38 100644 --- a/apps/cli/lib/i18n.ts +++ b/apps/cli/lib/i18n.ts @@ -4,15 +4,14 @@ 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; - } catch ( error ) { - console.error( 'Error reading appdata', error ); + const config = await readSharedConfig(); + return isSupportedLocale( config.locale ) ? config.locale : undefined; + } catch { return undefined; } } @@ -40,7 +39,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..73a5e39524 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'; +export 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/cli/tests/daemon.test.ts b/apps/cli/tests/daemon.test.ts index b1ccc6005a..061ae5369e 100644 --- a/apps/cli/tests/daemon.test.ts +++ b/apps/cli/tests/daemon.test.ts @@ -1,10 +1,12 @@ import { EventEmitter } from 'events'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import { PassThrough } from 'stream'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const testProcessName = 'studio-site-process-manager-test'; +const tmpDir = path.join( os.tmpdir(), 'studio-daemon-test' ); class MockChildProcess extends EventEmitter { pid = 4321; @@ -47,8 +49,8 @@ vi.mock( '../socket', async ( importOriginal ) => { describe( 'ProcessManagerDaemon', () => { beforeEach( () => { vi.clearAllMocks(); - fs.mkdirSync( path.join( '/tmp', 'logs' ), { recursive: true } ); - process.env.STUDIO_PROCESS_MANAGER_HOME = '/tmp'; + fs.mkdirSync( path.join( tmpDir, 'logs' ), { recursive: true } ); + process.env.STUDIO_PROCESS_MANAGER_HOME = tmpDir; } ); it( 'starts a process, emits events, and writes logs', async () => { @@ -112,10 +114,10 @@ describe( 'ProcessManagerDaemon', () => { ); expect( - fs.readFileSync( path.join( '/tmp', 'logs', `${ testProcessName }-out.log` ), 'utf8' ) + fs.readFileSync( path.join( tmpDir, 'logs', `${ testProcessName }-out.log` ), 'utf8' ) ).toContain( 'fixture-stdout' ); expect( - fs.readFileSync( path.join( '/tmp', 'logs', `${ testProcessName }-error.log` ), 'utf8' ) + fs.readFileSync( path.join( tmpDir, 'logs', `${ testProcessName }-error.log` ), 'utf8' ) ).toContain( 'fixture-stderr' ); } ); 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/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index ec6f8026ec..8834bf642b 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'; @@ -732,7 +733,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..96dbcf1924 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -4,7 +4,7 @@ import { BlueprintValidationWarning } from '@studio/common/lib/blueprint-validat import { SiteEvent, SnapshotEvent } from '@studio/common/lib/cli-events'; import { PreviewCommandLoggerAction } from '@studio/common/logger-actions'; import { ImportExportEventData } from 'src/lib/import-export/handle-events'; -import { StoredToken } from 'src/lib/oauth'; +import { StoredAuthToken } from 'src/lib/oauth'; import { getMainWindow } from 'src/main-window'; import type { UserData } from 'src/storage/storage-types'; @@ -27,7 +27,7 @@ export interface IpcEvents { warnings?: BlueprintValidationWarning[]; }, ]; - 'auth-updated': [ { token: StoredToken } | { error: unknown } ]; + 'auth-updated': [ { token: StoredAuthToken } | { 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..597644d2e0 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(), @@ -20,9 +11,9 @@ const meResponseSchema = z.object( { display_name: z.string(), } ); -type StoredToken = z.infer< typeof authTokenSchema >; +type StoredAuthToken = z.infer< typeof authTokenSchema >; -async function handleAuthCallback( hash: string ): Promise< StoredToken > { +async function handleAuthCallback( hash: string ): Promise< StoredAuthToken > { const params = new URLSearchParams( hash.substring( 1 ) ); const error = params.get( 'error' ); @@ -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..5db8c37c65 100644 --- a/apps/studio/src/lib/oauth.ts +++ b/apps/studio/src/lib/oauth.ts @@ -1,41 +1,17 @@ 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 StoredAuthToken } 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 { StoredAuthToken } from '@studio/common/lib/shared-config'; export function getSignUpUrl( locale: SupportedLocale ) { const oauth2Redirect = encodeURIComponent( getAuthenticationUrl( locale ) ); return `https://wordpress.com/start/wpcc/oauth2-user?oauth2_client_id=${ CLIENT_ID }&oauth2_redirect=${ oauth2Redirect }&locale=${ locale }`; } -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; +export async function getAuthenticationToken(): Promise< StoredAuthToken | null > { + return readAuthToken(); } export async function isAuthenticated(): Promise< boolean > { 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/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index c944730b11..47b13b5caf 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -1,4 +1,6 @@ import { + AUTH_EVENTS, + cliAuthEventSchema, cliSiteEventSchema, cliSnapshotEventSchema, SiteEvent, @@ -81,6 +83,17 @@ export async function startCliEventsSubscriber(): Promise< void > { } ); eventEmitter.on( 'data', ( { data } ) => { + const authParsed = cliAuthEventSchema.safeParse( data ); + if ( authParsed.success ) { + const { event, token } = authParsed.data.value; + if ( event === AUTH_EVENTS.LOGIN && token ) { + void sendIpcEventToRenderer( 'auth-updated', { token } ); + } else { + void sendIpcEventToRenderer( 'auth-updated', { token: null } ); + } + return; + } + const snapshotParsed = cliSnapshotEventSchema.safeParse( data ); if ( snapshotParsed.success ) { void sendIpcEventToRenderer( 'snapshot-changed', snapshotParsed.data.value ); diff --git a/apps/studio/src/modules/sync/lib/ipc-handlers.ts b/apps/studio/src/modules/sync/lib/ipc-handlers.ts index ce40e05be5..6ff3a7f51c 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 ) { @@ -533,14 +537,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/auth-token-schema.ts b/tools/common/lib/auth-token-schema.ts new file mode 100644 index 0000000000..f0cdbda01b --- /dev/null +++ b/tools/common/lib/auth-token-schema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +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 StoredAuthToken = z.infer< typeof authTokenSchema >; diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index 124a7d1fae..1c2d4f1a5f 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -5,6 +5,7 @@ * subscribes to them to maintain its state without reading config files. */ import { z } from 'zod'; +import { authTokenSchema } from '@studio/common/lib/auth-token-schema'; import { snapshotSchema } from '@studio/common/types/snapshot'; /** @@ -45,6 +46,11 @@ export enum SITE_EVENTS { DELETED = 'site-deleted', } +export enum AUTH_EVENTS { + LOGIN = 'auth-login', + LOGOUT = 'auth-logout', +} + export enum SNAPSHOT_EVENTS { CREATED = 'snapshot-created', UPDATED = 'snapshot-updated', @@ -68,6 +74,13 @@ export const snapshotEventSchema = z.object( { export type SnapshotEvent = z.infer< typeof snapshotEventSchema >; +export const authEventSchema = z.object( { + event: z.enum( AUTH_EVENTS ), + token: authTokenSchema.optional(), +} ); + +export type AuthEvent = z.infer< typeof authEventSchema >; + /** * Socket-level schemas for events sent between daemon-client and the _events command. */ @@ -85,6 +98,13 @@ export const snapshotSocketEventSchema = z.object( { } ), } ); +export const authSocketEventSchema = z.object( { + event: z.enum( AUTH_EVENTS ), + data: z.object( { + token: authTokenSchema.optional(), + } ), +} ); + /** * CLI stdout key-value pair schemas for events parsed by Studio's cli-events-subscriber. */ @@ -105,3 +125,12 @@ export const cliSnapshotEventSchema = z.object( { .transform( ( val ) => JSON.parse( val ) ) .pipe( snapshotEventSchema ), } ); + +export const cliAuthEventSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'auth-event' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) ) + .pipe( authEventSchema ), +} ); diff --git a/tools/common/lib/shared-config.ts b/tools/common/lib/shared-config.ts new file mode 100644 index 0000000000..009d7e0e12 --- /dev/null +++ b/tools/common/lib/shared-config.ts @@ -0,0 +1,142 @@ +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 { authTokenSchema, type StoredAuthToken } from './auth-token-schema'; +import { lockFileAsync, unlockFileAsync } from './lockfile'; + +export { authTokenSchema }; +export type { StoredAuthToken }; + +export class SharedConfigVersionMismatchError extends Error { + constructor() { + super( + 'A newer version of Studio or the Studio CLI is installed on your system. Please update all components to the same version.' + ); + this.name = 'SharedConfigVersionMismatchError'; + } +} + +const SHARED_CONFIG_FILENAME = 'shared.json'; + +// Schema updates must maintain backwards compatibility. If a breaking change is needed, +// increment SHARED_CONFIG_VERSION and add a data migration function. +const SHARED_CONFIG_VERSION = 1; + +const sharedConfigSchema = z + .object( { + version: z.literal( SHARED_CONFIG_VERSION ), + authToken: authTokenSchema.optional(), + locale: z.string().optional(), + } ) + .loose(); + +export type SharedConfig = z.infer< typeof sharedConfigSchema >; + +const DEFAULT_SHARED_CONFIG: SharedConfig = { + version: SHARED_CONFIG_VERSION, +}; + +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 ); + } + + let data: Record< string, unknown > | undefined; + try { + const fileContent = await readFile( configPath, { encoding: 'utf8' } ); + data = JSON.parse( fileContent ); + return sharedConfigSchema.parse( data ); + } catch ( error ) { + if ( error instanceof z.ZodError ) { + if ( typeof data?.version === 'number' && data.version !== SHARED_CONFIG_VERSION ) { + throw new SharedConfigVersionMismatchError(); + } + return structuredClone( DEFAULT_SHARED_CONFIG ); + } + if ( 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 = SHARED_CONFIG_VERSION; + + 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< StoredAuthToken | 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 ( error ) { + if ( error instanceof SharedConfigVersionMismatchError ) { + throw error; + } + 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..c9e6938b2f --- /dev/null +++ b/tools/common/lib/tests/shared-config.test.ts @@ -0,0 +1,258 @@ +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, + SharedConfigVersionMismatchError, +} from '@studio/common/lib/shared-config'; +import type { SharedConfig } 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 throw SharedConfigVersionMismatchError when version differs from current', async () => { + const data = { version: 2, authToken: validToken }; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( data ) ) ); + + await expect( readSharedConfig() ).rejects.toThrow( SharedConfigVersionMismatchError ); + } ); + + 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 as const, 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 unknown as SharedConfig; + 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(); + } ); + + it( 'should throw SharedConfigVersionMismatchError on version mismatch', async () => { + const data = { version: 2, authToken: validToken }; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( data ) ) ); + + await expect( readAuthToken() ).rejects.toThrow( SharedConfigVersionMismatchError ); + } ); + } ); + + 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(); + } ); + } ); +} ); From 0d2e27176cfa55e8abe28501d680a1fdb49bb525 Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Mon, 23 Mar 2026 15:14:27 +0000 Subject: [PATCH 06/11] Migrate appdata to ~/.studio/appdata.json with versioned migrations (#2836) * Migrate appdata to ~/.studio/appdata.json with extensible migration framework * Move appdata migration from CLI to Studio Desktop * Rewrite appdata migration to split into shared.json, cli.json, and appdata.json * trigger ci * Ensure .studio directory exists before lockfile and fix import order * Fix readFile mock type to return Buffer instead of string * Refactor appdata to store only Desktop-specific state with sites as Record * Address PR feedback: rename to app.json and siteMetadata, use zod parsing in migration * Fix prettier formatting in user-data test * Fix e2e migration by respecting E2E_APP_DATA_PATH in getOldAppdataPath * new migration interface * studio migrations * studio migrations * cli migrations * Rename appdata references, centralize config paths and lockfile constants * Isolate e2e config directory to fix test failures * Move site metadata cleanup to SiteServer, fix typecheck and test failures * Add APP_CONFIG_LOCKFILE_NAME constant and fix test failures --- apps/cli/commands/_events.ts | 7 +- apps/cli/index.ts | 5 + apps/cli/lib/cli-config/core.ts | 28 +- apps/cli/lib/daemon-client.ts | 1 - apps/cli/lib/tests/snapshots.test.ts | 11 +- .../00-check-studio-compatibility.ts | 37 ++ apps/cli/migrations/index.ts | 4 + apps/studio/e2e/e2e-helpers.ts | 4 + apps/studio/src/index.ts | 16 +- apps/studio/src/ipc-handlers.ts | 31 +- .../export/exporters/default-exporter.test.ts | 1 + .../tests/import/import-manager.test.ts | 4 +- apps/studio/src/lib/tests/bump-stats.test.ts | 9 + .../src/lib/tests/windows-helpers.test.ts | 39 +- apps/studio/src/main-window.ts | 7 +- .../00-migrate-from-wp-now-folder.ts | 32 ++ .../01-rename-launch-uniques-stat.ts | 24 + .../migrations/02-migrate-to-split-config.ts | 210 +++++++ apps/studio/src/migrations/index.ts | 10 + .../migrations/migrate-from-wp-now-folder.ts | 30 - .../remove-sites-with-empty-dirs.ts | 23 - .../migrations/rename-launch-uniques-stat.ts | 21 - .../tests/00-migrate-to-split-config.test.ts | 534 ++++++++++++++++++ .../modules/cli/lib/cli-events-subscriber.ts | 2 +- apps/studio/src/site-server.ts | 26 +- apps/studio/src/storage/paths.ts | 8 +- apps/studio/src/storage/storage-types.ts | 22 +- .../src/storage/tests/user-data.test.ts | 159 ++---- apps/studio/src/storage/user-data.ts | 173 +----- .../stores/tests/installed-apps-api.test.ts | 3 +- apps/studio/src/tests/index.test.ts | 1 - apps/studio/src/tests/ipc-handlers.test.ts | 1 - apps/studio/src/tests/main-window.test.ts | 1 - .../studio/src/tests/open-file-in-ide.test.ts | 1 - apps/studio/vitest.setup.ts | 2 +- tools/common/constants.ts | 3 +- tools/common/lib/config-paths.ts | 29 + tools/common/lib/migration.ts | 23 + tools/common/lib/shared-config.ts | 30 +- tools/common/lib/tests/migration.test.ts | 101 ++++ tools/common/lib/tests/shared-config.test.ts | 6 +- 41 files changed, 1212 insertions(+), 467 deletions(-) create mode 100644 apps/cli/migrations/00-check-studio-compatibility.ts create mode 100644 apps/cli/migrations/index.ts create mode 100644 apps/studio/src/migrations/00-migrate-from-wp-now-folder.ts create mode 100644 apps/studio/src/migrations/01-rename-launch-uniques-stat.ts create mode 100644 apps/studio/src/migrations/02-migrate-to-split-config.ts create mode 100644 apps/studio/src/migrations/index.ts delete mode 100644 apps/studio/src/migrations/migrate-from-wp-now-folder.ts delete mode 100644 apps/studio/src/migrations/remove-sites-with-empty-dirs.ts delete mode 100644 apps/studio/src/migrations/rename-launch-uniques-stat.ts create mode 100644 apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts create mode 100644 tools/common/lib/config-paths.ts create mode 100644 tools/common/lib/migration.ts create mode 100644 tools/common/lib/tests/migration.test.ts diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index 3a9c3f58b0..708209453e 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -19,11 +19,12 @@ import { SnapshotEvent, AuthEvent, } from '@studio/common/lib/cli-events'; +import { isEmptyDir } from '@studio/common/lib/fs-utils'; import { sequential } from '@studio/common/lib/sequential'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { readCliConfig, SiteData } from 'cli/lib/cli-config/core'; -import { getSiteUrl } from 'cli/lib/cli-config/sites'; +import { getSiteUrl, removeSiteFromConfig } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, @@ -63,6 +64,10 @@ const emitSiteEvent = sequential( async function emitAllSitesStatus(): Promise< void > { const cliConfig = await readCliConfig(); for ( const site of cliConfig.sites ) { + if ( site.path && ( await isEmptyDir( site.path ).catch( () => true ) ) ) { + await removeSiteFromConfig( site.id ); + continue; + } await emitSiteEvent( SITE_EVENTS.UPDATED, site.id ); } } diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 866f55a95e..573f926db4 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -34,6 +34,11 @@ async function main() { return path.resolve( untildify( value ) ); }, } ) + .middleware( async () => { + const { runMigrations } = await import( '@studio/common/lib/migration' ); + const { migrations } = await import( 'cli/migrations' ); + await runMigrations( migrations ); + } ) .middleware( async ( argv ) => { if ( __ENABLE_CLI_TELEMETRY__ && ! argv.avoidTelemetry ) { try { diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index 9f52833b6b..26cbc95dd8 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -1,13 +1,17 @@ import fs from 'fs'; import path from 'path'; -import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { + CLI_CONFIG_LOCKFILE_NAME, + LOCKFILE_STALE_TIME, + LOCKFILE_WAIT_TIME, +} from '@studio/common/constants'; import { siteDetailsSchema } from '@studio/common/lib/cli-events'; +import { getCliConfigPath, getConfigDirectory } from '@studio/common/lib/config-paths'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { snapshotSchema } from '@studio/common/types/snapshot'; 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'; @@ -46,18 +50,6 @@ const DEFAULT_CLI_CONFIG: CliConfig = { snapshots: [], }; -export function getCliConfigDirectory(): string { - if ( process.env.E2E && process.env.E2E_CLI_CONFIG_PATH ) { - return process.env.E2E_CLI_CONFIG_PATH; - } - - return STUDIO_CLI_HOME; -} - -export function getCliConfigPath(): string { - return path.join( getCliConfigDirectory(), 'cli.json' ); -} - export async function readCliConfig(): Promise< CliConfig > { const configPath = getCliConfigPath(); @@ -65,10 +57,10 @@ export async function readCliConfig(): Promise< CliConfig > { return structuredClone( DEFAULT_CLI_CONFIG ); } + let data: Record< string, unknown >; try { const fileContent = await readFile( configPath, { encoding: 'utf8' } ); - // eslint-disable-next-line no-var - var data = JSON.parse( fileContent ); + data = JSON.parse( fileContent ); } catch ( error ) { throw new LoggerError( __( 'Failed to read CLI config file.' ), error ); } @@ -101,7 +93,7 @@ export async function saveCliConfig( config: CliConfig ): Promise< void > { try { config.version = CLI_CONFIG_VERSION; - const configDir = getCliConfigDirectory(); + const configDir = getConfigDirectory(); if ( ! fs.existsSync( configDir ) ) { fs.mkdirSync( configDir, { recursive: true } ); } @@ -118,7 +110,7 @@ export async function saveCliConfig( config: CliConfig ): Promise< void > { } } -const LOCKFILE_PATH = path.join( getCliConfigDirectory(), 'cli.json.lock' ); +const LOCKFILE_PATH = path.join( getConfigDirectory(), CLI_CONFIG_LOCKFILE_NAME ); export async function lockCliConfig(): Promise< void > { await lockFileAsync( LOCKFILE_PATH, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 2c8af1834a..2eeff3bbc3 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -4,7 +4,6 @@ import { EventEmitter } from 'events'; import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; -import { cacheFunctionTTL } from '@studio/common/lib/cache-function-ttl'; import { type AUTH_EVENTS, type SITE_EVENTS, diff --git a/apps/cli/lib/tests/snapshots.test.ts b/apps/cli/lib/tests/snapshots.test.ts index 289a0d6303..3b27de3d56 100644 --- a/apps/cli/lib/tests/snapshots.test.ts +++ b/apps/cli/lib/tests/snapshots.test.ts @@ -13,6 +13,9 @@ const mocks = vi.hoisted( () => ( { writeFile: vi.fn(), pathJoin: vi.fn().mockImplementation( ( ...args: string[] ) => args.join( '/' ) ), pathResolve: vi.fn().mockImplementation( ( path: string ) => path ), + pathDirname: vi + .fn() + .mockImplementation( ( p: string ) => p.split( '/' ).slice( 0, -1 ).join( '/' ) ), pathBasename: vi.fn(), lockfileLock: vi.fn().mockImplementation( ( path, options, callback ) => callback( null ) ), lockfileUnlock: vi.fn().mockImplementation( ( path, callback ) => callback( null ) ), @@ -28,9 +31,15 @@ vi.mock( 'fs', () => ( { } ) ); vi.mock( 'os', () => ( { default: { homedir: mocks.homedir }, homedir: mocks.homedir } ) ); vi.mock( 'path', () => ( { - default: { join: mocks.pathJoin, resolve: mocks.pathResolve, basename: mocks.pathBasename }, + default: { + join: mocks.pathJoin, + resolve: mocks.pathResolve, + dirname: mocks.pathDirname, + basename: mocks.pathBasename, + }, join: mocks.pathJoin, resolve: mocks.pathResolve, + dirname: mocks.pathDirname, basename: mocks.pathBasename, } ) ); vi.mock( 'atomically', () => ( { diff --git a/apps/cli/migrations/00-check-studio-compatibility.ts b/apps/cli/migrations/00-check-studio-compatibility.ts new file mode 100644 index 0000000000..0829bfdfa8 --- /dev/null +++ b/apps/cli/migrations/00-check-studio-compatibility.ts @@ -0,0 +1,37 @@ +import fs from 'fs'; +import path from 'path'; +import { getAppConfigPath } from '@studio/common/lib/config-paths'; +import { __ } from '@wordpress/i18n'; +import { getAppdataDirectory } from 'cli/lib/server-files'; +import { LoggerError } from 'cli/logger'; +import type { Migration } from '@studio/common/lib/migration'; + +/** + * Checks compatibility between the standalone CLI and the installed Studio Desktop app. + * + * If Studio Desktop is installed (platform-specific appdata exists) but the shared + * config at ~/.studio/app.json is missing, it means Studio hasn't been updated + * to a version that supports the shared location. In that case, prompt the user + * to update Studio. + * + * Always needs to run. Throws if incompatible. + */ +export const checkStudioCompatibilityForInitialMigration: Migration = { + async needsToRun() { + return true; + }, + async run() { + if ( fs.existsSync( getAppConfigPath() ) ) { + return; + } + + const oldAppdataPath = path.join( getAppdataDirectory(), 'appdata-v1.json' ); + if ( fs.existsSync( oldAppdataPath ) ) { + throw new LoggerError( + __( + 'A newer version of Studio is required. Please update the Studio desktop app to continue using the CLI.' + ) + ); + } + }, +}; diff --git a/apps/cli/migrations/index.ts b/apps/cli/migrations/index.ts new file mode 100644 index 0000000000..9de95d32f3 --- /dev/null +++ b/apps/cli/migrations/index.ts @@ -0,0 +1,4 @@ +import { checkStudioCompatibilityForInitialMigration } from './00-check-studio-compatibility'; +import type { Migration } from '@studio/common/lib/migration'; + +export const migrations: Migration[] = [ checkStudioCompatibilityForInitialMigration ]; diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index a284f30a73..f7dbd262f3 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -16,6 +16,7 @@ export class E2ESession { appDataPath: string; homePath: string; cliConfigPath: string; + sharedConfigPath: string; private mainProcessLogs: string[] = []; private readonly maxMainProcessLogChunks = 500; private stdoutListener?: ( chunk: Buffer | string ) => void; @@ -27,12 +28,14 @@ export class E2ESession { this.appDataPath = path.join( this.sessionPath, 'appData' ); this.homePath = path.join( this.sessionPath, 'home' ); this.cliConfigPath = path.join( this.sessionPath, 'cliConfig' ); + this.sharedConfigPath = path.join( this.sessionPath, 'sharedConfig' ); } async launch( testEnv: NodeJS.ProcessEnv = {} ) { await fs.mkdir( this.appDataPath, { recursive: true } ); await fs.mkdir( this.homePath, { recursive: true } ); await fs.mkdir( this.cliConfigPath, { recursive: true } ); + await fs.mkdir( this.sharedConfigPath, { recursive: true } ); // Pre-create appdata file with beta features enabled for CLI testing // Path must include 'Studio' subfolder to match Electron app's path structure @@ -115,6 +118,7 @@ export class E2ESession { E2E_APP_DATA_PATH: this.appDataPath, E2E_HOME_PATH: this.homePath, E2E_CLI_CONFIG_PATH: this.cliConfigPath, + E2E_SHARED_CONFIG_PATH: this.sharedConfigPath, }, timeout: 60_000, } ); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index 22b3948feb..be36f4b38a 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -13,6 +13,7 @@ import path from 'path'; import { pathToFileURL } from 'url'; import * as Sentry from '@sentry/electron/main'; import { PROTOCOL_PREFIX } from '@studio/common/constants'; +import { runMigrations } from '@studio/common/lib/migration'; import { suppressPunycodeWarning } from '@studio/common/lib/suppress-punycode-warning'; import { __, _n, sprintf } from '@wordpress/i18n'; import { @@ -38,12 +39,7 @@ import { getSentryReleaseInfo } from 'src/lib/sentry-release'; import { startUserDataWatcher, stopUserDataWatcher } from 'src/lib/user-data-watcher'; import { setupLogging } from 'src/logging'; import { createMainWindow, getMainWindow } from 'src/main-window'; -import { - needsToMigrateFromWpNowFolder, - migrateFromWpNowFolder, -} from 'src/migrations/migrate-from-wp-now-folder'; -import { removeSitesWithEmptyDirectories } from 'src/migrations/remove-sites-with-empty-dirs'; -import { renameLaunchUniquesStat } from 'src/migrations/rename-launch-uniques-stat'; +import { migrations } from 'src/migrations'; import { startCliEventsSubscriber, stopCliEventsSubscriber, @@ -311,16 +307,10 @@ async function appBoot() { // WordPress server files are updated asynchronously to avoid delaying app initialization updateWPServerFiles().catch( Sentry.captureException ); - if ( await needsToMigrateFromWpNowFolder() ) { - await migrateFromWpNowFolder(); - } + await runMigrations( migrations ).catch( Sentry.captureException ); await setupSentryUserId(); - await removeSitesWithEmptyDirectories(); - - await renameLaunchUniquesStat(); - // Fetch data from CLI and subscribe to CLI events before starting the user data // watcher. The watcher can trigger getMainWindow() which creates the window early, // so sites must be loaded first. diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 6ad9025920..2a538ce6a0 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -234,7 +234,7 @@ export async function getSiteDetails( _event: IpcMainInvokeEvent ): Promise< Sit const sites = SiteServer.getAllDetails(); const userData = await loadUserData(); for ( const site of sites ) { - const appdataSite = userData.sites.find( ( s ) => s.id === site.id ); + const appdataSite = userData.siteMetadata[ site.id ]; if ( appdataSite ) { site.sortOrder = appdataSite.sortOrder; site.themeDetails = appdataSite.themeDetails; @@ -647,19 +647,6 @@ export async function deleteSite( event: IpcMainInvokeEvent, id: string, deleteF throw new Error( 'Site not found.' ); } await server.delete( deleteFiles ); - - // Clean up Studio-only data (sortOrder, themeDetails) from appdata - try { - await lockAppdata(); - const userData = await loadUserData(); - const siteIndex = userData.sites.findIndex( ( s ) => s.id === id ); - if ( siteIndex !== -1 ) { - userData.sites.splice( siteIndex, 1 ); - await saveUserData( userData ); - } - } finally { - await unlockAppdata(); - } } export async function copySite( @@ -1557,9 +1544,7 @@ export async function isFullscreen( _event: IpcMainInvokeEvent ): Promise< boole } export async function getAllCustomDomains(): Promise< string[] > { - const userData = await loadUserData(); - - return userData.sites + return SiteServer.getAllDetails() .map( ( site ) => site.customDomain ) .filter( ( domain ): domain is string => domain !== undefined ); } @@ -1665,15 +1650,11 @@ export async function updateSitesSortOrder( await lockAppdata(); const userData = await loadUserData(); - const updatedSites = userData.sites.map( ( site ) => { - const update = updates.find( ( u ) => u.siteId === site.id ); - if ( update ) { - return { ...site, sortOrder: update.sortOrder }; - } - return site; - } ); + for ( const { siteId, sortOrder } of updates ) { + userData.siteMetadata[ siteId ] = { ...userData.siteMetadata[ siteId ], sortOrder }; + } - await saveUserData( { ...userData, sites: updatedSites } ); + await saveUserData( userData ); } finally { await unlockAppdata(); } diff --git a/apps/studio/src/lib/import-export/tests/export/exporters/default-exporter.test.ts b/apps/studio/src/lib/import-export/tests/export/exporters/default-exporter.test.ts index 2d8769630b..b23f6f63ba 100644 --- a/apps/studio/src/lib/import-export/tests/export/exporters/default-exporter.test.ts +++ b/apps/studio/src/lib/import-export/tests/export/exporters/default-exporter.test.ts @@ -41,6 +41,7 @@ vi.mock( 'fs/promises', () => ( { vi.mock( 'os', () => ( { default: { tmpdir: vi.fn(), + homedir: vi.fn().mockReturnValue( '/mock/home' ), }, } ) ); vi.mock( 'fs-extra' ); diff --git a/apps/studio/src/lib/import-export/tests/import/import-manager.test.ts b/apps/studio/src/lib/import-export/tests/import/import-manager.test.ts index 1eaf737251..e38a1c9144 100644 --- a/apps/studio/src/lib/import-export/tests/import/import-manager.test.ts +++ b/apps/studio/src/lib/import-export/tests/import/import-manager.test.ts @@ -15,9 +15,6 @@ vi.mock( 'src/storage/paths', () => ( { getUserDataCertificatesPath: vi .fn() .mockReturnValue( '/path/to/app/appData/App Name/certificates' ), - getUserDataLockFilePath: vi - .fn() - .mockReturnValue( '/path/to/app/appData/App Name/appdata-v1.json.lock' ), } ) ); vi.mock( 'src/lib/import-export/import/handlers/backup-handler-factory' ); vi.mock( 'fs/promises', () => ( { @@ -30,6 +27,7 @@ vi.mock( 'fs/promises', () => ( { vi.mock( 'os', () => ( { default: { tmpdir: vi.fn(), + homedir: vi.fn().mockReturnValue( '/mock/home' ), }, } ) ); vi.mock( 'path', () => ( { diff --git a/apps/studio/src/lib/tests/bump-stats.test.ts b/apps/studio/src/lib/tests/bump-stats.test.ts index d78d015ea9..708646e9ca 100644 --- a/apps/studio/src/lib/tests/bump-stats.test.ts +++ b/apps/studio/src/lib/tests/bump-stats.test.ts @@ -9,6 +9,15 @@ vi.mock( 'atomically', () => ( { writeFile: vi.fn(), } ) ); +vi.mock( 'fs', () => ( { + default: { + existsSync: vi.fn().mockReturnValue( true ), + mkdirSync: vi.fn(), + }, + existsSync: vi.fn().mockReturnValue( true ), + mkdirSync: vi.fn(), +} ) ); + // Store original fetch to restore later const originalFetch = global.fetch; diff --git a/apps/studio/src/lib/tests/windows-helpers.test.ts b/apps/studio/src/lib/tests/windows-helpers.test.ts index 380a5d4335..d891cf0131 100644 --- a/apps/studio/src/lib/tests/windows-helpers.test.ts +++ b/apps/studio/src/lib/tests/windows-helpers.test.ts @@ -62,7 +62,7 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should return early on non-Windows platforms', async () => { Object.defineProperty( process, 'platform', { value: 'darwin' } ); - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -70,7 +70,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should show prompt on Windows platform', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -81,7 +81,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'version tracking', () => { it( 'should show prompt when no previous response exists', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); @@ -91,7 +91,8 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should skip prompt when user said "no" to the current version', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + version: 1, + siteMetadata: {}, promptWindowsSpeedUpResult: { response: 'no', appVersion: currentVersion, @@ -106,7 +107,8 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should show prompt again when user said "no" to a previous version', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + version: 1, + siteMetadata: {}, promptWindowsSpeedUpResult: { response: 'no', appVersion: '1.2.2', // Previous version @@ -122,7 +124,8 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should skip prompt when user said "yes" regardless of version', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + version: 1, + siteMetadata: {}, promptWindowsSpeedUpResult: { response: 'yes', appVersion: '1.2.2', // Previous version @@ -137,7 +140,8 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should always show prompt when skipIfAlreadyPrompted is false', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + version: 1, + siteMetadata: {}, promptWindowsSpeedUpResult: { response: 'no', appVersion: currentVersion, @@ -155,7 +159,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'legacy format handling', () => { it( 'should handle legacy string format "yes" and skip prompt', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + siteMetadata: {}, // @ts-expect-error - Testing legacy string format for backward compatibility promptWindowsSpeedUpResult: 'yes', } ); @@ -167,7 +171,7 @@ describe( 'promptWindowsSpeedUpSites', () => { it( 'should handle legacy string format "no" and show prompt', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + siteMetadata: {}, // @ts-expect-error - Testing legacy string format for backward compatibility promptWindowsSpeedUpResult: 'no', } ); @@ -181,7 +185,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'user response handling', () => { it( 'should save "yes" response with current app version and dontAskAgain false', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 0, checkboxChecked: false } ); // First button (yes) await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -196,7 +200,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should save "no" response with current app version and dontAskAgain false', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); // Second button (no) await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -213,7 +217,7 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'dialog content', () => { it( 'should show correct dialog title and message', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -230,7 +234,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should show checkbox when skipIfAlreadyPrompted is true', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); @@ -244,7 +248,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should not show checkbox when skipIfAlreadyPrompted is false', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: false } ); await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: false } ); @@ -261,7 +265,8 @@ describe( 'promptWindowsSpeedUpSites', () => { describe( 'dontAskAgain functionality', () => { it( 'should skip prompt when dontAskAgain is true regardless of version', async () => { mockLoadUserData.mockResolvedValue( { - sites: [], + version: 1, + siteMetadata: {}, promptWindowsSpeedUpResult: { response: 'no', appVersion: '1.2.2', // Previous version @@ -275,7 +280,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should save dontAskAgain true when checkbox is checked with "yes" response', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 0, checkboxChecked: true } ); // First button (yes) with checkbox await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); @@ -290,7 +295,7 @@ describe( 'promptWindowsSpeedUpSites', () => { } ); it( 'should save dontAskAgain true when checkbox is checked with "no" response', async () => { - mockLoadUserData.mockResolvedValue( { sites: [] } ); + mockLoadUserData.mockResolvedValue( { version: 1, siteMetadata: {} } ); mockDialogShowMessageBox.mockResolvedValue( { response: 1, checkboxChecked: true } ); // Second button (no) with checkbox await promptWindowsSpeedUpSites( { skipIfAlreadyPrompted: true } ); diff --git a/apps/studio/src/main-window.ts b/apps/studio/src/main-window.ts index c517a63a01..12143af93e 100644 --- a/apps/studio/src/main-window.ts +++ b/apps/studio/src/main-window.ts @@ -17,6 +17,7 @@ import { import { sendIpcEventToRendererWithWindow } from 'src/ipc-utils'; import { promptWindowsSpeedUpSites } from 'src/lib/windows-helpers'; import { removeMenu } from 'src/menu'; +import { SiteServer } from 'src/site-server'; import { loadUserData, updateAppdata, @@ -105,8 +106,10 @@ export async function createMainWindow(): Promise< BrowserWindow > { // Open the DevTools if the user had it open last time they used the app. // During development the dev tools default to open. - setupDevTools( mainWindow, userData.devToolsOpen ); - initializePortFinder( userData.sites ); + void loadUserData().then( ( userData ) => { + setupDevTools( mainWindow, userData.devToolsOpen ); + initializePortFinder( SiteServer.getAllDetails() ); + } ); mainWindow.webContents.on( 'devtools-opened', async () => { await updateAppdata( { devToolsOpen: true } ); diff --git a/apps/studio/src/migrations/00-migrate-from-wp-now-folder.ts b/apps/studio/src/migrations/00-migrate-from-wp-now-folder.ts new file mode 100644 index 0000000000..7b6c6c7f36 --- /dev/null +++ b/apps/studio/src/migrations/00-migrate-from-wp-now-folder.ts @@ -0,0 +1,32 @@ +import { app } from 'electron'; +import path from 'path'; +import { pathExists, recursiveCopyDirectory } from '@studio/common/lib/fs-utils'; +import { getServerFilesPath } from 'src/storage/paths'; +import { loadUserData } from 'src/storage/user-data'; +import type { Migration } from '@studio/common/lib/migration'; + +const wpNowPath = path.join( app.getPath( 'home' ), '.wp-now' ); + +// Database and server files are no longer stored in ~/.wp-now +// In order to help our early adopters, we'll do a one-time migration from +// ~/.wp-now to ~/Library/Application Support/Studio +export const migrateFromWpNowFolder: Migration = { + async needsToRun() { + if ( ! ( await pathExists( wpNowPath ) ) ) { + return false; + } + + if ( await pathExists( getServerFilesPath() ) ) { + // Either the migration has already been done, or they weren't an early adopter. + return false; + } + + // Only copy ~/.wp-now if at least one of those sites refers to a site + // in our app. + const { siteMetadata } = await loadUserData(); + return Object.keys( siteMetadata ).length > 0; + }, + async run() { + await recursiveCopyDirectory( wpNowPath, getServerFilesPath() ); + }, +}; diff --git a/apps/studio/src/migrations/01-rename-launch-uniques-stat.ts b/apps/studio/src/migrations/01-rename-launch-uniques-stat.ts new file mode 100644 index 0000000000..4f21931a75 --- /dev/null +++ b/apps/studio/src/migrations/01-rename-launch-uniques-stat.ts @@ -0,0 +1,24 @@ +import { StatsGroup } from 'src/lib/bump-stats'; +import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; +import type { Migration } from '@studio/common/lib/migration'; + +export const renameLaunchUniquesStat: Migration = { + async needsToRun() { + const userData = await loadUserData(); + return userData.lastBumpStats?.[ 'local-environment-launch-uniques' ] !== undefined; + }, + async run() { + const userData = await loadUserData(); + const lastBumpStat = userData.lastBumpStats![ 'local-environment-launch-uniques' ]; + + userData.lastBumpStats![ StatsGroup.STUDIO_APP_LAUNCH_UNIQUE ] = lastBumpStat; + delete userData.lastBumpStats![ 'local-environment-launch-uniques' ]; + + try { + await lockAppdata(); + await saveUserData( userData ); + } finally { + await unlockAppdata(); + } + }, +}; diff --git a/apps/studio/src/migrations/02-migrate-to-split-config.ts b/apps/studio/src/migrations/02-migrate-to-split-config.ts new file mode 100644 index 0000000000..1d5ebcde9d --- /dev/null +++ b/apps/studio/src/migrations/02-migrate-to-split-config.ts @@ -0,0 +1,210 @@ +/** + * Migrates appdata-v1.json from the platform-specific Electron location + * into the three new config files at ~/.studio/: + * + * - shared.json: auth token + locale + * - cli.json: sites + snapshots + * - app.json: Desktop-only state (UI prefs, sync, etc.) + * + * The old file is left intact intentionally β€” cleanup will happen + * in a future release after migration is validated. + */ + +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as Sentry from '@sentry/electron/main'; +import { siteDetailsSchema } from '@studio/common/lib/cli-events'; +import { + getAppConfigPath, + getCliConfigPath, + getSharedConfigPath, +} from '@studio/common/lib/config-paths'; +import { sharedConfigSchema } from '@studio/common/lib/shared-config'; +import { snapshotSchema } from '@studio/common/types/snapshot'; +import { readFile, writeFile } from 'atomically'; +import { z } from 'zod'; +import { sanitizeUserpath } from 'src/lib/sanitize-for-logging'; +import type { Migration } from '@studio/common/lib/migration'; + +/** + * Returns the old platform-specific appdata path used by previous Studio versions. + * macOS: ~/Library/Application Support/Studio/appdata-v1.json + * Windows: %APPDATA%\Studio\appdata-v1.json + */ +function getOldAppdataPath(): string { + if ( process.env.E2E && process.env.E2E_APP_DATA_PATH ) { + return path.join( process.env.E2E_APP_DATA_PATH, 'Studio', 'appdata-v1.json' ); + } + if ( process.platform === 'win32' ) { + return path.join( process.env.APPDATA || '', 'Studio', 'appdata-v1.json' ); + } + return path.join( os.homedir(), 'Library', 'Application Support', 'Studio', 'appdata-v1.json' ); +} + +const sharedConfigExtractSchema = z.object( { + ...sharedConfigSchema.omit( { version: true } ).shape, +} ); + +const cliSiteSchema = siteDetailsSchema.extend( { + url: z.string().optional(), + latestCliPid: z.number().optional(), +} ); + +function buildSharedConfig( oldData: Record< string, unknown > ): Record< string, unknown > { + const parsed = sharedConfigExtractSchema.safeParse( oldData ); + return { version: 1, ...( parsed.success ? parsed.data : {} ) }; +} + +function buildCliConfig( oldData: Record< string, unknown > ): Record< string, unknown > { + const config: Record< string, unknown > = { + version: 1, + sites: [], + snapshots: [], + }; + + if ( Array.isArray( oldData.sites ) ) { + config.sites = oldData.sites.reduce( ( acc: unknown[], site: Record< string, unknown > ) => { + const result = cliSiteSchema.safeParse( site ); + if ( result.success ) { + acc.push( result.data ); + } else { + Sentry.captureException( result.error, { + extra: { siteId: site.id, context: 'migrate-to-split-config' }, + } ); + } + return acc; + }, [] ); + } + + if ( Array.isArray( oldData.snapshots ) ) { + config.snapshots = oldData.snapshots.reduce( + ( acc: unknown[], snapshot: Record< string, unknown > ) => { + const result = snapshotSchema.safeParse( snapshot ); + if ( result.success ) { + acc.push( result.data ); + } else { + Sentry.captureException( result.error, { + extra: { snapshotUrl: snapshot.url, context: 'migrate-to-split-config' }, + } ); + } + return acc; + }, + [] + ); + } + + if ( typeof oldData.aiProvider === 'string' ) { + config.aiProvider = oldData.aiProvider; + } + + if ( typeof oldData.anthropicApiKey === 'string' ) { + config.anthropicApiKey = oldData.anthropicApiKey; + } + + return config; +} + +// Top-level fields that moved to shared.json or cli.json (excluded from app.json). +const movedTopLevelFields = new Set( [ + ...Object.keys( sharedConfigExtractSchema.shape ), + 'sites', + 'snapshots', + 'version', +] ); + +// Per-site fields managed by CLI or runtime β€” excluded from app.json site entries. +const excludedSiteFields = new Set( [ + ...Object.keys( cliSiteSchema.shape ), + ...Object.keys( snapshotSchema.shape ), + 'id', + 'running', +] ); + +function pickAppSiteMetadata( site: Record< string, unknown > ): Record< string, unknown > { + const result: Record< string, unknown > = {}; + for ( const key of Object.keys( site ) ) { + if ( ! excludedSiteFields.has( key ) ) { + result[ key ] = site[ key ]; + } + } + return result; +} + +function buildAppConfig( oldData: Record< string, unknown > ): Record< string, unknown > { + const config: Record< string, unknown > = { version: 1 }; + + for ( const key of Object.keys( oldData ) ) { + if ( ! movedTopLevelFields.has( key ) ) { + config[ key ] = oldData[ key ]; + } + } + + if ( Array.isArray( oldData.sites ) ) { + const sitesRecord: Record< string, Record< string, unknown > > = {}; + for ( const site of oldData.sites ) { + const id = site.id as string; + if ( ! id ) { + continue; + } + const fields = pickAppSiteMetadata( site ); + if ( Object.keys( fields ).length > 0 ) { + sitesRecord[ id ] = fields; + } + } + + if ( Object.keys( sitesRecord ).length > 0 ) { + config.siteMetadata = sitesRecord; + } + } + + return config; +} + +async function writeJsonFile( filePath: string, data: Record< string, unknown > ): Promise< void > { + const dir = path.dirname( filePath ); + if ( ! fs.existsSync( dir ) ) { + fs.mkdirSync( dir, { recursive: true } ); + } + const content = JSON.stringify( data, null, 2 ) + '\n'; + await writeFile( filePath, content, { encoding: 'utf8' } ); +} + +export const migrateAppConfig: Migration = { + async needsToRun() { + const newAppdataPath = getAppConfigPath(); + if ( fs.existsSync( newAppdataPath ) ) { + return false; + } + const oldPath = getOldAppdataPath(); + return fs.existsSync( oldPath ); + }, + async run() { + const oldPath = getOldAppdataPath(); + const rawContent = await readFile( oldPath, { encoding: 'utf8' } ); + const oldData: Record< string, unknown > = JSON.parse( rawContent ); + + // Write shared.json and cli.json first β€” if the process crashes before writing + // app.json, the next boot will retry the migration since we check for + // app.json existence as the completion marker. + const sharedConfigPath = getSharedConfigPath(); + if ( ! fs.existsSync( sharedConfigPath ) ) { + await writeJsonFile( sharedConfigPath, buildSharedConfig( oldData ) ); + console.log( `Migrated auth/locale to ${ sanitizeUserpath( sharedConfigPath ) }` ); + } + + const cliConfigPath = getCliConfigPath(); + if ( ! fs.existsSync( cliConfigPath ) ) { + await writeJsonFile( cliConfigPath, buildCliConfig( oldData ) ); + console.log( `Migrated sites/snapshots to ${ sanitizeUserpath( cliConfigPath ) }` ); + } + + const newAppdataPath = getAppConfigPath(); + await writeJsonFile( newAppdataPath, buildAppConfig( oldData ) ); + console.log( + `Migrated Desktop settings from ${ sanitizeUserpath( oldPath ) } to ${ sanitizeUserpath( + newAppdataPath + ) }` + ); + }, +}; diff --git a/apps/studio/src/migrations/index.ts b/apps/studio/src/migrations/index.ts new file mode 100644 index 0000000000..a07489333d --- /dev/null +++ b/apps/studio/src/migrations/index.ts @@ -0,0 +1,10 @@ +import { migrateFromWpNowFolder } from './00-migrate-from-wp-now-folder'; +import { renameLaunchUniquesStat } from './01-rename-launch-uniques-stat'; +import { migrateAppConfig } from './02-migrate-to-split-config'; +import type { Migration } from '@studio/common/lib/migration'; + +export const migrations: Migration[] = [ + migrateAppConfig, + migrateFromWpNowFolder, + renameLaunchUniquesStat, +]; diff --git a/apps/studio/src/migrations/migrate-from-wp-now-folder.ts b/apps/studio/src/migrations/migrate-from-wp-now-folder.ts deleted file mode 100644 index 6384e5901e..0000000000 --- a/apps/studio/src/migrations/migrate-from-wp-now-folder.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { app } from 'electron'; -import path from 'path'; -import { pathExists, recursiveCopyDirectory } from '@studio/common/lib/fs-utils'; -import { getServerFilesPath } from 'src/storage/paths'; -import { loadUserData } from 'src/storage/user-data'; - -const wpNowPath = path.join( app.getPath( 'home' ), '.wp-now' ); - -// Database and server files are no longer stored in ~/.wp-now -// In order to help our early adopters, we'll do a one-time migration from -// ~/.wp-now to ~/Library/Application Support/Studio -export async function needsToMigrateFromWpNowFolder() { - if ( ! ( await pathExists( wpNowPath ) ) ) { - return false; - } - - if ( await pathExists( getServerFilesPath() ) ) { - // Either the migration has already been done, or they weren't an early adopter. - return false; - } - - // Only copy ~/.wp-now if at least one of those sites refers to a site - // in our app. - const { sites } = await loadUserData(); - return !! sites.length; -} - -export async function migrateFromWpNowFolder() { - await recursiveCopyDirectory( wpNowPath, getServerFilesPath() ); -} diff --git a/apps/studio/src/migrations/remove-sites-with-empty-dirs.ts b/apps/studio/src/migrations/remove-sites-with-empty-dirs.ts deleted file mode 100644 index 75893b13da..0000000000 --- a/apps/studio/src/migrations/remove-sites-with-empty-dirs.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { isEmptyDir } from '@studio/common/lib/fs-utils'; -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; - -export async function removeSitesWithEmptyDirectories() { - try { - await lockAppdata(); - const userData = await loadUserData(); - const sitesWithNonEmptyDirectories: SiteDetails[] = []; - const storedSites = userData.sites || []; - for ( const site of storedSites ) { - if ( ! site.path ) { - continue; - } - const directoryIsEmpty = await isEmptyDir( site.path ); - if ( ! directoryIsEmpty ) { - sitesWithNonEmptyDirectories.push( site ); - } - } - await saveUserData( { ...userData, sites: sitesWithNonEmptyDirectories } ); - } finally { - await unlockAppdata(); - } -} diff --git a/apps/studio/src/migrations/rename-launch-uniques-stat.ts b/apps/studio/src/migrations/rename-launch-uniques-stat.ts deleted file mode 100644 index 0c8d09bab8..0000000000 --- a/apps/studio/src/migrations/rename-launch-uniques-stat.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { StatsGroup } from 'src/lib/bump-stats'; -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; - -export async function renameLaunchUniquesStat() { - const userData = await loadUserData(); - const lastBumpStat = userData.lastBumpStats?.[ 'local-environment-launch-uniques' ]; - - if ( lastBumpStat === undefined ) { - return; - } - - userData.lastBumpStats![ StatsGroup.STUDIO_APP_LAUNCH_UNIQUE ] = lastBumpStat; - delete userData.lastBumpStats![ 'local-environment-launch-uniques' ]; - - try { - await lockAppdata(); - await saveUserData( userData ); - } finally { - await unlockAppdata(); - } -} diff --git a/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts b/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts new file mode 100644 index 0000000000..360dfef51e --- /dev/null +++ b/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts @@ -0,0 +1,534 @@ +/** + * @vitest-environment node + */ +import { siteDetailsSchema } from '@studio/common/lib/cli-events'; +import { authTokenSchema, sharedConfigSchema } from '@studio/common/lib/shared-config'; +import { snapshotSchema } from '@studio/common/types/snapshot'; +import { readFile, writeFile } from 'atomically'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { z } from 'zod'; +import { migrateAppConfig } from 'src/migrations/02-migrate-to-split-config'; + +async function runMigration() { + if ( await migrateAppConfig.needsToRun() ) { + await migrateAppConfig.run(); + } +} + +const { mockFsExistsSync, mockFsMkdirSync } = vi.hoisted( () => ( { + mockFsExistsSync: vi.fn(), + mockFsMkdirSync: vi.fn(), +} ) ); + +vi.mock( 'fs', () => ( { + default: { + existsSync: mockFsExistsSync, + mkdirSync: mockFsMkdirSync, + }, + existsSync: mockFsExistsSync, + mkdirSync: mockFsMkdirSync, +} ) ); + +vi.mock( 'atomically', () => ( { + readFile: vi.fn(), + writeFile: vi.fn(), +} ) ); + +vi.mock( 'src/lib/sanitize-for-logging', () => ( { + sanitizeUserpath: ( p: string ) => p, +} ) ); + +// Validation schemas matching the actual config file schemas. +// shared.json schema +const sharedConfigValidationSchema = sharedConfigSchema; + +// cli.json site schema (matches cli-config/core.ts siteSchema) +const cliSiteValidationSchema = siteDetailsSchema + .extend( { + url: z.string().optional(), + latestCliPid: z.number().optional(), + } ) + .loose(); + +const cliConfigValidationSchema = z.object( { + version: z.literal( 1 ), + sites: z.array( cliSiteValidationSchema ), + snapshots: z.array( snapshotSchema ), +} ); + +// app.json schema β€” Desktop-only top-level fields + per-site Desktop fields (keyed by id) +const appSiteValidationSchema = z + .object( { + themeDetails: z + .object( { + name: z.string(), + path: z.string(), + slug: z.string(), + isBlockTheme: z.boolean(), + supportsWidgets: z.boolean(), + supportsMenus: z.boolean(), + } ) + .optional(), + sortOrder: z.number().optional(), + } ) + .strict(); + +const appConfigValidationSchema = z + .object( { + version: z.literal( 1 ), + siteMetadata: z.record( z.string(), appSiteValidationSchema ).optional(), + devToolsOpen: z.boolean().optional(), + windowBounds: z + .object( { + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + isFullScreen: z.boolean().optional(), + } ) + .optional(), + onboardingCompleted: z.boolean().optional(), + lastBumpStats: z.record( z.string(), z.unknown() ).optional(), + promptWindowsSpeedUpResult: z + .object( { + response: z.enum( [ 'yes', 'no' ] ), + appVersion: z.string(), + dontAskAgain: z.boolean(), + } ) + .optional(), + connectedWpcomSites: z.record( z.string(), z.unknown() ).optional(), + sentryUserId: z.string().optional(), + lastSeenVersion: z.string().optional(), + preferredTerminal: z.string().optional(), + preferredEditor: z.string().optional(), + betaFeatures: z.unknown().optional(), + stopSitesOnQuit: z.boolean().optional(), + } ) + .strict(); + +/** + * A realistic old appdata-v1.json with all fields populated. + */ +function createOldAppdata() { + return { + version: 1, + // Fields β†’ shared.json + authToken: { + accessToken: 'test-token-123', + expiresIn: 1209600, + expirationTime: 1900000000000, + id: 42, + email: 'test@example.com', + displayName: 'Test User', + }, + locale: 'pt-br', + // Fields β†’ cli.json + sites: [ + { + id: 'site-1', + name: 'My Site', + path: '/home/user/Studio/my-site', + port: 8881, + phpVersion: '8.2', + customDomain: 'mysite.local', + enableHttps: true, + adminUsername: 'admin', + adminPassword: 'password', + adminEmail: 'admin@example.com', + isWpAutoUpdating: false, + autoStart: true, + latestCliPid: 12345, + enableXdebug: false, + enableDebugLog: true, + enableDebugDisplay: false, + // Fields β†’ app.json (Desktop-only per-site) + themeDetails: { + name: 'Twenty Twenty-Four', + path: '/themes/twentytwentyfour', + slug: 'twentytwentyfour', + isBlockTheme: true, + supportsWidgets: false, + supportsMenus: false, + }, + sortOrder: 0, + // Runtime field that should be stripped + running: false, + }, + { + id: 'site-2', + name: 'Another Site', + path: '/home/user/Studio/another-site', + port: 8882, + phpVersion: '8.1', + themeDetails: { + name: 'Starter Theme', + path: '/themes/starter', + slug: 'starter', + isBlockTheme: false, + supportsWidgets: true, + supportsMenus: true, + }, + sortOrder: 1, + running: true, + }, + ], + snapshots: [ + { + url: 'https://preview.wp.com/snap1', + atomicSiteId: 100, + localSiteId: 'site-1', + date: 1710000000000, + name: 'Snapshot 1', + userId: 42, + }, + ], + // Fields β†’ app.json (Desktop-only top-level) + devToolsOpen: true, + windowBounds: { x: 100, y: 200, width: 1200, height: 800 }, + onboardingCompleted: true, + lastBumpStats: { 'studio-app-launch': { mac_arm64: 5 } }, + promptWindowsSpeedUpResult: { + response: 'no' as const, + appVersion: '1.7.0', + dontAskAgain: true, + }, + connectedWpcomSites: { 42: [ { id: 1, name: 'Remote Site', url: 'https://remote.wp.com' } ] }, + sentryUserId: 'sentry-uuid-123', + lastSeenVersion: '1.7.0', + preferredTerminal: 'iterm', + preferredEditor: 'vscode', + betaFeatures: { ai: true }, + stopSitesOnQuit: false, + }; +} + +/** + * Returns the written JSON for a given file path from writeFile mock calls. + */ +function getWrittenJson( filePath: string ): Record< string, unknown > | undefined { + const mockedWriteFile = vi.mocked( writeFile ); + const call = mockedWriteFile.mock.calls.find( ( [ path ] ) => + ( path as string ).endsWith( filePath ) + ); + if ( ! call ) { + return undefined; + } + return JSON.parse( call[ 1 ] as string ); +} + +describe( 'migrateAppConfig', () => { + beforeEach( () => { + vi.clearAllMocks(); + // By default: no new files exist, old file exists + mockFsExistsSync.mockImplementation( ( p: string ) => { + if ( p.includes( 'appdata-v1.json' ) ) { + return true; + } + return false; + } ); + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( createOldAppdata() ) ) ); + } ); + + it( 'does not need to run if new app.json already exists', async () => { + mockFsExistsSync.mockImplementation( ( p: string ) => { + if ( p.endsWith( 'app.json' ) && p.includes( '.studio' ) ) { + return true; + } + return false; + } ); + + expect( await migrateAppConfig.needsToRun() ).toBe( false ); + } ); + + it( 'does not need to run if old appdata-v1.json does not exist', async () => { + mockFsExistsSync.mockReturnValue( false ); + + expect( await migrateAppConfig.needsToRun() ).toBe( false ); + } ); + + it( 'throws when old appdata is not valid JSON', async () => { + vi.mocked( readFile ).mockResolvedValue( Buffer.from( 'not valid json {{{' ) ); + + expect( await migrateAppConfig.needsToRun() ).toBe( true ); + await expect( migrateAppConfig.run() ).rejects.toThrow( SyntaxError ); + + expect( writeFile ).not.toHaveBeenCalled(); + } ); + + it( 'writes three config files from old appdata', async () => { + await runMigration(); + + expect( writeFile ).toHaveBeenCalledTimes( 3 ); + } ); + + describe( 'shared.json', () => { + it( 'contains auth token and locale matching the shared config schema', async () => { + await runMigration(); + + const shared = getWrittenJson( 'shared.json' ); + expect( shared ).toBeDefined(); + + // Validate against the real shared config schema + const result = sharedConfigValidationSchema.safeParse( shared ); + expect( result.success ).toBe( true ); + } ); + + it( 'preserves the auth token data', async () => { + await runMigration(); + + const shared = getWrittenJson( 'shared.json' ); + const oldData = createOldAppdata(); + + // Validate the token matches the authTokenSchema + const tokenResult = authTokenSchema.safeParse( shared?.authToken ); + expect( tokenResult.success ).toBe( true ); + expect( shared?.authToken ).toEqual( oldData.authToken ); + expect( shared?.locale ).toBe( 'pt-br' ); + } ); + + it( 'handles missing auth token and locale gracefully', async () => { + const oldData = createOldAppdata(); + + const { authToken, locale, ...rest } = oldData; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( rest ) ) ); + + await runMigration(); + + const shared = getWrittenJson( 'shared.json' ); + expect( shared ).toEqual( { version: 1 } ); + expect( sharedConfigValidationSchema.safeParse( shared ).success ).toBe( true ); + } ); + + it( 'does not include non-shared fields', async () => { + await runMigration(); + + const shared = getWrittenJson( 'shared.json' ); + expect( shared ).not.toHaveProperty( 'sites' ); + expect( shared ).not.toHaveProperty( 'snapshots' ); + expect( shared ).not.toHaveProperty( 'devToolsOpen' ); + expect( shared ).not.toHaveProperty( 'windowBounds' ); + } ); + } ); + + describe( 'cli.json', () => { + it( 'contains sites and snapshots matching the CLI config schema', async () => { + await runMigration(); + + const cli = getWrittenJson( 'cli.json' ); + expect( cli ).toBeDefined(); + + const result = cliConfigValidationSchema.safeParse( cli ); + expect( result.success ).toBe( true ); + } ); + + it( 'includes only CLI-relevant site fields', async () => { + await runMigration(); + + const cli = getWrittenJson( 'cli.json' ); + const sites = cli?.sites as Record< string, unknown >[]; + + expect( sites ).toHaveLength( 2 ); + + // Should have CLI fields + expect( sites[ 0 ] ).toHaveProperty( 'id', 'site-1' ); + expect( sites[ 0 ] ).toHaveProperty( 'name', 'My Site' ); + expect( sites[ 0 ] ).toHaveProperty( 'port', 8881 ); + expect( sites[ 0 ] ).toHaveProperty( 'customDomain', 'mysite.local' ); + expect( sites[ 0 ] ).toHaveProperty( 'enableHttps', true ); + expect( sites[ 0 ] ).toHaveProperty( 'latestCliPid', 12345 ); + + // Should NOT have Desktop-only fields + expect( sites[ 0 ] ).not.toHaveProperty( 'themeDetails' ); + expect( sites[ 0 ] ).not.toHaveProperty( 'sortOrder' ); + expect( sites[ 0 ] ).not.toHaveProperty( 'running' ); + } ); + + it( 'preserves snapshots as-is', async () => { + await runMigration(); + + const cli = getWrittenJson( 'cli.json' ); + const oldData = createOldAppdata(); + + expect( cli?.snapshots ).toEqual( oldData.snapshots ); + + // Validate each snapshot against the schema + const snapshots = cli?.snapshots as unknown[]; + for ( const snapshot of snapshots ) { + expect( snapshotSchema.safeParse( snapshot ).success ).toBe( true ); + } + } ); + + it( 'handles empty sites and snapshots', async () => { + const oldData = createOldAppdata(); + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { ...oldData, sites: [], snapshots: [] } ) ) + ); + + await runMigration(); + + const cli = getWrittenJson( 'cli.json' ); + expect( cli?.sites ).toEqual( [] ); + expect( cli?.snapshots ).toEqual( [] ); + expect( cliConfigValidationSchema.safeParse( cli ).success ).toBe( true ); + } ); + + it( 'handles missing sites and snapshots arrays', async () => { + const oldData = createOldAppdata(); + + const { sites, snapshots, ...rest } = oldData; + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( rest ) ) ); + + await runMigration(); + + const cli = getWrittenJson( 'cli.json' ); + expect( cli?.sites ).toEqual( [] ); + expect( cli?.snapshots ).toEqual( [] ); + expect( cliConfigValidationSchema.safeParse( cli ).success ).toBe( true ); + } ); + } ); + + describe( 'app.json', () => { + it( 'contains Desktop-only fields matching the app config schema', async () => { + await runMigration(); + + const appConfig = getWrittenJson( 'app.json' ); + expect( appConfig ).toBeDefined(); + + const result = appConfigValidationSchema.safeParse( appConfig ); + expect( result.success ).toBe( true ); + } ); + + it( 'preserves all Desktop-only top-level fields', async () => { + await runMigration(); + + const appConfig = getWrittenJson( 'app.json' ); + const oldData = createOldAppdata(); + + expect( appConfig?.devToolsOpen ).toBe( oldData.devToolsOpen ); + expect( appConfig?.windowBounds ).toEqual( oldData.windowBounds ); + expect( appConfig?.onboardingCompleted ).toBe( oldData.onboardingCompleted ); + expect( appConfig?.lastBumpStats ).toEqual( oldData.lastBumpStats ); + expect( appConfig?.promptWindowsSpeedUpResult ).toEqual( oldData.promptWindowsSpeedUpResult ); + expect( appConfig?.connectedWpcomSites ).toEqual( oldData.connectedWpcomSites ); + expect( appConfig?.sentryUserId ).toBe( oldData.sentryUserId ); + expect( appConfig?.lastSeenVersion ).toBe( oldData.lastSeenVersion ); + expect( appConfig?.preferredTerminal ).toBe( oldData.preferredTerminal ); + expect( appConfig?.preferredEditor ).toBe( oldData.preferredEditor ); + expect( appConfig?.betaFeatures ).toEqual( oldData.betaFeatures ); + expect( appConfig?.stopSitesOnQuit ).toBe( oldData.stopSitesOnQuit ); + } ); + + it( 'does not include fields that moved to shared.json or cli.json', async () => { + await runMigration(); + + const appConfig = getWrittenJson( 'app.json' ); + + expect( appConfig ).not.toHaveProperty( 'authToken' ); + expect( appConfig ).not.toHaveProperty( 'locale' ); + expect( appConfig ).not.toHaveProperty( 'snapshots' ); + } ); + + it( 'keeps per-site Desktop fields (themeDetails, sortOrder) keyed by id', async () => { + await runMigration(); + + const appConfig = getWrittenJson( 'app.json' ); + const sites = appConfig?.siteMetadata as Record< string, Record< string, unknown > >; + const oldData = createOldAppdata(); + + expect( Object.keys( sites ) ).toHaveLength( 2 ); + + // Should have Desktop-only fields keyed by site id + expect( sites[ 'site-1' ] ).toEqual( { + themeDetails: oldData.sites[ 0 ].themeDetails, + sortOrder: 0, + } ); + + // Should NOT have CLI fields + expect( sites[ 'site-1' ] ).not.toHaveProperty( 'id' ); + expect( sites[ 'site-1' ] ).not.toHaveProperty( 'name' ); + expect( sites[ 'site-1' ] ).not.toHaveProperty( 'path' ); + expect( sites[ 'site-1' ] ).not.toHaveProperty( 'port' ); + expect( sites[ 'site-1' ] ).not.toHaveProperty( 'running' ); + } ); + + it( 'omits sites array when no sites have Desktop-specific data', async () => { + const oldData = createOldAppdata(); + const sitesWithoutDesktopFields = oldData.sites.map( + ( { themeDetails, sortOrder, ...rest } ) => rest + ); + vi.mocked( readFile ).mockResolvedValue( + Buffer.from( JSON.stringify( { ...oldData, sites: sitesWithoutDesktopFields } ) ) + ); + + await runMigration(); + + const appConfig = getWrittenJson( 'app.json' ); + expect( appConfig ).not.toHaveProperty( 'siteMetadata' ); + expect( appConfigValidationSchema.safeParse( appConfig ).success ).toBe( true ); + } ); + } ); + + describe( 'partial migration recovery', () => { + it( 'does not overwrite shared.json if it already exists', async () => { + mockFsExistsSync.mockImplementation( ( p: string ) => { + if ( p.includes( 'appdata-v1.json' ) ) { + return true; + } + if ( p.endsWith( 'shared.json' ) ) { + return true; + } + return false; + } ); + + await runMigration(); + + // Should write cli.json and app.json but not shared.json + expect( writeFile ).toHaveBeenCalledTimes( 2 ); + const writtenPaths = vi.mocked( writeFile ).mock.calls.map( ( [ p ] ) => p as string ); + expect( writtenPaths.some( ( p ) => p.endsWith( 'shared.json' ) ) ).toBe( false ); + expect( writtenPaths.some( ( p ) => p.endsWith( 'cli.json' ) ) ).toBe( true ); + expect( writtenPaths.some( ( p ) => p.endsWith( 'app.json' ) ) ).toBe( true ); + } ); + + it( 'does not overwrite cli.json if it already exists', async () => { + mockFsExistsSync.mockImplementation( ( p: string ) => { + if ( p.includes( 'appdata-v1.json' ) ) { + return true; + } + if ( p.endsWith( 'cli.json' ) ) { + return true; + } + return false; + } ); + + await runMigration(); + + // Should write shared.json and app.json but not cli.json + expect( writeFile ).toHaveBeenCalledTimes( 2 ); + const writtenPaths = vi.mocked( writeFile ).mock.calls.map( ( [ p ] ) => p as string ); + expect( writtenPaths.some( ( p ) => p.endsWith( 'cli.json' ) ) ).toBe( false ); + expect( writtenPaths.some( ( p ) => p.endsWith( 'shared.json' ) ) ).toBe( true ); + expect( writtenPaths.some( ( p ) => p.endsWith( 'app.json' ) ) ).toBe( true ); + } ); + } ); + + describe( 'minimal old appdata', () => { + it( 'handles an old appdata with only version field', async () => { + vi.mocked( readFile ).mockResolvedValue( Buffer.from( JSON.stringify( { version: 1 } ) ) ); + + await runMigration(); + + const shared = getWrittenJson( 'shared.json' ); + expect( shared ).toEqual( { version: 1 } ); + expect( sharedConfigValidationSchema.safeParse( shared ).success ).toBe( true ); + + const cli = getWrittenJson( 'cli.json' ); + expect( cli ).toEqual( { version: 1, sites: [], snapshots: [] } ); + expect( cliConfigValidationSchema.safeParse( cli ).success ).toBe( true ); + + const appConfig = getWrittenJson( 'app.json' ); + expect( appConfig ).toEqual( { version: 1 } ); + expect( appConfigValidationSchema.safeParse( appConfig ).success ).toBe( true ); + } ); + } ); +} ); diff --git a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index 47b13b5caf..f828e968bd 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -28,7 +28,7 @@ const handleSiteEvent = sequential( async ( event: SiteEvent ): Promise< void > const { event: eventType, siteId, site, running } = event; if ( eventType === SITE_EVENTS.DELETED ) { - SiteServer.unregister( siteId ); + await SiteServer.unregister( siteId ); void sendIpcEventToRenderer( 'site-event', event ); return; } diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index a2e098639d..0fdca31dc0 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -158,9 +158,10 @@ export class SiteServer { return server; } - static unregister( id: string ): void { + static async unregister( id: string ): Promise< void > { deletedServers.push( id ); servers.delete( id ); + await SiteServer.deleteSiteMetadata( id ); } static async create( @@ -196,6 +197,20 @@ export class SiteServer { await this.server.delete( deleteFiles ); deletedServers.push( this.details.id ); servers.delete( this.details.id ); + await SiteServer.deleteSiteMetadata( this.details.id ); + } + + private static async deleteSiteMetadata( id: string ) { + try { + await lockAppdata(); + const userData = await loadUserData(); + if ( userData.siteMetadata[ id ] ) { + delete userData.siteMetadata[ id ]; + await saveUserData( userData ); + } + } finally { + await unlockAppdata(); + } } async start() { @@ -412,10 +427,11 @@ export class SiteServer { try { await lockAppdata(); const userData = await loadUserData(); - const existingSite = userData.sites.find( ( site ) => site.id === this.details.id ); - if ( existingSite ) { - existingSite.themeDetails = this.details.themeDetails; - } + const siteId = this.details.id; + userData.siteMetadata[ siteId ] = { + ...userData.siteMetadata[ siteId ], + themeDetails: this.details.themeDetails, + }; await saveUserData( userData ); } finally { await unlockAppdata(); diff --git a/apps/studio/src/storage/paths.ts b/apps/studio/src/storage/paths.ts index db7a68f282..5df8a53c57 100644 --- a/apps/studio/src/storage/paths.ts +++ b/apps/studio/src/storage/paths.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { LOCKFILE_NAME } from '@studio/common/constants'; +import { getAppConfigPath } from '@studio/common/lib/config-paths'; function inChildProcess() { return process.env.STUDIO_IN_CHILD_PROCESS === 'true'; @@ -19,11 +19,7 @@ export function getUserDataFilePath(): string { if ( process.env.DEV_APP_DATA_PATH ) { return process.env.DEV_APP_DATA_PATH; } - return path.join( getAppDataPath(), getAppName(), 'appdata-v1.json' ); -} - -export function getUserDataLockFilePath(): string { - return path.join( getAppDataPath(), getAppName(), LOCKFILE_NAME ); + return getAppConfigPath(); } export function getServerFilesPath(): string { diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 7e8744142c..e838ae1ab3 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -11,8 +11,14 @@ export interface WindowBounds { isFullScreen?: boolean; } +export interface AppdataSiteData { + themeDetails?: SiteDetails[ 'themeDetails' ]; + sortOrder?: number; +} + export interface UserData { - sites: SiteDetails[]; + version: 1; + siteMetadata: Record< string, AppdataSiteData >; devToolsOpen?: boolean; windowBounds?: WindowBounds; onboardingCompleted?: boolean; @@ -28,17 +34,13 @@ export interface UserData { stopSitesOnQuit?: boolean; } -export interface PersistedUserData extends Omit< UserData, 'sites' > { - version: 1; - - // Users can edit the file system manually which would make UserData['name'] and UserData['path'] - // get out of sync. `name` is redundant because it can be calculated from `path`, so we - // won't persist `name`. - sites: Omit< StoppedSiteDetails, 'running' >[]; -} - export interface PromptWindowsSpeedUpResult { response: 'yes' | 'no'; appVersion: string; dontAskAgain: boolean; } + +export const EMPTY_USER_DATA: UserData = { + version: 1, + siteMetadata: {}, +}; diff --git a/apps/studio/src/storage/tests/user-data.test.ts b/apps/studio/src/storage/tests/user-data.test.ts index 5288b5c32e..d480e34e9d 100644 --- a/apps/studio/src/storage/tests/user-data.test.ts +++ b/apps/studio/src/storage/tests/user-data.test.ts @@ -2,149 +2,114 @@ * @vitest-environment node */ // To run tests, execute `npm run test -- src/storage/user-data.test.ts` from the root directory -import { platformTestSuite } from '@studio/common/lib/tests/utils/platform-test-suite'; import { readFile, writeFile } from 'atomically'; import { vi } from 'vitest'; import { loadUserData, lockAppdata, unlockAppdata, saveUserData } from 'src/storage/user-data'; import { UserData } from '../storage-types'; -const { - getResourcesPathMock, - getUserDataFilePathMock, - getUserDataLockFilePathMock, - mockElectronApp, - mockFsExistsSync, -} = vi.hoisted( () => { - const mockApp = { - getPath: vi.fn().mockReturnValue( '/path/to/app/appData' ), - }; - return { - getResourcesPathMock: vi.fn().mockReturnValue( '/path/to/app/appData/App Name' ), - getUserDataFilePathMock: vi - .fn() - .mockReturnValue( '/path/to/app/appData/App Name/appdata-v1.json' ), - getUserDataLockFilePathMock: vi - .fn() - .mockReturnValue( '/path/to/app/appData/App Name/appdata-v1.json.lock' ), - mockElectronApp: mockApp, - mockFsExistsSync: vi.fn(), - }; -} ); - -vi.mock( 'electron', () => ( { - app: mockElectronApp, -} ) ); +const { getUserDataFilePathMock, getAppConfigLockFilePathMock, mockFsExistsSync, mockFsMkdirSync } = + vi.hoisted( () => { + return { + getUserDataFilePathMock: vi.fn().mockReturnValue( '/path/to/app/.studio/app.json' ), + getAppConfigLockFilePathMock: vi.fn().mockReturnValue( '/path/to/app/.studio/app.json.lock' ), + mockFsExistsSync: vi.fn().mockReturnValue( true ), + mockFsMkdirSync: vi.fn(), + }; + } ); vi.mock( 'fs', () => ( { default: { existsSync: mockFsExistsSync, - renameSync: vi.fn(), + mkdirSync: mockFsMkdirSync, }, existsSync: mockFsExistsSync, - renameSync: vi.fn(), + mkdirSync: mockFsMkdirSync, } ) ); vi.mock( 'src/storage/paths', () => ( { - getResourcesPath: getResourcesPathMock, getUserDataFilePath: getUserDataFilePathMock, - getUserDataLockFilePath: getUserDataLockFilePathMock, +} ) ); +vi.mock( '@studio/common/lib/config-paths', () => ( { + getAppConfigLockFilePath: getAppConfigLockFilePathMock, } ) ); vi.mock( 'atomically', () => ( { readFile: vi.fn().mockResolvedValue( - JSON.stringify( { - sites: [ - { name: 'Tristan', path: '/to/tristan' }, - { name: 'Arthur', path: '/to/arthur' }, - { name: 'Lancelot', path: '/to/lancelot' }, - ], - } ) + Buffer.from( + JSON.stringify( { + version: 1, + siteMetadata: { + 'site-1': { sortOrder: 0 }, + 'site-2': { sortOrder: 1 }, + }, + onboardingCompleted: true, + } ) + ) ), writeFile: vi.fn(), } ) ); -const mockedUserData: RecursivePartial< UserData > = { - sites: [ - { name: 'Tristan', path: '/to/tristan' }, - { name: 'Arthur', path: '/to/arthur' }, - { name: 'Lancelot', path: '/to/lancelot' }, - ], -}; - -const defaultThemeDetails = { - name: '', - path: '', - slug: '', - isBlockTheme: false, - supportsWidgets: false, - supportsMenus: false, +const mockedUserData: UserData = { + version: 1, + siteMetadata: { + 'site-1': { + sortOrder: 0, + themeDetails: { + name: 'Twenty Twenty-Four', + path: '/themes/twentytwentyfour', + slug: 'twentytwentyfour', + isBlockTheme: true, + supportsWidgets: false, + supportsMenus: false, + }, + }, + 'site-2': { sortOrder: 1 }, + }, + onboardingCompleted: true, }; -platformTestSuite( 'User data', () => { - beforeEach( () => { - // Assume each site path exists - mockFsExistsSync.mockReturnValue( true ); - // Reset other mocks to ensure clean state - mockElectronApp.getPath.mockReturnValue( '/path/to/app/appData' ); +describe( 'User data', () => { + afterEach( () => { + vi.clearAllMocks(); } ); - afterEach( () => {} ); - describe( 'loadUserData', () => { - test( 'loads user data correctly and sorts sites', async () => { + test( 'loads user data with siteMetadata as record', async () => { const result = await loadUserData(); - expect( result.sites.map( ( site ) => site.name ) ).toEqual( [ - 'Arthur', - 'Lancelot', - 'Tristan', - ] ); + expect( result.siteMetadata ).toEqual( { + 'site-1': { sortOrder: 0 }, + 'site-2': { sortOrder: 1 }, + } ); + expect( result.onboardingCompleted ).toBe( true ); } ); - test( 'Filters out sites where the path does not exist', async () => { - mockFsExistsSync.mockImplementation( ( path ) => path === '/to/lancelot' ); + test( 'returns empty siteMetadata record when file does not exist', async () => { + vi.mocked( readFile ).mockRejectedValue( + Object.assign( new Error( 'ENOENT' ), { code: 'ENOENT' } ) + ); + const result = await loadUserData(); - expect( result.sites.map( ( sites ) => sites.name ) ).toEqual( [ 'Lancelot' ] ); + expect( result ).toEqual( { version: 1, siteMetadata: {} } ); } ); - test( 'populates PHP version when unknown', async () => { - vi.mocked( readFile ).mockResolvedValue( - Buffer.from( - JSON.stringify( { - sites: [ - { name: 'Arthur', path: '/to/arthur', phpVersion: '8.3' }, - { name: 'Lancelot', path: '/to/lancelot', phpVersion: '8.1' }, - { name: 'Tristan', path: '/to/tristan' }, - ], - } ) - ) - ); + test( 'normalizes version to 1', async () => { const result = await loadUserData(); - expect( result.sites.map( ( site ) => site.phpVersion ) ).toEqual( [ '8.3', '8.1', '8.0' ] ); + expect( result.version ).toBe( 1 ); } ); } ); describe( 'saveUserData', () => { - test( 'saves user data correctly', async () => { + test( 'saves user data with version field', async () => { try { await lockAppdata(); - await saveUserData( mockedUserData as UserData ); + await saveUserData( mockedUserData ); } finally { await unlockAppdata(); } expect( writeFile ).toHaveBeenCalledWith( - '/path/to/app/appData/App Name/appdata-v1.json', - JSON.stringify( - { - version: 1, - sites: mockedUserData.sites?.map( ( site ) => ( { - ...site, - themeDetails: defaultThemeDetails, - } ) ), - }, - null, - 2 - ) + '\n', + '/path/to/app/.studio/app.json', + JSON.stringify( mockedUserData, null, 2 ) + '\n', 'utf-8' ); } ); diff --git a/apps/studio/src/storage/user-data.ts b/apps/studio/src/storage/user-data.ts index e28f8128f0..ebfb2e9ae6 100644 --- a/apps/studio/src/storage/user-data.ts +++ b/apps/studio/src/storage/user-data.ts @@ -1,98 +1,25 @@ -import { app } from 'electron'; import fs from 'fs'; import nodePath from 'node:path'; import * as Sentry from '@sentry/electron/main'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +import { getAppConfigLockFilePath } from '@studio/common/lib/config-paths'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { sortSites } from '@studio/common/lib/sort-sites'; -import { SupportedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions'; import { readFile, writeFile } from 'atomically'; -import semver from 'semver'; import { sanitizeUnstructuredData, sanitizeUserpath } from 'src/lib/sanitize-for-logging'; -import { getUserDataFilePath, getUserDataLockFilePath } from 'src/storage/paths'; -import type { PersistedUserData, UserData, WindowBounds } from 'src/storage/storage-types'; - -// Before persisting the PHP version of sites, the default PHP version used was 8.0. -// In case we can't retrieve the PHP version from site details, we assume it was created -// with version 8.0. -const DEFAULT_PHP_VERSION_WHEN_UNKNOWN: SupportedPHPVersion = '8.0'; - -const migrateUserData = ( appName: string ) => { - const appDataPath = app.getPath( 'appData' ); - const oldPath = nodePath.join( appDataPath, appName, 'appdata-v1.json' ); - const newPath = getUserDataFilePath(); - - if ( fs.existsSync( oldPath ) && ! fs.existsSync( newPath ) ) { - fs.renameSync( oldPath, newPath ); - console.log( - `Moved user data from ${ sanitizeUserpath( oldPath ) } to ${ sanitizeUserpath( newPath ) }` - ); - } -}; - -// Temporary function to migrate old user data to the new location -// This function will be removed in a future release -function migrateUserDataOldName() { - migrateUserData( 'Local Environment' ); - migrateUserData( 'Build' ); -} - -/** - * Ensures each site has a valid PHP version. If the stored version is unsupported, - * it selects the closest supported version (min if too low, max if too high). - */ -function populatePhpVersion( sites: SiteDetails[] ) { - // Sort versions to reliably find min and max - const sortedVersions = [ ...SupportedPHPVersions ].sort( ( a, b ) => - semver.compare( semver.coerce( a )!, semver.coerce( b )! ) - ); - const minVersion = sortedVersions[ 0 ]; - const maxVersion = sortedVersions[ sortedVersions.length - 1 ]; - const minCoerced = semver.coerce( minVersion )!; - const maxCoerced = semver.coerce( maxVersion )!; - - sites.forEach( ( site ) => { - if ( typeof site.phpVersion === 'undefined' ) { - site.phpVersion = DEFAULT_PHP_VERSION_WHEN_UNKNOWN; - return; - } - - if ( SupportedPHPVersions.includes( site.phpVersion as SupportedPHPVersion ) ) { - return; - } - - const coercedPhpVersion = semver.coerce( site.phpVersion ); - if ( ! coercedPhpVersion ) { - site.phpVersion = DEFAULT_PHP_VERSION_WHEN_UNKNOWN; - return; - } - - if ( semver.lt( coercedPhpVersion, minCoerced ) ) { - site.phpVersion = minVersion; - } else if ( semver.gt( coercedPhpVersion, maxCoerced ) ) { - site.phpVersion = maxVersion; - } else { - site.phpVersion = DEFAULT_PHP_VERSION_WHEN_UNKNOWN; - } - } ); -} +import { getUserDataFilePath } from 'src/storage/paths'; +import { EMPTY_USER_DATA, type UserData, type WindowBounds } from 'src/storage/storage-types'; export async function loadUserData(): Promise< UserData > { - migrateUserDataOldName(); const filePath = getUserDataFilePath(); try { const asString = await readFile( filePath, 'utf-8' ); try { const parsed = JSON.parse( asString ); - const data = fromDiskFormat( parsed ); - - sortSites( data.sites ); - populatePhpVersion( data.sites ); - return data; + const { siteMetadata, ...data } = parsed; + return { ...data, version: 1, siteMetadata: siteMetadata ?? {} }; } catch ( err ) { - // Awkward double try-catch needed to have access to the file contents if ( err instanceof SyntaxError ) { Sentry.addBreadcrumb( { data: { @@ -105,9 +32,7 @@ export async function loadUserData(): Promise< UserData > { } } catch ( err ) { if ( isErrnoException( err ) && err.code === 'ENOENT' ) { - return { - sites: [], - }; + return EMPTY_USER_DATA; } console.error( `Failed to load file ${ sanitizeUserpath( filePath ) }: ${ err }` ); throw err; @@ -116,13 +41,18 @@ export async function loadUserData(): Promise< UserData > { export async function saveUserData( data: UserData ): Promise< void > { const filePath = getUserDataFilePath(); - const asString = JSON.stringify( toDiskFormat( data ), null, 2 ) + '\n'; + const persisted: UserData = { ...data }; + const asString = JSON.stringify( persisted, null, 2 ) + '\n'; await writeFile( filePath, asString, 'utf-8' ); } -const LOCKFILE_PATH = getUserDataLockFilePath(); +const LOCKFILE_PATH = getAppConfigLockFilePath(); export async function lockAppdata() { + const dir = nodePath.dirname( LOCKFILE_PATH ); + if ( ! fs.existsSync( dir ) ) { + fs.mkdirSync( dir, { recursive: true } ); + } return lockFileAsync( LOCKFILE_PATH, { stale: LOCKFILE_STALE_TIME, wait: LOCKFILE_WAIT_TIME } ); } @@ -160,83 +90,6 @@ export async function updateAppdata( } } -function toDiskFormat( { sites, ...rest }: UserData ): PersistedUserData { - return { - version: 1, - sites: sites.map( - ( { - id, - path, - adminUsername, - adminPassword, - adminEmail, - port, - phpVersion, - isWpAutoUpdating, - name, - themeDetails, - customDomain, - enableHttps, - autoStart, - latestCliPid, - enableXdebug, - enableDebugLog, - enableDebugDisplay, - sortOrder, - } ) => { - // No object spreading allowed. TypeScript's structural typing is too permissive and - // will permit us to persist properties that aren't in the type definition. - // Add each property explicitly instead. - const persistedSiteDetails: PersistedUserData[ 'sites' ][ number ] = { - id, - name, - path, - adminUsername, - adminPassword, - adminEmail, - port, - phpVersion, - isWpAutoUpdating, - customDomain, - enableHttps, - autoStart, - latestCliPid, - enableXdebug, - enableDebugLog, - enableDebugDisplay, - sortOrder, - themeDetails: { - name: themeDetails?.name || '', - path: themeDetails?.path || '', - slug: themeDetails?.slug || '', - isBlockTheme: themeDetails?.isBlockTheme || false, - supportsWidgets: themeDetails?.supportsWidgets || false, - supportsMenus: themeDetails?.supportsMenus || false, - }, - }; - - return persistedSiteDetails; - } - ), - ...rest, - }; -} - -function fromDiskFormat( { version, sites, ...rest }: PersistedUserData ): UserData { - return { - sites: sites - .filter( ( site ) => fs.existsSync( site.path ) ) // Remove sites the user has deleted from disk - .map( ( { path, name, autoStart, ...restOfSite } ) => ( { - name: name || nodePath.basename( path ), - path, - running: false, - autoStart: autoStart || false, - ...restOfSite, - } ) ), - ...rest, - }; -} - export async function saveWindowBounds( bounds: WindowBounds ): Promise< void > { await updateAppdata( { windowBounds: bounds } ); } diff --git a/apps/studio/src/stores/tests/installed-apps-api.test.ts b/apps/studio/src/stores/tests/installed-apps-api.test.ts index 8805dc85bd..fc8466808a 100644 --- a/apps/studio/src/stores/tests/installed-apps-api.test.ts +++ b/apps/studio/src/stores/tests/installed-apps-api.test.ts @@ -88,8 +88,7 @@ describe( 'Installed Apps API', () => { const mockUserData = ( preferredEditor?: string ) => { vi.mocked( loadUserData ).mockResolvedValue( { - sites: [], - snapshots: [], + siteMetadata: {}, preferredEditor, } as Awaited< ReturnType< typeof loadUserData > > ); }; diff --git a/apps/studio/src/tests/index.test.ts b/apps/studio/src/tests/index.test.ts index 9ad6dbe83d..4c58f84d0c 100644 --- a/apps/studio/src/tests/index.test.ts +++ b/apps/studio/src/tests/index.test.ts @@ -37,7 +37,6 @@ vi.mock( 'atomically', () => ( { vi.mock( 'src/storage/paths', () => ( { getResourcesPath: vi.fn().mockReturnValue( '/mock/resources' ), getUserDataFilePath: vi.fn().mockReturnValue( '/mock/userdata.json' ), - getUserDataLockFilePath: vi.fn().mockReturnValue( '/mock/userdata.json.lock' ), getUserDataCertificatesPath: vi.fn().mockReturnValue( '/mock/certificates' ), getServerFilesPath: vi.fn().mockReturnValue( '/mock/server/files' ), getCliPath: vi.fn().mockReturnValue( '/mock/cli/path' ), diff --git a/apps/studio/src/tests/ipc-handlers.test.ts b/apps/studio/src/tests/ipc-handlers.test.ts index 8d60019061..d73c0589ca 100644 --- a/apps/studio/src/tests/ipc-handlers.test.ts +++ b/apps/studio/src/tests/ipc-handlers.test.ts @@ -30,7 +30,6 @@ vi.mock( '@sentry/electron/main', () => ( { vi.mock( 'src/storage/paths', () => ( { getResourcesPath: vi.fn().mockReturnValue( '/mock/resources' ), getUserDataFilePath: vi.fn().mockReturnValue( '/mock/userdata.json' ), - getUserDataLockFilePath: vi.fn().mockReturnValue( '/mock/userdata.json.lock' ), getUserDataCertificatesPath: vi.fn().mockReturnValue( '/mock/certificates' ), getServerFilesPath: vi.fn().mockReturnValue( '/mock/server/files' ), getCliPath: vi.fn().mockReturnValue( '/mock/cli/path' ), diff --git a/apps/studio/src/tests/main-window.test.ts b/apps/studio/src/tests/main-window.test.ts index 747d4498bd..7c1c28d4d8 100644 --- a/apps/studio/src/tests/main-window.test.ts +++ b/apps/studio/src/tests/main-window.test.ts @@ -18,7 +18,6 @@ vi.mock( 'src/lib/app-globals', () => ( { vi.mock( 'src/storage/paths', () => ( { getResourcesPath: vi.fn().mockReturnValue( '/mock/resources' ), getUserDataFilePath: vi.fn().mockReturnValue( '/mock/userdata.json' ), - getUserDataLockFilePath: vi.fn().mockReturnValue( '/mock/userdata.json.lock' ), getUserDataCertificatesPath: vi.fn().mockReturnValue( '/mock/certificates' ), getServerFilesPath: vi.fn().mockReturnValue( '/mock/server/files' ), getCliPath: vi.fn().mockReturnValue( '/mock/cli/path' ), diff --git a/apps/studio/src/tests/open-file-in-ide.test.ts b/apps/studio/src/tests/open-file-in-ide.test.ts index 54b337422f..25ae988e0c 100644 --- a/apps/studio/src/tests/open-file-in-ide.test.ts +++ b/apps/studio/src/tests/open-file-in-ide.test.ts @@ -38,7 +38,6 @@ vi.mock( '@sentry/electron/main', () => ( { vi.mock( 'src/storage/paths', () => ( { getResourcesPath: vi.fn().mockReturnValue( '/mock/resources' ), getUserDataFilePath: vi.fn().mockReturnValue( '/mock/userdata.json' ), - getUserDataLockFilePath: vi.fn().mockReturnValue( '/mock/userdata.json.lock' ), getUserDataCertificatesPath: vi.fn().mockReturnValue( '/mock/certificates' ), getServerFilesPath: vi.fn().mockReturnValue( '/mock/server/files' ), getCliPath: vi.fn().mockReturnValue( '/mock/cli/path' ), diff --git a/apps/studio/vitest.setup.ts b/apps/studio/vitest.setup.ts index bce9a3b69f..ef11e3fc24 100644 --- a/apps/studio/vitest.setup.ts +++ b/apps/studio/vitest.setup.ts @@ -169,10 +169,10 @@ vi.mock( 'electron', () => { vi.mock( 'src/storage/paths', () => ( { getResourcesPath: vi.fn().mockReturnValue( '/mock/resources' ), getUserDataFilePath: vi.fn().mockReturnValue( '/mock/userdata.json' ), - getUserDataLockFilePath: vi.fn().mockReturnValue( '/mock/userdata.json.lock' ), getUserDataCertificatesPath: vi.fn().mockReturnValue( '/mock/certificates' ), } ) ); + vi.mock( 'lockfile', () => { const lock = vi.fn( ( file, options, callback ) => callback( null ) ); const unlock = vi.fn( ( file, callback ) => callback( null ) ); diff --git a/tools/common/constants.ts b/tools/common/constants.ts index 31c5707a23..2ff3c170af 100644 --- a/tools/common/constants.ts +++ b/tools/common/constants.ts @@ -15,7 +15,8 @@ export const PROTOCOL_PREFIX = 'wp-studio'; export const DEFAULT_TOKEN_LIFETIME_MS = DAY_MS * 14; // Lockfile constants -export const LOCKFILE_NAME = 'appdata-v1.json.lock'; +export const APP_CONFIG_LOCKFILE_NAME = 'app.json.lock'; +export const CLI_CONFIG_LOCKFILE_NAME = 'cli.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/config-paths.ts b/tools/common/lib/config-paths.ts new file mode 100644 index 0000000000..afb9a18782 --- /dev/null +++ b/tools/common/lib/config-paths.ts @@ -0,0 +1,29 @@ +import os from 'os'; +import path from 'path'; +import { APP_CONFIG_LOCKFILE_NAME } from '../constants'; + +export function getConfigDirectory(): 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( getConfigDirectory(), 'shared.json' ); +} + +export function getAppConfigPath(): string { + return path.join( getConfigDirectory(), 'app.json' ); +} + +export function getAppConfigLockFilePath(): string { + return path.join( getConfigDirectory(), APP_CONFIG_LOCKFILE_NAME ); +} + +export function getCliConfigPath(): string { + if ( process.env.E2E && process.env.E2E_CLI_CONFIG_PATH ) { + return path.join( process.env.E2E_CLI_CONFIG_PATH, 'cli.json' ); + } + return path.join( getConfigDirectory(), 'cli.json' ); +} diff --git a/tools/common/lib/migration.ts b/tools/common/lib/migration.ts new file mode 100644 index 0000000000..70926795b5 --- /dev/null +++ b/tools/common/lib/migration.ts @@ -0,0 +1,23 @@ +/** + * Interface for data migrations. + * + * Each migration declares whether it needs to run and how to run. + * If `needsToRun` returns true but `run` throws, the error propagates + * to the caller β€” the CLI or Studio app should handle it accordingly. + */ +export interface Migration { + needsToRun: () => Promise< boolean >; + run: () => Promise< void >; +} + +/** + * Executes all migrations that need to run, in order. + * Throws on the first migration that fails. + */ +export async function runMigrations( migrations: Migration[] ): Promise< void > { + for ( const migration of migrations ) { + if ( await migration.needsToRun() ) { + await migration.run(); + } + } +} diff --git a/tools/common/lib/shared-config.ts b/tools/common/lib/shared-config.ts index 009d7e0e12..32888a3533 100644 --- a/tools/common/lib/shared-config.ts +++ b/tools/common/lib/shared-config.ts @@ -1,10 +1,10 @@ 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 { authTokenSchema, type StoredAuthToken } from './auth-token-schema'; +import { getConfigDirectory, getSharedConfigPath } from './config-paths'; import { lockFileAsync, unlockFileAsync } from './lockfile'; export { authTokenSchema }; @@ -19,13 +19,11 @@ export class SharedConfigVersionMismatchError extends Error { } } -const SHARED_CONFIG_FILENAME = 'shared.json'; - // Schema updates must maintain backwards compatibility. If a breaking change is needed, // increment SHARED_CONFIG_VERSION and add a data migration function. const SHARED_CONFIG_VERSION = 1; -const sharedConfigSchema = z +export const sharedConfigSchema = z .object( { version: z.literal( SHARED_CONFIG_VERSION ), authToken: authTokenSchema.optional(), @@ -39,17 +37,6 @@ const DEFAULT_SHARED_CONFIG: SharedConfig = { version: SHARED_CONFIG_VERSION, }; -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(); @@ -77,20 +64,19 @@ export async function readSharedConfig(): Promise< SharedConfig > { } export async function saveSharedConfig( config: SharedConfig ): Promise< void > { - config.version = SHARED_CONFIG_VERSION; - - const configDir = getSharedConfigDirectory(); + const configDir = getConfigDirectory(); if ( ! fs.existsSync( configDir ) ) { fs.mkdirSync( configDir, { recursive: true } ); } const configPath = getSharedConfigPath(); - const fileContent = JSON.stringify( config, null, 2 ) + '\n'; + const persisted = { ...config, version: SHARED_CONFIG_VERSION }; + const fileContent = JSON.stringify( persisted, null, 2 ) + '\n'; await writeFile( configPath, fileContent, { encoding: 'utf8' } ); } function getLockfilePath(): string { - return path.join( getSharedConfigDirectory(), SHARED_CONFIG_LOCKFILE_NAME ); + return path.join( getConfigDirectory(), SHARED_CONFIG_LOCKFILE_NAME ); } export async function lockSharedConfig(): Promise< void > { @@ -104,9 +90,7 @@ export async function unlockSharedConfig(): Promise< void > { await unlockFileAsync( getLockfilePath() ); } -export async function updateSharedConfig( - update: Partial< Omit< SharedConfig, 'version' > > -): Promise< void > { +export async function updateSharedConfig( update: Partial< SharedConfig > ): Promise< void > { try { await lockSharedConfig(); const config = await readSharedConfig(); diff --git a/tools/common/lib/tests/migration.test.ts b/tools/common/lib/tests/migration.test.ts new file mode 100644 index 0000000000..57df6effee --- /dev/null +++ b/tools/common/lib/tests/migration.test.ts @@ -0,0 +1,101 @@ +import { runMigrations, type Migration } from '@studio/common/lib/migration'; + +describe( 'runMigrations', () => { + it( 'does nothing when migrations array is empty', async () => { + await expect( runMigrations( [] ) ).resolves.toBeUndefined(); + } ); + + it( 'skips migrations that do not need to run', async () => { + const run = vi.fn(); + const migrations: Migration[] = [ { needsToRun: async () => false, run } ]; + await runMigrations( migrations ); + expect( run ).not.toHaveBeenCalled(); + } ); + + it( 'runs migrations that need to run', async () => { + const run = vi.fn(); + const migrations: Migration[] = [ { needsToRun: async () => true, run } ]; + await runMigrations( migrations ); + expect( run ).toHaveBeenCalledOnce(); + } ); + + it( 'runs multiple migrations in order', async () => { + const order: string[] = []; + const migrations: Migration[] = [ + { + needsToRun: async () => true, + run: async () => { + order.push( 'first' ); + }, + }, + { + needsToRun: async () => true, + run: async () => { + order.push( 'second' ); + }, + }, + ]; + await runMigrations( migrations ); + expect( order ).toEqual( [ 'first', 'second' ] ); + } ); + + it( 'only runs migrations that need to run', async () => { + const order: string[] = []; + const migrations: Migration[] = [ + { + needsToRun: async () => true, + run: async () => { + order.push( 'yes' ); + }, + }, + { + needsToRun: async () => false, + run: async () => { + order.push( 'no' ); + }, + }, + { + needsToRun: async () => true, + run: async () => { + order.push( 'also-yes' ); + }, + }, + ]; + await runMigrations( migrations ); + expect( order ).toEqual( [ 'yes', 'also-yes' ] ); + } ); + + it( 'throws when a migration that needs to run fails', async () => { + const migrations: Migration[] = [ + { + needsToRun: async () => true, + run: async () => { + throw new Error( 'migration failed' ); + }, + }, + ]; + await expect( runMigrations( migrations ) ).rejects.toThrow( 'migration failed' ); + } ); + + it( 'stops on first failure and does not run subsequent migrations', async () => { + const thirdRun = vi.fn(); + const migrations: Migration[] = [ + { + needsToRun: async () => true, + run: async () => {}, + }, + { + needsToRun: async () => true, + run: async () => { + throw new Error( 'boom' ); + }, + }, + { + needsToRun: async () => true, + run: thirdRun, + }, + ]; + await expect( runMigrations( migrations ) ).rejects.toThrow( 'boom' ); + expect( thirdRun ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/tools/common/lib/tests/shared-config.test.ts b/tools/common/lib/tests/shared-config.test.ts index c9e6938b2f..1ae4beaeef 100644 --- a/tools/common/lib/tests/shared-config.test.ts +++ b/tools/common/lib/tests/shared-config.test.ts @@ -3,14 +3,16 @@ import os from 'os'; import path from 'path'; import { readFile, writeFile } from 'atomically'; import { vi } from 'vitest'; +import { + getConfigDirectory as getSharedConfigDirectory, + getSharedConfigPath, +} from '@studio/common/lib/config-paths'; import { readSharedConfig, saveSharedConfig, updateSharedConfig, readAuthToken, getCurrentUserId, - getSharedConfigDirectory, - getSharedConfigPath, SharedConfigVersionMismatchError, } from '@studio/common/lib/shared-config'; import type { SharedConfig } from '@studio/common/lib/shared-config'; From 7be1ebcffd071a7944e0b9a974b5bd122a8d9acd Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 23 Mar 2026 15:26:13 +0000 Subject: [PATCH 07/11] Fix lint errors: update AI imports from appdata to cli-config --- apps/cli/ai/sessions/paths.ts | 2 +- apps/cli/ai/tests/ui.test.ts | 20 ++++++++++++++------ apps/cli/commands/ai/tests/ai.test.ts | 19 +++++++++---------- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/cli/ai/sessions/paths.ts b/apps/cli/ai/sessions/paths.ts index 5570d19bfa..cdfa31541e 100644 --- a/apps/cli/ai/sessions/paths.ts +++ b/apps/cli/ai/sessions/paths.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { getAppdataDirectory } from 'cli/lib/appdata'; +import { getAppdataDirectory } from 'cli/lib/server-files'; // Keep month/day segments zero-padded so directory names sort chronologically. function formatDatePart( value: number ): string { diff --git a/apps/cli/ai/tests/ui.test.ts b/apps/cli/ai/tests/ui.test.ts index f1d8fcde95..d02cdf92fa 100644 --- a/apps/cli/ai/tests/ui.test.ts +++ b/apps/cli/ai/tests/ui.test.ts @@ -1,13 +1,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AiChatUI } from 'cli/ai/ui'; -import { getSiteUrl, readAppdata } from 'cli/lib/appdata'; import { openBrowser } from 'cli/lib/browser'; +import { readCliConfig } from 'cli/lib/cli-config/core'; +import { getSiteUrl } from 'cli/lib/cli-config/sites'; -vi.mock( 'cli/lib/appdata', async ( importOriginal ) => { - const actual = await importOriginal< typeof import('cli/lib/appdata') >(); +vi.mock( 'cli/lib/cli-config/core', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('cli/lib/cli-config/core') >(); + return { + ...actual, + readCliConfig: vi.fn(), + }; +} ); + +vi.mock( 'cli/lib/cli-config/sites', async ( importOriginal ) => { + const actual = await importOriginal< typeof import('cli/lib/cli-config/sites') >(); return { ...actual, - readAppdata: vi.fn(), getSiteUrl: vi.fn(), }; } ); @@ -41,7 +49,7 @@ describe( 'AiChatUI.openActiveSiteInBrowser', () => { ui._activeSite = restoredSite; ui._activeSiteData = null; - vi.mocked( readAppdata ).mockResolvedValue( { + vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteData ], } as never ); vi.mocked( getSiteUrl ).mockReturnValue( 'http://localhost:8080' ); @@ -49,7 +57,7 @@ describe( 'AiChatUI.openActiveSiteInBrowser', () => { const opened = await ui.openActiveSiteInBrowser(); expect( opened ).toBe( true ); - expect( readAppdata ).toHaveBeenCalledTimes( 1 ); + expect( readCliConfig ).toHaveBeenCalledTimes( 1 ); expect( openBrowser ).toHaveBeenCalledWith( 'http://localhost:8080' ); expect( ui._activeSiteData ).toEqual( siteData ); } ); diff --git a/apps/cli/commands/ai/tests/ai.test.ts b/apps/cli/commands/ai/tests/ai.test.ts index a93dead792..370a495f16 100644 --- a/apps/cli/commands/ai/tests/ai.test.ts +++ b/apps/cli/commands/ai/tests/ai.test.ts @@ -14,7 +14,7 @@ import { registerCommand as registerAiCommand } from 'cli/commands/ai'; import { registerCommand as registerAiSessionsDeleteCommand } from 'cli/commands/ai/sessions/delete'; import { registerCommand as registerAiSessionsListCommand } from 'cli/commands/ai/sessions/list'; import { registerCommand as registerAiSessionsResumeCommand } from 'cli/commands/ai/sessions/resume'; -import { getAnthropicApiKey } from 'cli/lib/appdata'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { StudioArgv } from 'cli/types'; const { askUserMock, recordSessionContextMock, reportErrorMock, waitForInputMock } = vi.hoisted( @@ -26,17 +26,13 @@ const { askUserMock, recordSessionContextMock, reportErrorMock, waitForInputMock } ) ); -vi.mock( 'cli/lib/appdata', async () => { - const actual = await vi.importActual< typeof import('cli/lib/appdata') >( 'cli/lib/appdata' ); +vi.mock( 'cli/lib/cli-config/core', async () => { + const actual = + await vi.importActual< typeof import('cli/lib/cli-config/core') >( 'cli/lib/cli-config/core' ); return { ...actual, - getAnthropicApiKey: vi.fn(), - getAuthToken: vi.fn().mockResolvedValue( { - displayName: 'Test User', - email: 'test@example.com', - } ), - saveAnthropicApiKey: vi.fn(), + readCliConfig: vi.fn(), }; } ); @@ -175,7 +171,10 @@ vi.mock( 'cli/commands/auth/logout', () => ( { describe( 'CLI: studio ai sessions command', () => { beforeEach( () => { vi.clearAllMocks(); - vi.mocked( getAnthropicApiKey ).mockResolvedValue( 'test-api-key' ); + vi.mocked( readCliConfig ).mockResolvedValue( { + sites: [], + anthropicApiKey: 'test-api-key', + } as never ); askUserMock.mockResolvedValue( {} ); waitForInputMock.mockResolvedValue( '/exit' ); vi.spyOn( process, 'exit' ).mockImplementation( () => undefined as never ); From 56809750320a4afde527a704a8eb80bd4e2f8eb2 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 23 Mar 2026 15:35:53 +0000 Subject: [PATCH 08/11] trigger ci From 72a7bae07bba1b8099f1317d862b75a360b69fc2 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 23 Mar 2026 16:48:32 +0000 Subject: [PATCH 09/11] Exclude AI config fields from app.json and enforce shared config locking --- apps/studio/src/migrations/02-migrate-to-split-config.ts | 2 ++ .../src/migrations/tests/00-migrate-to-split-config.test.ts | 5 +++++ eslint.config.mjs | 5 +++++ 3 files changed, 12 insertions(+) diff --git a/apps/studio/src/migrations/02-migrate-to-split-config.ts b/apps/studio/src/migrations/02-migrate-to-split-config.ts index 1d5ebcde9d..e8c0882013 100644 --- a/apps/studio/src/migrations/02-migrate-to-split-config.ts +++ b/apps/studio/src/migrations/02-migrate-to-split-config.ts @@ -111,6 +111,8 @@ const movedTopLevelFields = new Set( [ 'sites', 'snapshots', 'version', + 'aiProvider', + 'anthropicApiKey', ] ); // Per-site fields managed by CLI or runtime β€” excluded from app.json site entries. diff --git a/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts b/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts index 360dfef51e..5016b7820d 100644 --- a/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts +++ b/apps/studio/src/migrations/tests/00-migrate-to-split-config.test.ts @@ -182,6 +182,9 @@ function createOldAppdata() { userId: 42, }, ], + // Fields β†’ cli.json (AI config) + aiProvider: 'anthropic', + anthropicApiKey: 'sk-ant-test-key-123', // Fields β†’ app.json (Desktop-only top-level) devToolsOpen: true, windowBounds: { x: 100, y: 200, width: 1200, height: 800 }, @@ -426,6 +429,8 @@ describe( 'migrateAppConfig', () => { expect( appConfig ).not.toHaveProperty( 'authToken' ); expect( appConfig ).not.toHaveProperty( 'locale' ); expect( appConfig ).not.toHaveProperty( 'snapshots' ); + expect( appConfig ).not.toHaveProperty( 'aiProvider' ); + expect( appConfig ).not.toHaveProperty( 'anthropicApiKey' ); } ); it( 'keeps per-site Desktop fields (themeDetails, sortOrder) keyed by id', async () => { diff --git a/eslint.config.mjs b/eslint.config.mjs index bc707b83b6..830427cea0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -98,6 +98,11 @@ export default defineConfig( lock: 'lockCliConfig', unlock: 'unlockCliConfig', }, + { + save: 'saveSharedConfig', + lock: 'lockSharedConfig', + unlock: 'unlockSharedConfig', + }, ], }, ], From 7f609233d4a49c053b203ed2253eb49f57b56dba Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 23 Mar 2026 16:50:01 +0000 Subject: [PATCH 10/11] Move getSiteByFolder outside try/finally to prevent unlock without lock --- apps/cli/lib/cli-config/snapshots.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/cli-config/snapshots.ts b/apps/cli/lib/cli-config/snapshots.ts index 010879ff22..5fcf3fd864 100644 --- a/apps/cli/lib/cli-config/snapshots.ts +++ b/apps/cli/lib/cli-config/snapshots.ts @@ -44,8 +44,8 @@ export async function saveSnapshotToConfig( userId: number, name: string ): Promise< Snapshot > { + const site = await getSiteByFolder( siteFolder ); try { - const site = await getSiteByFolder( siteFolder ); await lockCliConfig(); const config = await readCliConfig(); @@ -72,8 +72,8 @@ export async function updateSnapshotInConfig( atomicSiteId: number, siteFolder: string ): Promise< Snapshot > { + const site = await getSiteByFolder( siteFolder ); try { - const site = await getSiteByFolder( siteFolder ); await lockCliConfig(); const config = await readCliConfig(); const snapshot = config.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); From cc275886e7a6a027b9d264d170b68b7bf6a4010d Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 23 Mar 2026 17:02:26 +0000 Subject: [PATCH 11/11] Wrap saveSharedConfig test calls with lock/unlock to satisfy lint rule --- tools/common/lib/tests/shared-config.test.ts | 23 +++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/tools/common/lib/tests/shared-config.test.ts b/tools/common/lib/tests/shared-config.test.ts index 1ae4beaeef..386e00f303 100644 --- a/tools/common/lib/tests/shared-config.test.ts +++ b/tools/common/lib/tests/shared-config.test.ts @@ -10,6 +10,8 @@ import { import { readSharedConfig, saveSharedConfig, + lockSharedConfig, + unlockSharedConfig, updateSharedConfig, readAuthToken, getCurrentUserId, @@ -141,7 +143,12 @@ describe( 'Shared Config', () => { describe( 'saveSharedConfig', () => { it( 'should write JSON to shared.json', async () => { const config = { version: 1 as const, locale: 'en' }; - await saveSharedConfig( config ); + try { + await lockSharedConfig(); + await saveSharedConfig( config ); + } finally { + await unlockSharedConfig(); + } expect( writeFile ).toHaveBeenCalledWith( `${ mockHomeDir }/.studio/shared.json`, @@ -152,7 +159,12 @@ describe( 'Shared Config', () => { it( 'should create directory if it does not exist', async () => { vi.mocked( fs.existsSync ).mockReturnValue( false ); - await saveSharedConfig( { version: 1 } ); + try { + await lockSharedConfig(); + await saveSharedConfig( { version: 1 } ); + } finally { + await unlockSharedConfig(); + } expect( fs.mkdirSync ).toHaveBeenCalledWith( `${ mockHomeDir }/.studio`, { recursive: true, @@ -161,7 +173,12 @@ describe( 'Shared Config', () => { it( 'should set version to 1', async () => { const config = { version: 99 } as unknown as SharedConfig; - await saveSharedConfig( config ); + try { + await lockSharedConfig(); + await saveSharedConfig( config ); + } finally { + await unlockSharedConfig(); + } const written = vi.mocked( writeFile ).mock.calls[ 0 ][ 1 ] as string; expect( JSON.parse( written ).version ).toBe( 1 );