From 8a993d8cbde4c2e8c22a6c8979e41c25514c3cad Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 13 Mar 2026 19:01:22 +0000 Subject: [PATCH 01/12] 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 --- apps/cli/commands/preview/create.ts | 18 ++- apps/cli/commands/preview/delete.ts | 6 +- apps/cli/commands/preview/list.ts | 68 ++++----- apps/cli/commands/preview/set.ts | 74 ++++++++++ .../cli/commands/preview/tests/create.test.ts | 14 +- .../cli/commands/preview/tests/delete.test.ts | 16 +-- apps/cli/commands/preview/tests/list.test.ts | 14 +- .../cli/commands/preview/tests/update.test.ts | 16 +-- apps/cli/commands/preview/update.ts | 6 +- apps/cli/commands/site/delete.ts | 6 +- apps/cli/commands/site/tests/delete.test.ts | 36 ++--- apps/cli/commands/site/tests/list.test.ts | 2 + apps/cli/commands/site/tests/set.test.ts | 9 +- apps/cli/commands/site/tests/stop.test.ts | 38 ++++- apps/cli/index.ts | 2 + apps/cli/lib/appdata.ts | 2 - apps/cli/lib/cli-config.ts | 133 +++++++++++++++++- apps/cli/lib/snapshots.ts | 119 +--------------- apps/cli/lib/tests/snapshots.test.ts | 40 +++--- .../src/components/tests/header.test.tsx | 8 +- apps/studio/src/ipc-handlers.ts | 20 +-- .../src/lib/tests/windows-helpers.test.ts | 27 ++-- .../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/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 | 26 ++-- apps/studio/src/stores/snapshot-slice.ts | 21 ++- .../src/stores/tests/snapshot-slice.test.ts | 16 +-- package-lock.json | 1 - tools/common/logger-actions.ts | 1 + 34 files changed, 489 insertions(+), 345 deletions(-) create mode 100644 apps/cli/commands/preview/set.ts diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index 91e053365d..99f07bc19c 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -7,12 +7,12 @@ 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 { 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,10 +42,12 @@ export async function runCommand( siteFolder: string ): Promise< void > { ); logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); - const snapshot = await saveSnapshotToAppdata( + const snapshot = await saveSnapshotToConfig( siteFolder, uploadResponse.site_id, - uploadResponse.site_url + uploadResponse.site_url, + token.id, + name ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); @@ -67,8 +69,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..e78b0ed221 100644 --- a/apps/cli/commands/preview/delete.ts +++ b/apps/cli/commands/preview/delete.ts @@ -2,7 +2,7 @@ import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logge import { __ } from '@wordpress/i18n'; import { deleteSnapshot } from 'cli/lib/api'; import { getAuthToken } from 'cli/lib/appdata'; -import { deleteSnapshotFromAppdata, getSnapshotsFromAppdata } from 'cli/lib/snapshots'; +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 +13,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 +27,7 @@ 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 ); 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 330eba603b..a04692876a 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 Table 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'; import { formatDurationUntilExpiry, - getSnapshotsFromAppdata, + getSnapshotsFromConfig, isSnapshotExpired, } from 'cli/lib/snapshots'; import { getColumnWidths } from 'cli/lib/utils'; @@ -20,13 +20,18 @@ export async function runCommand( const logger = new Logger< LoggerAction >(); try { + if ( outputFormat === 'json' ) { + const config = await readCliConfig(); + logger.reportKeyValuePair( 'snapshots', JSON.stringify( config.snapshots ) ); + 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' ) ); @@ -51,42 +56,31 @@ export async function runCommand( logger.reportSuccess( snapshotsMessage ); } - if ( outputFormat === 'table' ) { - const colWidths = getColumnWidths( [ 0.4, 0.25, 0.175, 0.175 ] ); - const table = new Table( { - head: [ __( 'URL' ), __( 'Site Name' ), __( 'Updated' ), __( 'Expires in' ) ], - wordWrap: true, - wrapOnWordBoundary: false, - colWidths, - style: { - head: [], - border: [], - }, - } ); - - for ( const snapshot of snapshots ) { - const durationUntilExpiry = formatDurationUntilExpiry( snapshot.date ); - const url = `https://${ snapshot.url }`; - - table.push( [ - { href: url, content: url }, - snapshot.name, - format( snapshot.date, 'yyyy-MM-dd HH:mm' ), - durationUntilExpiry, - ] ); - } + const colWidths = getColumnWidths( [ 0.4, 0.25, 0.175, 0.175 ] ); + const table = new Table( { + head: [ __( 'URL' ), __( 'Site Name' ), __( 'Updated' ), __( 'Expires in' ) ], + wordWrap: true, + wrapOnWordBoundary: false, + colWidths, + style: { + head: [], + border: [], + }, + } ); - 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 ), - } ) ); + for ( const snapshot of snapshots ) { + const durationUntilExpiry = formatDurationUntilExpiry( snapshot.date ); + const url = `https://${ snapshot.url }`; - console.log( JSON.stringify( output, null, 2 ) ); + table.push( [ + { href: url, content: url }, + snapshot.name, + format( snapshot.date, 'yyyy-MM-dd HH:mm' ), + durationUntilExpiry, + ] ); } + + console.log( table.toString() ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); diff --git a/apps/cli/commands/preview/set.ts b/apps/cli/commands/preview/set.ts new file mode 100644 index 0000000000..c9506ff411 --- /dev/null +++ b/apps/cli/commands/preview/set.ts @@ -0,0 +1,74 @@ +import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; +import { __ } from '@wordpress/i18n'; +import { setSnapshotInConfig } from 'cli/lib/cli-config'; +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.' ) ); + } + + try { + logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); + await setSnapshotInConfig( host, { name } ); + logger.reportSuccess( __( 'Preview site updated' ) ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to update preview site' ), error ); + logger.reportError( loggerError ); + } + } +} + +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..522d36d800 100644 --- a/apps/cli/commands/preview/tests/create.test.ts +++ b/apps/cli/commands/preview/tests/create.test.ts @@ -6,7 +6,7 @@ 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 { saveSnapshotToConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { runCommand } from '../create'; @@ -92,7 +92,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 +133,12 @@ describe( 'Preview Create Command', () => { `Preview site available at: https://${ mockSiteUrl }`, ] ); - expect( saveSnapshotToAppdata ).toHaveBeenCalledWith( + expect( saveSnapshotToConfig ).toHaveBeenCalledWith( mockFolder, mockAtomicSiteId, - mockSiteUrl + mockSiteUrl, + mockAuthToken.id, + undefined ); expect( mockReportStart.mock.calls[ 4 ] ).toEqual( [ 'appdata', @@ -217,12 +219,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..589e885f87 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 { getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { mockReportStart, mockReportSuccess, @@ -25,6 +25,7 @@ vi.mock( 'cli/lib/cli-config', async () => { return { ...actual, getSiteByFolder: vi.fn(), + readCliConfig: vi.fn(), }; } ); vi.mock( 'cli/lib/snapshots' ); @@ -85,7 +86,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 +96,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 +104,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 +112,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..54588ee0ae 100644 --- a/apps/cli/commands/preview/tests/update.test.ts +++ b/apps/cli/commands/preview/tests/update.test.ts @@ -8,7 +8,7 @@ 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 { updateSnapshotInConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { mockReportStart, mockReportSuccess, mockReportError } from 'cli/tests/test-utils'; import { runCommand } from '../update'; @@ -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..e3c0321d62 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -10,7 +10,7 @@ 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 { 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 +54,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 +86,7 @@ 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 ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 72453a14e0..50b8dca497 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -16,7 +16,7 @@ import { import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } 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 ); } ) ); diff --git a/apps/cli/commands/site/tests/delete.test.ts b/apps/cli/commands/site/tests/delete.test.ts index 4dceb44446..fba5bede38 100644 --- a/apps/cli/commands/site/tests/delete.test.ts +++ b/apps/cli/commands/site/tests/delete.test.ts @@ -16,7 +16,7 @@ import { 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'; @@ -113,6 +113,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 +121,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 +154,7 @@ describe( 'CLI: studio site delete', () => { vi.mocked( readCliConfig, { partial: true } ).mockResolvedValue( { version: 1, sites: [], + snapshots: [], } ); await expect( runCommand( testSiteFolder ) ).rejects.toThrow( @@ -178,7 +180,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 +190,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 +209,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 +223,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 +234,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 +247,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 +263,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 +275,7 @@ describe( 'CLI: studio site delete', () => { version: 1, sites: [ testSite ], } ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); @@ -289,7 +291,7 @@ describe( 'CLI: studio site delete', () => { version: 1, sites: [ testSite ], } ); - vi.mocked( getSnapshotsFromAppdata ).mockResolvedValue( [] ); + vi.mocked( getSnapshotsFromConfig ).mockResolvedValue( [] ); await runCommand( testSiteFolder, false ); @@ -300,7 +302,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 +314,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..7ef6121e27 100644 --- a/apps/cli/commands/site/tests/list.test.ts +++ b/apps/cli/commands/site/tests/list.test.ts @@ -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..4e466ea13f 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -78,7 +78,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 +145,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], version: 1, + snapshots: [], } ); await runCommand( testSitePath, { https: true } ); @@ -200,6 +201,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], version: 1, + snapshots: [], } ); await runCommand( testSitePath, { https: true } ); @@ -216,6 +218,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithDomain ], version: 1, + snapshots: [], } ); vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); @@ -359,6 +362,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 +394,7 @@ describe( 'CLI: studio site set', () => { vi.mocked( readCliConfig ).mockResolvedValue( { sites: [ siteWithXdebug ], version: 1, + snapshots: [], } ); await runCommand( testSitePath, { xdebug: false } ); @@ -404,6 +409,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 +423,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/stop.test.ts b/apps/cli/commands/site/tests/stop.test.ts index ae9aec153b..87cd6199d1 100644 --- a/apps/cli/commands/site/tests/stop.test.ts +++ b/apps/cli/commands/site/tests/stop.test.ts @@ -238,7 +238,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 +254,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 +271,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 +280,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 +297,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 +321,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 +361,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/index.ts b/apps/cli/index.ts index 7a2ad44fa6..250e14d3ef 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -17,6 +17,7 @@ import { registerCommand as registerMcpCommand } from 'cli/commands/mcp'; import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create'; import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete'; import { registerCommand as registerListCommand } from 'cli/commands/preview/list'; +import { registerCommand as registerPreviewSetCommand } from 'cli/commands/preview/set'; import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update'; import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create'; import { registerCommand as registerSiteDeleteCommand } from 'cli/commands/site/delete'; @@ -94,6 +95,7 @@ async function main() { registerListCommand( previewYargs ); registerDeleteCommand( previewYargs ); registerUpdateCommand( previewYargs ); + registerPreviewSetCommand( previewYargs ); previewYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) ); } ) .command( 'site', __( 'Manage sites' ), ( sitesYargs ) => { diff --git a/apps/cli/lib/appdata.ts b/apps/cli/lib/appdata.ts index fe0405f789..304b07df07 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 { StatsMetric } from '@studio/common/types/stats'; import { __, sprintf } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; @@ -18,7 +17,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.ts index c9a5a8771b..c3241b13a5 100644 --- a/apps/cli/lib/cli-config.ts +++ b/apps/cli/lib/cli-config.ts @@ -4,7 +4,8 @@ import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constant 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 { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; +import { __, sprintf } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; import { STUDIO_CLI_HOME } from 'cli/lib/paths'; @@ -24,6 +25,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 +34,7 @@ export type SiteData = z.infer< typeof siteSchema >; const DEFAULT_CLI_CONFIG: CliConfig = { version: 1, sites: [], + snapshots: [], }; export function getCliConfigDirectory(): string { @@ -209,3 +212,131 @@ export async function removeSiteFromConfig( siteId: string ): Promise< void > { await unlockCliConfig(); } } + +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: + 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, + }; + + 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(); + } +} + +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; +} diff --git a/apps/cli/lib/snapshots.ts b/apps/cli/lib/snapshots.ts index f4d2753ef5..0abd093b73 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'; export function isSnapshotExpired( snapshot: Snapshot ) { const now = new Date(); diff --git a/apps/cli/lib/tests/snapshots.test.ts b/apps/cli/lib/tests/snapshots.test.ts index 7c824d090b..3d8d760358 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,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId, mockSiteUrl ); + await saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId ); expect( writeFile ).toHaveBeenCalled(); const savedData = JSON.parse( mocks.writeFile.mock.calls[ 0 ][ 1 ] ); @@ -149,7 +149,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId + 1, mockSiteUrl ); + await saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId + 1, mockSiteUrl, mockUserId ); expect( writeFile ).toHaveBeenCalled(); const savedData = JSON.parse( mocks.writeFile.mock.calls[ 0 ][ 1 ] ); @@ -189,7 +189,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); await expect( - saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId, mockSiteUrl ) + saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId ) ).rejects.toThrow( LoggerError ); expect( writeFile ).not.toHaveBeenCalled(); @@ -199,12 +199,12 @@ describe( 'Snapshots Module', () => { mocks.existsSync.mockReturnValueOnce( false ); await expect( - saveSnapshotToAppdata( mockSiteFolder, mockAtomicSiteId, mockSiteUrl ) + saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId ) ).rejects.toThrow( LoggerError ); } ); } ); - describe( 'updateSnapshotInAppdata', () => { + describe( 'updateSnapshotInConfig', () => { it( 'should update the date of an existing snapshot', async () => { const mockSiteId = 'abc123'; const mockUserData = { @@ -233,7 +233,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 +251,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 +285,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 +321,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 +335,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 +367,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 +392,7 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await deleteSnapshotFromAppdata( 'nonexistent.com' ); + await deleteSnapshotFromConfig( 'nonexistent.com' ); expect( writeFile ).not.toHaveBeenCalled(); } ); @@ -405,7 +405,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/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/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 6032d633ce..a716c808e5 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -35,7 +35,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 { StatsGroup, StatsMetric } from '@studio/common/types/stats'; import { __, sprintf, LocaleData, defaultI18n } from '@wordpress/i18n'; import { MACOS_TRAFFIC_LIGHT_POSITION, MAIN_MIN_WIDTH, SIDEBAR_WIDTH } from 'src/constants'; @@ -119,6 +118,8 @@ export { export { createSnapshot, deleteSnapshot, + fetchSnapshots, + setSnapshot, updateSnapshot, } from 'src/modules/preview-site/lib/ipc-handlers'; @@ -726,27 +727,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/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/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 20ab06de0e..55cc618de2 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/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 199c7b7719..714265e07f 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 { StoredToken } from 'src/lib/oauth'; import { SupportedEditor } from 'src/modules/user-settings/lib/editor'; import type { SyncSite } from 'src/modules/sync/types'; @@ -14,7 +13,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 1c23ef38ae..ffbbbf606e 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'; @@ -23,8 +18,8 @@ import onboardingReducer from 'src/stores/onboarding-slice'; import { providerConstantsReducer } from 'src/stores/provider-constants-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'; @@ -91,12 +86,16 @@ startAppListening( { }, } ); -// Save snapshots to user config +// Save snapshot changes to CLI config via preview set command startAppListening( { - matcher: isAnyOf( updateSnapshotLocally, snapshotActions.deleteSnapshotLocally ), - async effect( action, listenerApi ) { - const state = listenerApi.getState(); - await getIpcApi().saveSnapshotsToStorage( state.snapshot.snapshots ); + actionCreator: updateSnapshotLocally, + async effect( action ) { + const { atomicSiteId, snapshot } = action.payload; + const state = store.getState(); + const existing = state.snapshot.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); + if ( existing?.url && snapshot.name !== undefined ) { + await getIpcApi().setSnapshot( existing.url, { name: snapshot.name } ); + } }, } ); @@ -361,9 +360,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..9250b7ceaa 100644 --- a/apps/studio/src/stores/snapshot-slice.ts +++ b/apps/studio/src/stores/snapshot-slice.ts @@ -9,7 +9,6 @@ import { 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'; @@ -295,30 +294,25 @@ const selectSnapshotsBySiteAndUser = createSelector( ) ); -window.ipcListener.subscribe( 'user-data-updated', ( _, payload ) => { +export async function refreshSnapshots() { + const snapshots = await getIpcApi().fetchSnapshots(); const state = store.getState(); - const snapshots = payload.snapshots; + const countDiff = snapshots.length - state.snapshot.snapshots.length; - if ( ! fastDeepEqual( state.snapshot.snapshots, snapshots ) ) { - store.dispatch( snapshotSlice.actions.setSnapshots( { snapshots } ) ); + store.dispatch( snapshotSlice.actions.setSnapshots( { snapshots } ) ); - // Optimistically update the snapshot usage count - const countDiff = snapshots.length - state.snapshot.snapshots.length; + if ( countDiff !== 0 ) { 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 ); } -} ); +} function getOperationProgress( action: PreviewCommandLoggerAction ): [ string, number ] { switch ( action ) { @@ -485,6 +479,9 @@ window.ipcListener.subscribe( 'snapshot-success', ( event, payload ) => { } ); } } + + // Re-fetch snapshots from CLI after any successful operation + void refreshSnapshots(); } ); export const snapshotActions = { 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 419ab986e6..a5913b47f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20999,7 +20999,6 @@ }, "node_modules/object-assign": { "version": "4.1.1", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" 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 18c1bacf7dbec2a1fff1fb920fd2ef7fe4cf4fbb Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 16:42:23 +0000 Subject: [PATCH 02/12] Emit snapshot events to Studio via _events for realtime updates --- apps/cli/commands/_events.ts | 22 ++++++++++++++++++- apps/cli/commands/preview/create.ts | 3 +++ apps/cli/commands/preview/delete.ts | 3 +++ apps/cli/commands/preview/set.ts | 3 +++ apps/cli/commands/preview/update.ts | 3 +++ apps/cli/lib/daemon-client.ts | 10 ++++++++- apps/studio/src/ipc-utils.ts | 1 + .../modules/cli/lib/cli-events-subscriber.ts | 20 +++++++++++++++++ apps/studio/src/stores/snapshot-slice.ts | 4 ++++ tools/common/lib/site-events.ts | 6 +++++ 10 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index 7ca4fb1ce3..b9a56f9f72 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -7,7 +7,12 @@ */ import { sequential } from '@studio/common/lib/sequential'; -import { SITE_EVENTS, siteDetailsSchema, SiteEvent } from '@studio/common/lib/site-events'; +import { + SITE_EVENTS, + SNAPSHOT_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'; @@ -75,10 +80,25 @@ const siteEventSchema = z.object( { } ), } ); +const snapshotEventSchema = z.object( { + event: z.nativeEnum( SNAPSHOT_EVENTS ), + data: z.object( {} ), +} ); + +function emitSnapshotEvent( event: SNAPSHOT_EVENTS ): void { + logger.reportKeyValuePair( 'snapshot-event', JSON.stringify( { event } ) ); +} + export async function runCommand(): Promise< void > { const eventsSocketServer = new SocketServer( SITE_EVENTS_SOCKET_PATH, 2500 ); eventsSocketServer.on( 'message', ( { message: packet } ) => { try { + const snapshotParsed = snapshotEventSchema.safeParse( packet ); + if ( snapshotParsed.success ) { + emitSnapshotEvent( snapshotParsed.data.event ); + return; + } + const parsedPacket = siteEventSchema.parse( packet ); if ( parsedPacket.event === SITE_EVENTS.CREATED || diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index 99f07bc19c..edd2af90ca 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -1,12 +1,14 @@ import os from 'os'; import path from 'path'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; 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 { emitSnapshotEvent } from 'cli/lib/daemon-client'; import { saveSnapshotToConfig } from 'cli/lib/snapshots'; import { validateSiteSize } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; @@ -50,6 +52,7 @@ export async function runCommand( siteFolder: string, name?: string ): Promise< name ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.CREATED ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); logger.reportKeyValuePair( 'url', snapshot.url ); diff --git a/apps/cli/commands/preview/delete.ts b/apps/cli/commands/preview/delete.ts index e78b0ed221..8468159a00 100644 --- a/apps/cli/commands/preview/delete.ts +++ b/apps/cli/commands/preview/delete.ts @@ -1,7 +1,9 @@ +import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-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 { emitSnapshotEvent } from 'cli/lib/daemon-client'; import { deleteSnapshotFromConfig, getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; @@ -28,6 +30,7 @@ export async function runCommand( host: string ): Promise< void > { logger.reportStart( LoggerAction.DELETE, __( 'Deleting…' ) ); await deleteSnapshot( snapshotToDelete.atomicSiteId, token.accessToken ); await deleteSnapshotFromConfig( snapshotToDelete.url ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.DELETED ); logger.reportSuccess( __( 'Deletion successful' ) ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/preview/set.ts b/apps/cli/commands/preview/set.ts index c9506ff411..9d97a9a193 100644 --- a/apps/cli/commands/preview/set.ts +++ b/apps/cli/commands/preview/set.ts @@ -1,6 +1,8 @@ +import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { setSnapshotInConfig } from 'cli/lib/cli-config'; +import { emitSnapshotEvent } from 'cli/lib/daemon-client'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -24,6 +26,7 @@ export async function runCommand( host: string, options: SetCommandOptions ): Pr try { logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); await setSnapshotInConfig( host, { name } ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED ); logger.reportSuccess( __( 'Preview site updated' ) ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index e3c0321d62..6a1bed2e18 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -2,6 +2,7 @@ import os from 'node:os'; import path from 'node:path'; import { DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { Snapshot } from '@studio/common/types/snapshot'; import { __, _n, sprintf } from '@wordpress/i18n'; @@ -10,6 +11,7 @@ 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 { emitSnapshotEvent } from 'cli/lib/daemon-client'; import { getSnapshotsFromConfig, updateSnapshotInConfig } from 'cli/lib/snapshots'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; @@ -87,6 +89,7 @@ export async function runCommand( logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); const snapshot = await updateSnapshotInConfig( uploadResponse.site_id, siteFolder ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 8c0b7d3b3a..8de9caf494 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -7,7 +7,7 @@ import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constant import { cacheFunctionTTL } from '@studio/common/lib/cache-function-ttl'; 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 { SITE_EVENTS, SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; import { z } from 'zod'; import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, @@ -347,3 +347,11 @@ export async function emitSiteEvent( // Do nothing } } + +export async function emitSnapshotEvent( event: SNAPSHOT_EVENTS ): Promise< void > { + try { + await eventsSocketClient.send( { event, data: {} } ); + } catch { + // Do nothing + } +} diff --git a/apps/studio/src/ipc-utils.ts b/apps/studio/src/ipc-utils.ts index 3b30b915e9..06813265eb 100644 --- a/apps/studio/src/ipc-utils.ts +++ b/apps/studio/src/ipc-utils.ts @@ -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': [ void ]; '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/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index be882b6a3a..4ad4c82e52 100644 --- a/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts +++ b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts @@ -3,6 +3,7 @@ import { siteEventSchema, SiteEvent, SITE_EVENTS, + SNAPSHOT_EVENTS, SiteDetails, } from '@studio/common/lib/site-events'; import { z } from 'zod'; @@ -71,6 +72,19 @@ const cliSiteEventSchema = z.object( { .pipe( siteEventSchema ), } ); +const snapshotEventPayloadSchema = z.object( { + event: z.nativeEnum( SNAPSHOT_EVENTS ), +} ); + +const cliSnapshotEventSchema = z.object( { + action: z.literal( 'keyValuePair' ), + key: z.literal( 'snapshot-event' ), + value: z + .string() + .transform( ( val ) => JSON.parse( val ) ) + .pipe( snapshotEventPayloadSchema ), +} ); + let subscriber: ReturnType< typeof executeCliCommand > | null = null; export async function startCliEventsSubscriber(): Promise< void > { @@ -90,6 +104,12 @@ export async function startCliEventsSubscriber(): Promise< void > { } ); eventEmitter.on( 'data', ( { data } ) => { + const snapshotParsed = cliSnapshotEventSchema.safeParse( data ); + if ( snapshotParsed.success ) { + void sendIpcEventToRenderer( 'snapshot-changed' ); + return; + } + const parsed = cliSiteEventSchema.safeParse( data ); if ( ! parsed.success ) { return; diff --git a/apps/studio/src/stores/snapshot-slice.ts b/apps/studio/src/stores/snapshot-slice.ts index 9250b7ceaa..fac1de3ecb 100644 --- a/apps/studio/src/stores/snapshot-slice.ts +++ b/apps/studio/src/stores/snapshot-slice.ts @@ -357,6 +357,10 @@ function isBulkOperationSettled( bulkOperation: BulkOperation ) { } ); } +window.ipcListener.subscribe( 'snapshot-changed', () => { + void refreshSnapshots(); +} ); + window.ipcListener.subscribe( 'snapshot-output', ( event, payload ) => { const operation = getOperation( payload.operationId ); if ( ! operation ) { diff --git a/tools/common/lib/site-events.ts b/tools/common/lib/site-events.ts index b5ae1a2bb4..758dfc4fe2 100644 --- a/tools/common/lib/site-events.ts +++ b/tools/common/lib/site-events.ts @@ -44,6 +44,12 @@ export enum SITE_EVENTS { 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(), From c758246d6ab6d8338fa105b3ec5d8cb2cb018cf7 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 16:58:45 +0000 Subject: [PATCH 03/12] Move snapshot name generation from cli-config to preview create command --- apps/cli/ai/tests/tools.test.ts | 9 +++++++-- apps/cli/commands/preview/create.ts | 18 ++++++++++++++--- .../cli/commands/preview/tests/create.test.ts | 13 ++++++++---- apps/cli/lib/cli-config.ts | 19 ++++++++---------- apps/cli/lib/tests/snapshots.test.ts | 20 +++++++++++++++---- 5 files changed, 55 insertions(+), 24 deletions(-) diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 54378ce17b..f4ed457e5f 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, readAppdata } from 'cli/lib/appdata'; +import { readAppdata } from 'cli/lib/appdata'; +import { getSiteByFolder } from 'cli/lib/cli-config'; import { getProgressCallback, setProgressCallback } from 'cli/logger'; import { studioToolDefinitions } from '../tools'; @@ -58,10 +59,14 @@ vi.mock( 'cli/commands/site/stop', () => ( { vi.mock( 'cli/lib/appdata', async () => ( { ...( await vi.importActual( 'cli/lib/appdata' ) ), - getSiteByFolder: vi.fn(), readAppdata: vi.fn(), } ) ); +vi.mock( 'cli/lib/cli-config', async () => ( { + ...( await vi.importActual( 'cli/lib/cli-config' ) ), + getSiteByFolder: vi.fn(), +} ) ); + vi.mock( 'cli/lib/daemon-client', () => ( { connectToDaemon: vi.fn(), disconnectFromDaemon: vi.fn(), diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index edd2af90ca..811de2ae54 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -7,9 +7,9 @@ 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 { getSiteByFolder, getNextSnapshotSequence } from 'cli/lib/cli-config'; import { emitSnapshotEvent } from 'cli/lib/daemon-client'; -import { saveSnapshotToConfig } from 'cli/lib/snapshots'; +import { getSnapshotsFromConfig, saveSnapshotToConfig } from 'cli/lib/snapshots'; import { validateSiteSize } from 'cli/lib/validation'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -44,12 +44,24 @@ export async function runCommand( siteFolder: string, name?: string ): Promise< ); logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); + 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, token.id, - name + snapshotName ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); await emitSnapshotEvent( SNAPSHOT_EVENTS.CREATED ); diff --git a/apps/cli/commands/preview/tests/create.test.ts b/apps/cli/commands/preview/tests/create.test.ts index 522d36d800..2074ec5fd5 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 { saveSnapshotToConfig } from 'cli/lib/snapshots'; +import { getSiteByFolder, getNextSnapshotSequence } from 'cli/lib/cli-config'; +import { getSnapshotsFromConfig, saveSnapshotToConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { runCommand } from '../create'; @@ -27,13 +27,18 @@ vi.mock( 'cli/lib/appdata', async () => ( { vi.mock( 'cli/lib/cli-config', async () => ( { ...( await vi.importActual( 'cli/lib/cli-config' ) ), getSiteByFolder: vi.fn(), + getNextSnapshotSequence: vi.fn().mockReturnValue( 1 ), } ) ); vi.mock( 'cli/lib/validation', () => ( { validateSiteSize: vi.fn(), } ) ); 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; @@ -138,7 +143,7 @@ describe( 'Preview Create Command', () => { mockAtomicSiteId, mockSiteUrl, mockAuthToken.id, - undefined + 'Test Site Preview 1' ); expect( mockReportStart.mock.calls[ 4 ] ).toEqual( [ 'appdata', diff --git a/apps/cli/lib/cli-config.ts b/apps/cli/lib/cli-config.ts index c3241b13a5..bfcd96936f 100644 --- a/apps/cli/lib/cli-config.ts +++ b/apps/cli/lib/cli-config.ts @@ -5,7 +5,7 @@ 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 { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; import { z } from 'zod'; import { STUDIO_CLI_HOME } from 'cli/lib/paths'; @@ -233,7 +233,7 @@ export async function saveSnapshotToConfig( atomicSiteId: number, previewUrl: string, userId: number, - name?: string + name: string ): Promise< Snapshot > { try { const site = await getSiteByFolder( siteFolder ); @@ -246,14 +246,7 @@ export async function saveSnapshotToConfig( atomicSiteId, localSiteId: site.id, date: Date.now(), - name: - name || - sprintf( - /* translators: 1: Site name 2: Sequence number (e.g. "My Site Name Preview 1") */ - __( '%1$s Preview %2$d' ), - site.name, - nextSequenceNumber - ), + name, sequence: nextSequenceNumber, userId, }; @@ -327,7 +320,11 @@ export async function setSnapshotInConfig( } } -function getNextSnapshotSequence( siteId: string, snapshots: Snapshot[], userId: number ): number { +export function getNextSnapshotSequence( + siteId: string, + snapshots: Snapshot[], + userId: number +): number { const siteSnapshots = snapshots.filter( ( s ) => s.localSiteId === siteId && s.userId === userId ); diff --git a/apps/cli/lib/tests/snapshots.test.ts b/apps/cli/lib/tests/snapshots.test.ts index 3d8d760358..289a0d6303 100644 --- a/apps/cli/lib/tests/snapshots.test.ts +++ b/apps/cli/lib/tests/snapshots.test.ts @@ -100,7 +100,13 @@ describe( 'Snapshots Module', () => { mocks.readFile.mockResolvedValue( JSON.stringify( mockUserData ) ); - await saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId ); + 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 saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId + 1, mockSiteUrl, mockUserId ); + 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( - saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId ) + saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId, 'Test' ) ).rejects.toThrow( LoggerError ); expect( writeFile ).not.toHaveBeenCalled(); @@ -199,7 +211,7 @@ describe( 'Snapshots Module', () => { mocks.existsSync.mockReturnValueOnce( false ); await expect( - saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId ) + saveSnapshotToConfig( mockSiteFolder, mockAtomicSiteId, mockSiteUrl, mockUserId, 'Test' ) ).rejects.toThrow( LoggerError ); } ); } ); From 05f2c0e0a25b68a10343db72e7f38b0c54c58a2e Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 17:16:59 +0000 Subject: [PATCH 04/12] Split cli-config.ts into folder structure (core, sites, snapshots) --- apps/cli/lib/cli-config.ts | 339 --------------------------- apps/cli/lib/cli-config/core.ts | 121 ++++++++++ apps/cli/lib/cli-config/index.ts | 25 ++ apps/cli/lib/cli-config/sites.ts | 102 ++++++++ apps/cli/lib/cli-config/snapshots.ts | 130 ++++++++++ 5 files changed, 378 insertions(+), 339 deletions(-) delete mode 100644 apps/cli/lib/cli-config.ts create mode 100644 apps/cli/lib/cli-config/core.ts create mode 100644 apps/cli/lib/cli-config/index.ts create mode 100644 apps/cli/lib/cli-config/sites.ts create mode 100644 apps/cli/lib/cli-config/snapshots.ts diff --git a/apps/cli/lib/cli-config.ts b/apps/cli/lib/cli-config.ts deleted file mode 100644 index bfcd96936f..0000000000 --- a/apps/cli/lib/cli-config.ts +++ /dev/null @@ -1,339 +0,0 @@ -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 { snapshotSchema, type Snapshot } 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 { 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( () => [] ), - snapshots: z.array( snapshotSchema ).default( () => [] ), -} ); - -type CliConfig = z.infer< typeof cliConfigSchema >; -export type SiteData = z.infer< typeof siteSchema >; - -const DEFAULT_CLI_CONFIG: CliConfig = { - version: 1, - sites: [], - 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(); - - 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(); - } -} - -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(); - } -} - -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; -} diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts new file mode 100644 index 0000000000..7b6ea7610d --- /dev/null +++ b/apps/cli/lib/cli-config/core.ts @@ -0,0 +1,121 @@ +import fs from 'fs'; +import path from 'path'; +import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; +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'; +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( () => [] ), + snapshots: z.array( snapshotSchema ).default( () => [] ), +} ); + +type CliConfig = z.infer< typeof cliConfigSchema >; +export type SiteData = z.infer< typeof siteSchema >; + +const DEFAULT_CLI_CONFIG: CliConfig = { + version: 1, + sites: [], + 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(); + + 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 ); +} diff --git a/apps/cli/lib/cli-config/index.ts b/apps/cli/lib/cli-config/index.ts new file mode 100644 index 0000000000..a3117e248d --- /dev/null +++ b/apps/cli/lib/cli-config/index.ts @@ -0,0 +1,25 @@ +export type { SiteData } from './core'; +export { + getCliConfigDirectory, + getCliConfigPath, + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, +} from './core'; +export { + clearSiteLatestCliPid, + getSiteByFolder, + getSiteUrl, + removeSiteFromConfig, + updateSiteAutoStart, + updateSiteLatestCliPid, +} from './sites'; +export { + deleteSnapshotFromConfig, + getNextSnapshotSequence, + getSnapshotsFromConfig, + saveSnapshotToConfig, + setSnapshotInConfig, + updateSnapshotInConfig, +} from './snapshots'; 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..fcb633051e --- /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 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(); + } +} + +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; +} From df9f4c0c0f294207434cb07fbd2c3d8786c042db Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 17:35:27 +0000 Subject: [PATCH 05/12] Rename site-events to cli-events and enrich snapshot events with inline data (STU-1350) --- apps/cli/commands/_events.ts | 31 +++++++--- apps/cli/commands/preview/create.ts | 4 +- apps/cli/commands/preview/delete.ts | 4 +- apps/cli/commands/preview/set.ts | 4 +- apps/cli/commands/preview/update.ts | 4 +- apps/cli/commands/site/create.ts | 2 +- apps/cli/commands/site/delete.ts | 2 +- apps/cli/commands/site/list.ts | 2 +- apps/cli/commands/site/set.ts | 2 +- apps/cli/lib/cli-config/core.ts | 2 +- apps/cli/lib/daemon-client.ts | 9 ++- apps/cli/lib/wordpress-server-manager.ts | 2 +- apps/studio/src/hooks/use-site-details.tsx | 2 +- apps/studio/src/ipc-utils.ts | 4 +- .../modules/cli/lib/cli-events-subscriber.ts | 14 ++--- apps/studio/src/site-server.ts | 2 +- apps/studio/src/stores/snapshot-slice.ts | 60 ++++++++++++++++++- .../lib/{site-events.ts => cli-events.ts} | 13 +++- 18 files changed, 120 insertions(+), 43 deletions(-) rename tools/common/lib/{site-events.ts => cli-events.ts} (77%) diff --git a/apps/cli/commands/_events.ts b/apps/cli/commands/_events.ts index b9a56f9f72..5d182116a2 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -6,13 +6,14 @@ * stdout key-value pairs that Studio parses. */ -import { sequential } from '@studio/common/lib/sequential'; import { SITE_EVENTS, SNAPSHOT_EVENTS, siteDetailsSchema, SiteEvent, -} from '@studio/common/lib/site-events'; + SnapshotEvent, +} from '@studio/common/lib/cli-events'; +import { sequential } from '@studio/common/lib/sequential'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { z } from 'zod'; @@ -80,22 +81,34 @@ const siteEventSchema = z.object( { } ), } ); -const snapshotEventSchema = z.object( { +const snapshotSocketEventSchema = z.object( { event: z.nativeEnum( SNAPSHOT_EVENTS ), - data: z.object( {} ), + data: z.object( { + snapshotUrl: z.string(), + } ), } ); -function emitSnapshotEvent( event: SNAPSHOT_EVENTS ): void { - logger.reportKeyValuePair( 'snapshot-event', JSON.stringify( { event } ) ); -} +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 snapshotParsed = snapshotEventSchema.safeParse( packet ); + const snapshotParsed = snapshotSocketEventSchema.safeParse( packet ); if ( snapshotParsed.success ) { - emitSnapshotEvent( snapshotParsed.data.event ); + void emitSnapshotEvent( snapshotParsed.data.event, snapshotParsed.data.data.snapshotUrl ); return; } diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index 811de2ae54..d7dd10674c 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -1,7 +1,7 @@ import os from 'os'; import path from 'path'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; -import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; +import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; import { uploadArchive, waitForSiteReady } from 'cli/lib/api'; @@ -64,7 +64,7 @@ export async function runCommand( siteFolder: string, name?: string ): Promise< snapshotName ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.CREATED ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.CREATED, { snapshotUrl: snapshot.url } ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); logger.reportKeyValuePair( 'url', snapshot.url ); diff --git a/apps/cli/commands/preview/delete.ts b/apps/cli/commands/preview/delete.ts index 8468159a00..045f53749a 100644 --- a/apps/cli/commands/preview/delete.ts +++ b/apps/cli/commands/preview/delete.ts @@ -1,4 +1,4 @@ -import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; +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'; @@ -30,7 +30,7 @@ export async function runCommand( host: string ): Promise< void > { logger.reportStart( LoggerAction.DELETE, __( 'Deleting…' ) ); await deleteSnapshot( snapshotToDelete.atomicSiteId, token.accessToken ); await deleteSnapshotFromConfig( snapshotToDelete.url ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.DELETED ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.DELETED, { snapshotUrl: snapshotToDelete.url } ); logger.reportSuccess( __( 'Deletion successful' ) ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/preview/set.ts b/apps/cli/commands/preview/set.ts index 9d97a9a193..2e25728c48 100644 --- a/apps/cli/commands/preview/set.ts +++ b/apps/cli/commands/preview/set.ts @@ -1,4 +1,4 @@ -import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; +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'; @@ -26,7 +26,7 @@ export async function runCommand( host: string, options: SetCommandOptions ): Pr try { logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); await setSnapshotInConfig( host, { name } ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED, { snapshotUrl: host } ); logger.reportSuccess( __( 'Preview site updated' ) ); } catch ( error ) { if ( error instanceof LoggerError ) { diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index 6a1bed2e18..b33e551dda 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -2,7 +2,7 @@ import os from 'node:os'; import path from 'node:path'; import { DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; -import { SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; +import { SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { Snapshot } from '@studio/common/types/snapshot'; import { __, _n, sprintf } from '@wordpress/i18n'; @@ -89,7 +89,7 @@ export async function runCommand( logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); const snapshot = await updateSnapshotInConfig( uploadResponse.site_id, siteFolder ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED, { 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 a7a103806c..70f3202af5 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -35,7 +35,7 @@ import { hasDefaultDbBlock, removeDbConstants, } from '@studio/common/lib/remove-default-db-constants'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { sortSites } from '@studio/common/lib/sort-sites'; import { isValidWordPressVersion, diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 50b8dca497..f3e3fe079a 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -1,6 +1,6 @@ import fs from 'fs'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import { deleteSnapshot } from 'cli/lib/api'; diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index bb9586a92c..9f4069925a 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -1,4 +1,4 @@ -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 Table from 'cli-table3'; diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index d70e8351ea..e22a87d28c 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -7,7 +7,7 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; -import { SITE_EVENTS } from '@studio/common/lib/site-events'; +import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; import { getWordPressVersionUrl, diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index 7b6ea7610d..f55ae4fe4b 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { siteDetailsSchema } from '@studio/common/lib/site-events'; +import { siteDetailsSchema } from '@studio/common/lib/cli-events'; import { snapshotSchema } from '@studio/common/types/snapshot'; import { __ } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 8de9caf494..5dbbaf257e 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -7,7 +7,7 @@ import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constant import { cacheFunctionTTL } from '@studio/common/lib/cache-function-ttl'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { SITE_EVENTS, SNAPSHOT_EVENTS } from '@studio/common/lib/site-events'; +import { SITE_EVENTS, SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { z } from 'zod'; import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, @@ -348,9 +348,12 @@ export async function emitSiteEvent( } } -export async function emitSnapshotEvent( event: SNAPSHOT_EVENTS ): Promise< void > { +export async function emitSnapshotEvent( + event: SNAPSHOT_EVENTS, + data: { snapshotUrl: string } +): Promise< void > { try { - await eventsSocketClient.send( { event, data: {} } ); + await eventsSocketClient.send( { event, data } ); } catch { // Do nothing } diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 1d7a9c66c9..174b3d8be0 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -10,7 +10,7 @@ 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 { 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-utils.ts b/apps/studio/src/ipc-utils.ts index 06813265eb..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,7 +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': [ void ]; + '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/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index 4ad4c82e52..fd981f1d9c 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, + snapshotEventSchema, SiteEvent, SITE_EVENTS, - SNAPSHOT_EVENTS, SiteDetails, -} from '@studio/common/lib/site-events'; +} from '@studio/common/lib/cli-events'; +import { sequential } from '@studio/common/lib/sequential'; import { z } from 'zod'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; @@ -72,17 +72,13 @@ const cliSiteEventSchema = z.object( { .pipe( siteEventSchema ), } ); -const snapshotEventPayloadSchema = z.object( { - event: z.nativeEnum( SNAPSHOT_EVENTS ), -} ); - const cliSnapshotEventSchema = z.object( { action: z.literal( 'keyValuePair' ), key: z.literal( 'snapshot-event' ), value: z .string() .transform( ( val ) => JSON.parse( val ) ) - .pipe( snapshotEventPayloadSchema ), + .pipe( snapshotEventSchema ), } ); let subscriber: ReturnType< typeof executeCliCommand > | null = null; @@ -106,7 +102,7 @@ export async function startCliEventsSubscriber(): Promise< void > { eventEmitter.on( 'data', ( { data } ) => { const snapshotParsed = cliSnapshotEventSchema.safeParse( data ); if ( snapshotParsed.success ) { - void sendIpcEventToRenderer( 'snapshot-changed' ); + void sendIpcEventToRenderer( 'snapshot-changed', snapshotParsed.data.value ); return; } diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index e46b9d8523..f178048bad 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -3,7 +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 { siteListSchema, type SiteListItem } from '@studio/common/lib/cli-events'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; diff --git a/apps/studio/src/stores/snapshot-slice.ts b/apps/studio/src/stores/snapshot-slice.ts index fac1de3ecb..78ff1ddd02 100644 --- a/apps/studio/src/stores/snapshot-slice.ts +++ b/apps/studio/src/stores/snapshot-slice.ts @@ -6,6 +6,7 @@ import { isAnyOf, PayloadAction, } from '@reduxjs/toolkit'; +import { SNAPSHOT_EVENTS, SnapshotEvent } 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'; @@ -357,8 +358,63 @@ function isBulkOperationSettled( bulkOperation: BulkOperation ) { } ); } -window.ipcListener.subscribe( 'snapshot-changed', () => { - void refreshSnapshots(); +window.ipcListener.subscribe( 'snapshot-changed', ( event, snapshotEvent: 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 } ) + ); + store.dispatch( + wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { + data.siteCount = Math.max( 0, data.siteCount - 1 ); + } ) + ); + setTimeout( () => { + store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); + }, 8000 ); + } + 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.setSnapshots( { + snapshots: [ ...state.snapshot.snapshots, snapshot ], + } ) + ); + store.dispatch( + wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { + data.siteCount = data.siteCount + 1; + } ) + ); + setTimeout( () => { + store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); + }, 8000 ); + } + return; + } + + if ( eventType === SNAPSHOT_EVENTS.UPDATED ) { + store.dispatch( + snapshotActions.updateSnapshotLocally( { + atomicSiteId: snapshot.atomicSiteId, + snapshot, + } ) + ); + } } ); window.ipcListener.subscribe( 'snapshot-output', ( event, payload ) => { diff --git a/tools/common/lib/site-events.ts b/tools/common/lib/cli-events.ts similarity index 77% rename from tools/common/lib/site-events.ts rename to tools/common/lib/cli-events.ts index 758dfc4fe2..51e66eae54 100644 --- a/tools/common/lib/site-events.ts +++ b/tools/common/lib/cli-events.ts @@ -1,10 +1,11 @@ /** - * Shared types for site events between CLI and Studio app. + * 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 site state without reading appdata. + * subscribes to them to maintain its state without reading config files. */ import { z } from 'zod'; +import { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; /** * Site data included in events. This is the data Studio needs to display sites. @@ -58,3 +59,11 @@ export const siteEventSchema = z.object( { } ); export type SiteEvent = z.infer< typeof siteEventSchema >; + +export const snapshotEventSchema = z.object( { + event: z.nativeEnum( SNAPSHOT_EVENTS ), + snapshot: snapshotSchema.optional(), + snapshotUrl: z.string(), +} ); + +export type SnapshotEvent = z.infer< typeof snapshotEventSchema >; From 0683f63d4b058b5b065367cea1cfdd951f3ddca9 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 18:09:22 +0000 Subject: [PATCH 06/12] Fix snapshot update infinite loop and preview set error handling --- apps/cli/commands/preview/set.ts | 17 ++++------------- apps/studio/src/stores/index.ts | 2 +- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/cli/commands/preview/set.ts b/apps/cli/commands/preview/set.ts index 2e25728c48..3adf3b5e34 100644 --- a/apps/cli/commands/preview/set.ts +++ b/apps/cli/commands/preview/set.ts @@ -23,19 +23,10 @@ export async function runCommand( host: string, options: SetCommandOptions ): Pr throw new LoggerError( __( 'Preview site name cannot be empty.' ) ); } - try { - logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); - await setSnapshotInConfig( host, { name } ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED, { snapshotUrl: host } ); - logger.reportSuccess( __( 'Preview site updated' ) ); - } catch ( error ) { - if ( error instanceof LoggerError ) { - logger.reportError( error ); - } else { - const loggerError = new LoggerError( __( 'Failed to update preview site' ), error ); - logger.reportError( loggerError ); - } - } + logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); + await setSnapshotInConfig( host, { name } ); + await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED, { snapshotUrl: host } ); + logger.reportSuccess( __( 'Preview site updated' ) ); } export const registerCommand = ( yargs: StudioArgv ) => { diff --git a/apps/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index ae5dda1263..c7e259ef68 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -91,7 +91,7 @@ startAppListening( { const { atomicSiteId, snapshot } = action.payload; const state = store.getState(); const existing = state.snapshot.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); - if ( existing?.url && snapshot.name !== undefined ) { + if ( existing?.url && snapshot.name !== undefined && snapshot.name !== existing.name ) { await getIpcApi().setSnapshot( existing.url, { name: snapshot.name } ); } }, From 5bef546b98bd3bda865a296687372d1330c2f0be Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 19:47:10 +0000 Subject: [PATCH 07/12] Unify CLI event emitter, share event schemas in cli-events, and clean up daemon-client (STU-1350) --- apps/cli/__mocks__/lib/daemon-client.ts | 2 +- apps/cli/commands/_events.ts | 19 ++------- apps/cli/commands/preview/create.ts | 6 +-- apps/cli/commands/preview/delete.ts | 7 +++- apps/cli/commands/preview/set.ts | 4 +- apps/cli/commands/preview/update.ts | 6 +-- apps/cli/commands/site/create.ts | 6 +-- apps/cli/commands/site/delete.ts | 6 +-- apps/cli/commands/site/set.ts | 6 +-- apps/cli/lib/cli-config/snapshots.ts | 36 ++++++++--------- apps/cli/lib/daemon-client.ts | 29 ++++---------- .../modules/cli/lib/cli-events-subscriber.ts | 23 +---------- tools/common/lib/cli-events.ts | 40 ++++++++++++++++++- 13 files changed, 93 insertions(+), 97 deletions(-) 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/commands/_events.ts b/apps/cli/commands/_events.ts index 5d182116a2..5ee33025cb 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -10,13 +10,14 @@ import { SITE_EVENTS, SNAPSHOT_EVENTS, siteDetailsSchema, + siteSocketEventSchema, + snapshotSocketEventSchema, SiteEvent, SnapshotEvent, } from '@studio/common/lib/cli-events'; import { sequential } from '@studio/common/lib/sequential'; 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 { connectToDaemon, @@ -74,20 +75,6 @@ async function emitAllSitesStopped(): Promise< void > { } } -const siteEventSchema = z.object( { - event: z.string(), - data: z.object( { - siteId: z.string(), - } ), -} ); - -const snapshotSocketEventSchema = z.object( { - event: z.nativeEnum( SNAPSHOT_EVENTS ), - data: z.object( { - snapshotUrl: z.string(), - } ), -} ); - const emitSnapshotEvent = sequential( async ( event: SNAPSHOT_EVENTS, snapshotUrl: string ): Promise< void > => { const cliConfig = await readCliConfig(); @@ -112,7 +99,7 @@ export async function runCommand(): Promise< void > { return; } - const parsedPacket = siteEventSchema.parse( packet ); + 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 d7dd10674c..640fc3f03b 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -1,14 +1,14 @@ import os from 'os'; import path from 'path'; -import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; 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, getNextSnapshotSequence } from 'cli/lib/cli-config'; -import { emitSnapshotEvent } from 'cli/lib/daemon-client'; +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'; @@ -64,7 +64,7 @@ export async function runCommand( siteFolder: string, name?: string ): Promise< snapshotName ); logger.reportSuccess( __( 'Preview site saved to Studio' ) ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.CREATED, { snapshotUrl: snapshot.url } ); + await emitCliEvent( { event: SNAPSHOT_EVENTS.CREATED, data: { snapshotUrl: snapshot.url } } ); logger.reportKeyValuePair( 'name', snapshot.name ?? '' ); logger.reportKeyValuePair( 'url', snapshot.url ); diff --git a/apps/cli/commands/preview/delete.ts b/apps/cli/commands/preview/delete.ts index 045f53749a..d6ed51338b 100644 --- a/apps/cli/commands/preview/delete.ts +++ b/apps/cli/commands/preview/delete.ts @@ -3,7 +3,7 @@ import { PreviewCommandLoggerAction as LoggerAction } from '@studio/common/logge import { __ } from '@wordpress/i18n'; import { deleteSnapshot } from 'cli/lib/api'; import { getAuthToken } from 'cli/lib/appdata'; -import { emitSnapshotEvent } from 'cli/lib/daemon-client'; +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'; @@ -30,7 +30,10 @@ export async function runCommand( host: string ): Promise< void > { logger.reportStart( LoggerAction.DELETE, __( 'Deleting…' ) ); await deleteSnapshot( snapshotToDelete.atomicSiteId, token.accessToken ); await deleteSnapshotFromConfig( snapshotToDelete.url ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.DELETED, { snapshotUrl: 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/set.ts b/apps/cli/commands/preview/set.ts index 3adf3b5e34..31c66ebfa0 100644 --- a/apps/cli/commands/preview/set.ts +++ b/apps/cli/commands/preview/set.ts @@ -2,7 +2,7 @@ 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'; -import { emitSnapshotEvent } from 'cli/lib/daemon-client'; +import { emitCliEvent } from 'cli/lib/daemon-client'; import { normalizeHostname } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; @@ -25,7 +25,7 @@ export async function runCommand( host: string, options: SetCommandOptions ): Pr logger.reportStart( LoggerAction.SET, __( 'Updating preview site…' ) ); await setSnapshotInConfig( host, { name } ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED, { snapshotUrl: host } ); + await emitCliEvent( { event: SNAPSHOT_EVENTS.UPDATED, data: { snapshotUrl: host } } ); logger.reportSuccess( __( 'Preview site updated' ) ); } diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index b33e551dda..46d0cebb2b 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -1,8 +1,8 @@ import os from 'node:os'; import path from 'node:path'; import { DEMO_SITE_EXPIRATION_DAYS } from '@studio/common/constants'; -import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; 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'; import { __, _n, sprintf } from '@wordpress/i18n'; @@ -11,7 +11,7 @@ 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 { emitSnapshotEvent } from 'cli/lib/daemon-client'; +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'; @@ -89,7 +89,7 @@ export async function runCommand( logger.reportStart( LoggerAction.APPDATA, __( 'Saving preview site to Studio…' ) ); const snapshot = await updateSnapshotInConfig( uploadResponse.site_id, siteFolder ); - await emitSnapshotEvent( SNAPSHOT_EVENTS.UPDATED, { snapshotUrl: snapshot.url } ); + 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 70f3202af5..c907b6305e 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -14,6 +14,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, @@ -35,7 +36,6 @@ import { hasDefaultDbBlock, removeDbConstants, } from '@studio/common/lib/remove-default-db-constants'; -import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { sortSites } from '@studio/common/lib/sort-sites'; import { isValidWordPressVersion, @@ -59,7 +59,7 @@ import { updateSiteAutoStart, updateSiteLatestCliPid, } from 'cli/lib/cli-config'; -import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; +import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { generateSiteName, getDefaultSitePath } from 'cli/lib/generate-site-name'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; import { getServerFilesPath } from 'cli/lib/server-files'; @@ -444,7 +444,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 f3e3fe079a..5df5a6172a 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -1,6 +1,6 @@ import fs from 'fs'; -import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { SITE_EVENTS } from '@studio/common/lib/cli-events'; +import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n, sprintf } from '@wordpress/i18n'; import { deleteSnapshot } from 'cli/lib/api'; @@ -13,7 +13,7 @@ import { saveCliConfig, unlockCliConfig, } from 'cli/lib/cli-config'; -import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; +import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { removeDomainFromHosts } from 'cli/lib/hosts-file'; import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils'; import { getSnapshotsFromConfig, deleteSnapshotFromConfig } from 'cli/lib/snapshots'; @@ -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/set.ts b/apps/cli/commands/site/set.ts index e22a87d28c..4be83812c4 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/cli-events'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; import { getWordPressVersionUrl, @@ -24,7 +24,7 @@ import { unlockCliConfig, updateSiteLatestCliPid, } from 'cli/lib/cli-config'; -import { connectToDaemon, disconnectFromDaemon, emitSiteEvent } from 'cli/lib/daemon-client'; +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 +315,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/lib/cli-config/snapshots.ts b/apps/cli/lib/cli-config/snapshots.ts index fcb633051e..010879ff22 100644 --- a/apps/cli/lib/cli-config/snapshots.ts +++ b/apps/cli/lib/cli-config/snapshots.ts @@ -4,6 +4,24 @@ 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 @@ -110,21 +128,3 @@ export async function setSnapshotInConfig( await unlockCliConfig(); } } - -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; -} diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index 5dbbaf257e..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, SNAPSHOT_EVENTS } from '@studio/common/lib/cli-events'; import { z } from 'zod'; import { PROCESS_MANAGER_EVENTS_SOCKET_PATH, @@ -331,29 +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 > { - try { - await eventsSocketClient.send( { event, data } ); - } catch { - // Do nothing - } -} - -export async function emitSnapshotEvent( - event: SNAPSHOT_EVENTS, - data: { snapshotUrl: 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/studio/src/modules/cli/lib/cli-events-subscriber.ts b/apps/studio/src/modules/cli/lib/cli-events-subscriber.ts index fd981f1d9c..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,12 +1,11 @@ import { - siteEventSchema, - snapshotEventSchema, + cliSiteEventSchema, + cliSnapshotEventSchema, SiteEvent, SITE_EVENTS, SiteDetails, } from '@studio/common/lib/cli-events'; import { sequential } from '@studio/common/lib/sequential'; -import { z } from 'zod'; import { sendIpcEventToRenderer } from 'src/ipc-utils'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; import { SiteServer } from 'src/site-server'; @@ -63,24 +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 ), -} ); - -const cliSnapshotEventSchema = z.object( { - action: z.literal( 'keyValuePair' ), - key: z.literal( 'snapshot-event' ), - value: z - .string() - .transform( ( val ) => JSON.parse( val ) ) - .pipe( snapshotEventSchema ), -} ); - let subscriber: ReturnType< typeof executeCliCommand > | null = null; export async function startCliEventsSubscriber(): Promise< void > { diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index 51e66eae54..9eca78e191 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -4,8 +4,8 @@ * The CLI emits these events via the `_events` command, and Studio * subscribes to them to maintain its state without reading config files. */ +import { snapshotSchema } from '@studio/common/types/snapshot'; import { z } from 'zod'; -import { snapshotSchema, type Snapshot } from '@studio/common/types/snapshot'; /** * Site data included in events. This is the data Studio needs to display sites. @@ -67,3 +67,41 @@ export const snapshotEventSchema = z.object( { } ); 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.nativeEnum( 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 ), +} ); From f2e49dc2b941a7e4712b4c535bb2ebaccc5240ce Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 19:48:21 +0000 Subject: [PATCH 08/12] trigger ci From d178ad3bc463b8da5e816cdfcfbaa20425985ed5 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 16 Mar 2026 20:00:50 +0000 Subject: [PATCH 09/12] Fix import order lint errors in core.ts and site-server.ts --- apps/cli/lib/cli-config/core.ts | 2 +- apps/studio/src/site-server.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index f55ae4fe4b..bdae5c5ea6 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -1,8 +1,8 @@ import fs from 'fs'; import path from 'path'; import { LOCKFILE_STALE_TIME, LOCKFILE_WAIT_TIME } from '@studio/common/constants'; -import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { siteDetailsSchema } from '@studio/common/lib/cli-events'; +import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { snapshotSchema } from '@studio/common/types/snapshot'; import { __ } from '@wordpress/i18n'; import { readFile, writeFile } from 'atomically'; diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index f178048bad..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 { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; import { siteListSchema, type SiteListItem } from '@studio/common/lib/cli-events'; +import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; From d160808782949107c48f9a03d04482fe12d93279 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 17 Mar 2026 12:12:32 +0000 Subject: [PATCH 10/12] Address PR review: add stdout json output, addSnapshot action, reusable usage count helper, fix listener state bug --- apps/cli/commands/preview/list.ts | 6 ++- apps/cli/commands/site/list.ts | 7 ++- apps/studio/src/stores/index.ts | 11 ++-- apps/studio/src/stores/snapshot-slice.ts | 68 +++++++++++------------- 4 files changed, 45 insertions(+), 47 deletions(-) diff --git a/apps/cli/commands/preview/list.ts b/apps/cli/commands/preview/list.ts index a04692876a..3ca2631738 100644 --- a/apps/cli/commands/preview/list.ts +++ b/apps/cli/commands/preview/list.ts @@ -3,7 +3,7 @@ import { __, _n, sprintf } from '@wordpress/i18n'; import Table from 'cli-table3'; import { format } from 'date-fns'; import { getAuthToken } from 'cli/lib/appdata'; -import { readCliConfig } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; import { formatDurationUntilExpiry, getSnapshotsFromConfig, @@ -22,7 +22,9 @@ export async function runCommand( try { if ( outputFormat === 'json' ) { const config = await readCliConfig(); - logger.reportKeyValuePair( 'snapshots', JSON.stringify( config.snapshots ) ); + const json = JSON.stringify( config.snapshots ); + console.log( json ); + logger.reportKeyValuePair( 'snapshots', json ); return; } diff --git a/apps/cli/commands/site/list.ts b/apps/cli/commands/site/list.ts index 9f4069925a..d894ad733a 100644 --- a/apps/cli/commands/site/list.ts +++ b/apps/cli/commands/site/list.ts @@ -2,7 +2,8 @@ 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 Table 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/studio/src/stores/index.ts b/apps/studio/src/stores/index.ts index c7e259ef68..096ecaf1f6 100644 --- a/apps/studio/src/stores/index.ts +++ b/apps/studio/src/stores/index.ts @@ -87,12 +87,13 @@ startAppListening( { // Save snapshot changes to CLI config via preview set command startAppListening( { actionCreator: updateSnapshotLocally, - async effect( action ) { + async effect( action, listenerApi ) { const { atomicSiteId, snapshot } = action.payload; - const state = store.getState(); - const existing = state.snapshot.snapshots.find( ( s ) => s.atomicSiteId === atomicSiteId ); - if ( existing?.url && snapshot.name !== undefined && snapshot.name !== existing.name ) { - await getIpcApi().setSnapshot( existing.url, { name: snapshot.name } ); + 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 } ); } }, } ); diff --git a/apps/studio/src/stores/snapshot-slice.ts b/apps/studio/src/stores/snapshot-slice.ts index 78ff1ddd02..b06a154193 100644 --- a/apps/studio/src/stores/snapshot-slice.ts +++ b/apps/studio/src/stores/snapshot-slice.ts @@ -6,7 +6,7 @@ import { isAnyOf, PayloadAction, } from '@reduxjs/toolkit'; -import { SNAPSHOT_EVENTS, SnapshotEvent } from '@studio/common/lib/cli-events'; +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'; @@ -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,24 +298,34 @@ const selectSnapshotsBySiteAndUser = createSelector( ) ); +// 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; + } + + store.dispatch( + wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { + 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 ); +} + 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 } ) ); - - if ( countDiff !== 0 ) { - store.dispatch( - wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { - data.siteCount = Math.max( 0, data.siteCount + countDiff ); - } ) - ); - - setTimeout( () => { - store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); - }, 8000 ); - } + updateSnapshotUsageCount( countDiff ); } function getOperationProgress( action: PreviewCommandLoggerAction ): [ string, number ] { @@ -358,7 +371,7 @@ function isBulkOperationSettled( bulkOperation: BulkOperation ) { } ); } -window.ipcListener.subscribe( 'snapshot-changed', ( event, snapshotEvent: SnapshotEvent ) => { +window.ipcListener.subscribe( 'snapshot-changed', ( _, snapshotEvent ) => { const { event: eventType, snapshot, snapshotUrl } = snapshotEvent; if ( eventType === SNAPSHOT_EVENTS.DELETED ) { @@ -368,14 +381,7 @@ window.ipcListener.subscribe( 'snapshot-changed', ( event, snapshotEvent: Snapsh store.dispatch( snapshotSlice.actions.deleteSnapshotLocally( { atomicSiteId: existing.atomicSiteId } ) ); - store.dispatch( - wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { - data.siteCount = Math.max( 0, data.siteCount - 1 ); - } ) - ); - setTimeout( () => { - store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); - }, 8000 ); + updateSnapshotUsageCount( -1 ); } return; } @@ -390,19 +396,8 @@ window.ipcListener.subscribe( 'snapshot-changed', ( event, snapshotEvent: Snapsh const state = store.getState(); const existing = state.snapshot.snapshots.find( ( s ) => s.url === snapshot.url ); if ( ! existing ) { - store.dispatch( - snapshotSlice.actions.setSnapshots( { - snapshots: [ ...state.snapshot.snapshots, snapshot ], - } ) - ); - store.dispatch( - wpcomApi.util.updateQueryData( 'getSnapshotUsage', undefined, ( data ) => { - data.siteCount = data.siteCount + 1; - } ) - ); - setTimeout( () => { - store.dispatch( wpcomApi.util.invalidateTags( [ 'SnapshotUsage' ] ) ); - }, 8000 ); + store.dispatch( snapshotSlice.actions.addSnapshot( { snapshot } ) ); + updateSnapshotUsageCount( 1 ); } return; } @@ -539,9 +534,6 @@ window.ipcListener.subscribe( 'snapshot-success', ( event, payload ) => { } ); } } - - // Re-fetch snapshots from CLI after any successful operation - void refreshSnapshots(); } ); export const snapshotActions = { From a71301626f86215a2fe06a178631972b5471ebc2 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 17 Mar 2026 13:39:02 +0000 Subject: [PATCH 11/12] Remove barrel file, replace z.nativeEnum with z.enum, update imports to direct paths --- 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 | 3 ++- apps/cli/commands/preview/create.ts | 3 ++- apps/cli/commands/preview/set.ts | 2 +- .../cli/commands/preview/tests/create.test.ts | 12 ++++++--- apps/cli/commands/preview/tests/list.test.ts | 14 ++++++++--- .../cli/commands/preview/tests/update.test.ts | 6 ++--- apps/cli/commands/preview/update.ts | 2 +- apps/cli/commands/site/create.ts | 6 +++-- apps/cli/commands/site/delete.ts | 4 +-- apps/cli/commands/site/set.ts | 5 ++-- 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 | 16 ++++++++---- apps/cli/commands/site/tests/list.test.ts | 6 ++--- apps/cli/commands/site/tests/set.test.ts | 21 ++++++++-------- 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 | 20 +++++++++------ apps/cli/commands/wp.ts | 2 +- apps/cli/lib/cli-config/index.ts | 25 ------------------- apps/cli/lib/generate-site-name.ts | 2 +- apps/cli/lib/proxy-server.ts | 2 +- apps/cli/lib/site-utils.ts | 3 ++- apps/cli/lib/snapshots.ts | 2 +- apps/cli/lib/tests/site-utils.test.ts | 6 ++--- .../tests/wordpress-server-manager.test.ts | 2 +- apps/cli/lib/wordpress-server-manager.ts | 2 +- tools/common/lib/cli-events.ts | 4 +-- 33 files changed, 127 insertions(+), 110 deletions(-) delete mode 100644 apps/cli/lib/cli-config/index.ts 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 a6f42b32df..e5e8a0df88 100644 --- a/apps/cli/ai/tools.ts +++ b/apps/cli/ai/tools.ts @@ -16,7 +16,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 { normalizeHostname } from 'cli/lib/utils'; import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; diff --git a/apps/cli/ai/ui.ts b/apps/cli/ai/ui.ts index d2af4715ea..9b2d358f8d 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 5ee33025cb..02ee934a9b 100644 --- a/apps/cli/commands/_events.ts +++ b/apps/cli/commands/_events.ts @@ -18,7 +18,8 @@ import { import { sequential } from '@studio/common/lib/sequential'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; -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, diff --git a/apps/cli/commands/preview/create.ts b/apps/cli/commands/preview/create.ts index 640fc3f03b..1ffcf284cf 100644 --- a/apps/cli/commands/preview/create.ts +++ b/apps/cli/commands/preview/create.ts @@ -7,7 +7,8 @@ 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, getNextSnapshotSequence } from 'cli/lib/cli-config'; +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'; diff --git a/apps/cli/commands/preview/set.ts b/apps/cli/commands/preview/set.ts index 31c66ebfa0..2dcb1689bb 100644 --- a/apps/cli/commands/preview/set.ts +++ b/apps/cli/commands/preview/set.ts @@ -1,7 +1,7 @@ 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'; +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'; diff --git a/apps/cli/commands/preview/tests/create.test.ts b/apps/cli/commands/preview/tests/create.test.ts index 2074ec5fd5..55725ac55c 100644 --- a/apps/cli/commands/preview/tests/create.test.ts +++ b/apps/cli/commands/preview/tests/create.test.ts @@ -5,7 +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, getNextSnapshotSequence } from 'cli/lib/cli-config'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; +import { getNextSnapshotSequence } from 'cli/lib/cli-config/snapshots'; import { getSnapshotsFromConfig, saveSnapshotToConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { runCommand } from '../create'; @@ -24,11 +25,14 @@ 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' ) ), - getSiteByFolder: vi.fn(), +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', () => ( { validateSiteSize: vi.fn(), } ) ); diff --git a/apps/cli/commands/preview/tests/list.test.ts b/apps/cli/commands/preview/tests/list.test.ts index 589e885f87..e74938e98c 100644 --- a/apps/cli/commands/preview/tests/list.test.ts +++ b/apps/cli/commands/preview/tests/list.test.ts @@ -1,6 +1,6 @@ import { vi } from 'vitest'; import { getAuthToken } from 'cli/lib/appdata'; -import { getSiteByFolder } from 'cli/lib/cli-config'; +import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { getSnapshotsFromConfig } from 'cli/lib/snapshots'; import { mockReportStart, @@ -20,14 +20,20 @@ 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(), 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(), + }; +} ); vi.mock( 'cli/lib/snapshots' ); vi.mock( 'cli/logger', () => ( { Logger: class { diff --git a/apps/cli/commands/preview/tests/update.test.ts b/apps/cli/commands/preview/tests/update.test.ts index 54588ee0ae..dd5447c1fa 100644 --- a/apps/cli/commands/preview/tests/update.test.ts +++ b/apps/cli/commands/preview/tests/update.test.ts @@ -7,7 +7,7 @@ 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 { 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'; @@ -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(), diff --git a/apps/cli/commands/preview/update.ts b/apps/cli/commands/preview/update.ts index 46d0cebb2b..ad460280af 100644 --- a/apps/cli/commands/preview/update.ts +++ b/apps/cli/commands/preview/update.ts @@ -10,7 +10,7 @@ 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 { 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'; diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index c907b6305e..870206bf00 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -52,13 +52,15 @@ import { import { lockCliConfig, readCliConfig, - removeSiteFromConfig, saveCliConfig, SiteData, unlockCliConfig, +} from 'cli/lib/cli-config/core'; +import { + removeSiteFromConfig, updateSiteAutoStart, updateSiteLatestCliPid, -} from 'cli/lib/cli-config'; +} from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { generateSiteName, getDefaultSitePath } from 'cli/lib/generate-site-name'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; diff --git a/apps/cli/commands/site/delete.ts b/apps/cli/commands/site/delete.ts index 5df5a6172a..55846535ba 100644 --- a/apps/cli/commands/site/delete.ts +++ b/apps/cli/commands/site/delete.ts @@ -7,12 +7,12 @@ 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'; +} 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'; diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index 4be83812c4..3febcf2581 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -17,13 +17,12 @@ 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'; +} 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'; 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 fba5bede38..9f11894893 100644 --- a/apps/cli/commands/site/tests/delete.test.ts +++ b/apps/cli/commands/site/tests/delete.test.ts @@ -7,12 +7,12 @@ 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'; @@ -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' ); diff --git a/apps/cli/commands/site/tests/list.test.ts b/apps/cli/commands/site/tests/list.test.ts index 7ef6121e27..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(), diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index 4e466ea13f..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 ), }; } ); 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 87cd6199d1..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' ); 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/lib/cli-config/index.ts b/apps/cli/lib/cli-config/index.ts deleted file mode 100644 index a3117e248d..0000000000 --- a/apps/cli/lib/cli-config/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type { SiteData } from './core'; -export { - getCliConfigDirectory, - getCliConfigPath, - lockCliConfig, - readCliConfig, - saveCliConfig, - unlockCliConfig, -} from './core'; -export { - clearSiteLatestCliPid, - getSiteByFolder, - getSiteUrl, - removeSiteFromConfig, - updateSiteAutoStart, - updateSiteLatestCliPid, -} from './sites'; -export { - deleteSnapshotFromConfig, - getNextSnapshotSequence, - getSnapshotsFromConfig, - saveSnapshotToConfig, - setSnapshotInConfig, - updateSnapshotInConfig, -} from './snapshots'; diff --git a/apps/cli/lib/generate-site-name.ts b/apps/cli/lib/generate-site-name.ts index b3ea80d4b3..f10ab43564 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 { readCliConfig } from 'cli/lib/cli-config'; +import { readCliConfig } from 'cli/lib/cli-config/core'; const DEFAULT_SITES_DIR = path.join( os.homedir(), 'Studio' ); 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-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 0abd093b73..86ce500d5f 100644 --- a/apps/cli/lib/snapshots.ts +++ b/apps/cli/lib/snapshots.ts @@ -8,7 +8,7 @@ export { saveSnapshotToConfig, updateSnapshotInConfig, deleteSnapshotFromConfig, -} from 'cli/lib/cli-config'; +} 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/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 174b3d8be0..879869091f 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/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/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index 9eca78e191..a11119e93f 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -61,7 +61,7 @@ export const siteEventSchema = z.object( { export type SiteEvent = z.infer< typeof siteEventSchema >; export const snapshotEventSchema = z.object( { - event: z.nativeEnum( SNAPSHOT_EVENTS ), + event: z.enum( SNAPSHOT_EVENTS ), snapshot: snapshotSchema.optional(), snapshotUrl: z.string(), } ); @@ -79,7 +79,7 @@ export const siteSocketEventSchema = z.object( { } ); export const snapshotSocketEventSchema = z.object( { - event: z.nativeEnum( SNAPSHOT_EVENTS ), + event: z.enum( SNAPSHOT_EVENTS ), data: z.object( { snapshotUrl: z.string(), } ), From 4256aa7fe6d052d62c63aa4db01e8364984f6540 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Tue, 17 Mar 2026 13:51:38 +0000 Subject: [PATCH 12/12] Fix import order in cli-events.ts and remove unused test imports --- apps/cli/commands/preview/tests/create.test.ts | 3 +-- tools/common/lib/cli-events.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/cli/commands/preview/tests/create.test.ts b/apps/cli/commands/preview/tests/create.test.ts index 55725ac55c..2da3192ea9 100644 --- a/apps/cli/commands/preview/tests/create.test.ts +++ b/apps/cli/commands/preview/tests/create.test.ts @@ -6,8 +6,7 @@ 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'; -import { getSnapshotsFromConfig, saveSnapshotToConfig } from 'cli/lib/snapshots'; +import { saveSnapshotToConfig } from 'cli/lib/snapshots'; import { LoggerError } from 'cli/logger'; import { runCommand } from '../create'; diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index a11119e93f..124a7d1fae 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -4,8 +4,8 @@ * The CLI emits these events via the `_events` command, and Studio * subscribes to them to maintain its state without reading config files. */ -import { snapshotSchema } from '@studio/common/types/snapshot'; 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.