Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 70 additions & 44 deletions cli/commands/site/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand All @@ -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 ),
Expand All @@ -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();
}
}
}

Expand All @@ -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 );
Expand Down
57 changes: 57 additions & 0 deletions cli/lib/pm2-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down Expand Up @@ -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 );
}
};
}
26 changes: 25 additions & 1 deletion cli/lib/wordpress-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
stopProcess,
getPm2Bus,
sendMessageToProcess,
subscribeProcessEvents,
type SubscribeProcessEventsOptions,
} from 'cli/lib/pm2-manager';
import { ProcessDescription } from 'cli/lib/types/pm2';
import {
Expand All @@ -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 > {
Expand Down Expand Up @@ -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 );
}