diff --git a/cli/commands/site/list.ts b/cli/commands/site/list.ts index 103069610..02ad99cfa 100644 --- a/cli/commands/site/list.ts +++ b/cli/commands/site/list.ts @@ -4,18 +4,19 @@ import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; import { getSiteUrl, readAppdata, type SiteData } from 'cli/lib/appdata'; import { connect, disconnect } from 'cli/lib/pm2-manager'; import { getColumnWidths, getPrettyPath } from 'cli/lib/utils'; -import { isServerRunning } from 'cli/lib/wordpress-server-manager'; +import { isServerRunning, subscribeSiteEvents } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; interface SiteListEntry { + id: string; status: string; name: string; path: string; url: string; } -async function getSiteListData( sites: SiteData[] ) { +async function getSiteListData( sites: SiteData[] ): Promise< SiteListEntry[] > { const result: SiteListEntry[] = []; for await ( const site of sites ) { @@ -24,6 +25,7 @@ async function getSiteListData( sites: SiteData[] ) { const url = getSiteUrl( site ); result.push( { + id: site.id, status, name: site.name, path: getPrettyPath( site.path ), @@ -34,60 +36,78 @@ async function getSiteListData( sites: SiteData[] ) { return result; } +function displaySiteList( sitesData: SiteListEntry[], format: 'table' | 'json' ): void { + if ( format === 'table' ) { + const colWidths = getColumnWidths( [ 0.1, 0.2, 0.3, 0.4 ] ); + + const table = new Table( { + head: [ __( 'Status' ), __( 'Name' ), __( 'Path' ), __( 'URL' ) ], + wordWrap: true, + wrapOnWordBoundary: false, + colWidths, + style: { + head: [], + border: [], + }, + } ); + + table.push( + ...sitesData.map( ( site ) => [ + site.status, + site.name, + site.path, + { href: new URL( site.url ).toString(), content: site.url }, + ] ) + ); + + console.log( table.toString() ); + } else { + console.log( JSON.stringify( sitesData, null, 2 ) ); + } +} + const logger = new Logger< LoggerAction >(); -export async function runCommand( format: 'table' | 'json' ): Promise< void > { +export async function runCommand( format: 'table' | 'json', watch: boolean ): Promise< void > { try { logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading sites…' ) ); const appdata = await readAppdata(); if ( appdata.sites.length === 0 ) { logger.reportSuccess( __( 'No sites found' ) ); - return; + if ( ! watch ) { + return; + } + } else { + const sitesMessage = sprintf( + _n( 'Found %d site', 'Found %d sites', appdata.sites.length ), + appdata.sites.length + ); + logger.reportSuccess( sitesMessage ); } - const sitesMessage = sprintf( - _n( 'Found %d site', 'Found %d sites', appdata.sites.length ), - appdata.sites.length - ); - - logger.reportSuccess( sitesMessage ); - logger.reportStart( LoggerAction.START_DAEMON, __( 'Connecting to process daemon...' ) ); await connect(); logger.reportSuccess( __( 'Connected to process daemon' ) ); const sitesData = await getSiteListData( appdata.sites ); - - if ( format === 'table' ) { - const colWidths = getColumnWidths( [ 0.1, 0.2, 0.3, 0.4 ] ); - - const table = new Table( { - head: [ __( 'Status' ), __( 'Name' ), __( 'Path' ), __( 'URL' ) ], - wordWrap: true, - wrapOnWordBoundary: false, - colWidths, - style: { - head: [], - border: [], + displaySiteList( sitesData, format ); + + if ( watch ) { + await subscribeSiteEvents( + async () => { + console.clear(); + const freshAppdata = await readAppdata(); + const freshSitesData = await getSiteListData( freshAppdata.sites ); + displaySiteList( freshSitesData, format ); }, - } ); - - table.push( - ...sitesData.map( ( site ) => [ - site.status, - site.name, - site.path, - { href: new URL( site.url ).toString(), content: site.url }, - ] ) + { debounceMs: 500 } ); - - console.log( table.toString() ); - } else { - console.log( JSON.stringify( sitesData, null, 2 ) ); } } finally { - disconnect(); + if ( ! watch ) { + disconnect(); + } } } @@ -96,16 +116,22 @@ export const registerCommand = ( yargs: StudioArgv ) => { command: 'list', describe: __( 'List local sites' ), builder: ( yargs ) => { - return yargs.option( 'format', { - type: 'string', - choices: [ 'table', 'json' ], - default: 'table', - description: __( 'Output format' ), - } ); + return yargs + .option( 'format', { + type: 'string', + choices: [ 'table', 'json' ], + default: 'table', + description: __( 'Output format' ), + } ) + .option( 'watch', { + type: 'boolean', + default: false, + description: __( 'Watch for site status changes and update the list in real-time' ), + } ); }, handler: async ( argv ) => { try { - await runCommand( argv.format as 'table' | 'json' ); + await runCommand( argv.format as 'table' | 'json', argv.watch as boolean ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); diff --git a/cli/lib/pm2-manager.ts b/cli/lib/pm2-manager.ts index 720557732..28a30a36b 100644 --- a/cli/lib/pm2-manager.ts +++ b/cli/lib/pm2-manager.ts @@ -17,6 +17,15 @@ const STUDIO_PM2_HOME = path.join( os.homedir(), '.studio', 'pm2' ); process.env.PM2_HOME = STUDIO_PM2_HOME; +export interface ProcessEventData { + processName: string; + event: string; +} + +export interface SubscribeProcessEventsOptions { + debounceMs?: number; +} + function resolvePm2(): typeof import('pm2') { try { return require( 'pm2' ); @@ -246,3 +255,51 @@ export async function stopProcess( processName: string ): Promise< void > { } ); } ); } + +/** + * Subscribe to PM2 process events (online, exit, stop, restart) + * @param handler - Callback invoked when a process event occurs + * @param options - Configuration options + * @returns Unsubscribe function to stop listening + */ +export async function subscribeProcessEvents( + handler: ( data: ProcessEventData ) => void, + options: SubscribeProcessEventsOptions = {} +): Promise< () => void > { + const { debounceMs = 0 } = options; + const bus = await getPm2Bus(); + + let debounceTimeout: NodeJS.Timeout | null = null; + let pendingEvent: ProcessEventData | null = null; + + const eventHandler = ( data: { process: { name: string }; event: string } ) => { + const eventData: ProcessEventData = { + processName: data.process.name, + event: data.event, + }; + + if ( debounceMs > 0 ) { + pendingEvent = eventData; + if ( debounceTimeout ) { + clearTimeout( debounceTimeout ); + } + debounceTimeout = setTimeout( () => { + if ( pendingEvent ) { + handler( pendingEvent ); + pendingEvent = null; + } + }, debounceMs ); + } else { + handler( eventData ); + } + }; + + bus.on( 'process:event', eventHandler ); + + return () => { + bus.off( 'process:event', eventHandler ); + if ( debounceTimeout ) { + clearTimeout( debounceTimeout ); + } + }; +} diff --git a/cli/lib/wordpress-server-manager.ts b/cli/lib/wordpress-server-manager.ts index c04a1e45b..15a1312b6 100644 --- a/cli/lib/wordpress-server-manager.ts +++ b/cli/lib/wordpress-server-manager.ts @@ -17,6 +17,8 @@ import { stopProcess, getPm2Bus, sendMessageToProcess, + subscribeProcessEvents, + type SubscribeProcessEventsOptions, } from 'cli/lib/pm2-manager'; import { ProcessDescription } from 'cli/lib/types/pm2'; import { @@ -25,8 +27,10 @@ import { ManagerMessagePayload, } from 'cli/lib/types/wordpress-server-ipc'; +const SITE_PROCESS_PREFIX = 'studio-site-'; + function getProcessName( siteId: string ): string { - return `studio-site-${ siteId }`; + return `${ SITE_PROCESS_PREFIX }-${ siteId }`; } export async function isServerRunning( siteId: string ): Promise< ProcessDescription | undefined > { @@ -298,3 +302,23 @@ export async function runBlueprint( await stopProcess( processName ); } } + +/** + * Subscribe to site server events (online, exit, stop, restart) + * @param handler - Callback invoked when a site event occurs + * @param options - Configuration options (e.g., debounceMs) + * @returns Unsubscribe function to stop listening + */ +export async function subscribeSiteEvents( + handler: ( data: { siteId: string; event: string } ) => void, + options: SubscribeProcessEventsOptions = {} +): Promise< () => void > { + return subscribeProcessEvents( ( { processName, event } ) => { + if ( ! processName.startsWith( SITE_PROCESS_PREFIX ) ) { + return; + } + + const siteId = processName.replace( SITE_PROCESS_PREFIX, '' ); + handler( { siteId, event } ); + }, options ); +}