Skip to content
Merged
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
1 change: 0 additions & 1 deletion cli/commands/site/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import os from 'node:os';
import { __, _n, sprintf } from '@wordpress/i18n';
import Table from 'cli-table3';
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
Expand Down
56 changes: 56 additions & 0 deletions cli/commands/site/stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { __ } from '@wordpress/i18n';
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
import { clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdata';
import { connect, disconnect } from 'cli/lib/pm2-manager';
import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils';
import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager';
import { Logger, LoggerError } from 'cli/logger';
import { StudioArgv } from 'cli/types';

export async function runCommand( siteFolder: string ): Promise< void > {
const logger = new Logger< LoggerAction >();

try {
const site = await getSiteByFolder( siteFolder, false );

await connect();

const runningProcess = await isServerRunning( site.id );
if ( ! runningProcess ) {
logger.reportSuccess( __( 'WordPress site is not running' ) );
return;
}

logger.reportStart( LoggerAction.STOP_SITE, __( 'Stopping WordPress site...' ) );
try {
await stopWordPressServer( site.id );
await clearSiteLatestCliPid( site.id );
logger.reportSuccess( __( 'WordPress site stopped' ) );
await stopProxyIfNoSitesNeedIt( site.id, logger );
} catch ( error ) {
throw new LoggerError( __( 'Failed to stop WordPress server' ), error );
}
} catch ( error ) {
if ( error instanceof LoggerError ) {
logger.reportError( error );
} else {
const loggerError = new LoggerError( __( 'Failed to stop site infrastructure' ), error );
logger.reportError( loggerError );
}
} finally {
disconnect();
}
}

export const registerCommand = ( yargs: StudioArgv ) => {
return yargs.command( {
command: 'stop',
describe: __( 'Stop local site' ),
builder: ( yargs ) => {
return yargs;
},
Comment on lines +49 to +51
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
builder: ( yargs ) => {
return yargs;
},

handler: async ( argv ) => {
await runCommand( argv.path );
},
} );
};
191 changes: 191 additions & 0 deletions cli/commands/site/tests/stop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { SiteData, clearSiteLatestCliPid, getSiteByFolder } from 'cli/lib/appdata';
import { connect, disconnect } from 'cli/lib/pm2-manager';
import { stopProxyIfNoSitesNeedIt } from 'cli/lib/site-utils';
import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager';
import { Logger, LoggerError } from 'cli/logger';

jest.mock( 'cli/lib/appdata', () => ( {
...jest.requireActual( 'cli/lib/appdata' ),
getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ),
getSiteByFolder: jest.fn(),
clearSiteLatestCliPid: jest.fn(),
getSiteUrl: jest.fn( ( site ) => `http://localhost:${ site.port }` ),
} ) );
jest.mock( 'cli/lib/pm2-manager' );
jest.mock( 'cli/lib/site-utils' );
jest.mock( 'cli/lib/wordpress-server-manager' );
jest.mock( 'cli/logger' );

describe( 'Site Stop Command', () => {
const mockSiteFolder = '/test/site/path';
const mockSiteData: SiteData = {
id: 'test-site-id',
name: 'Test Site',
path: mockSiteFolder,
port: 8881,
adminUsername: 'admin',
adminPassword: 'password123',
running: true,
phpVersion: '8.0',
url: `http://localhost:8881`,
};

const mockProcessDescription = {
name: 'test-site-id',
pmId: 0,
status: 'online',
pid: 12345,
};

let mockLogger: {
reportStart: jest.Mock;
reportSuccess: jest.Mock;
reportError: jest.Mock;
};

beforeEach( () => {
jest.clearAllMocks();

mockLogger = {
reportStart: jest.fn(),
reportSuccess: jest.fn(),
reportError: jest.fn(),
};

( Logger as jest.Mock ).mockReturnValue( mockLogger );
( connect as jest.Mock ).mockResolvedValue( undefined );
( disconnect as jest.Mock ).mockReturnValue( undefined );
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );
( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined );
( clearSiteLatestCliPid as jest.Mock ).mockResolvedValue( undefined );
( stopProxyIfNoSitesNeedIt as jest.Mock ).mockResolvedValue( undefined );
} );

afterEach( () => {
jest.restoreAllMocks();
} );

describe( 'Error Handling', () => {
it( 'should handle site not found error', async () => {
( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
expect( disconnect ).toHaveBeenCalled();
} );

it( 'should handle PM2 connection failure', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( mockLogger.reportError ).toHaveBeenCalled();
expect( disconnect ).toHaveBeenCalled();
} );

it( 'should handle WordPress server stop failure', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );
( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( mockLogger.reportStart ).toHaveBeenCalledWith(
'stopSite',
'Stopping WordPress site...'
);
expect( mockLogger.reportError ).toHaveBeenCalledWith( expect.any( LoggerError ) );
expect( disconnect ).toHaveBeenCalled();
} );
} );

describe( 'Success Cases', () => {
it( 'should report that site is not running if server is not running', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site is not running' );
expect( stopWordPressServer ).not.toHaveBeenCalled();
expect( clearSiteLatestCliPid ).not.toHaveBeenCalled();
expect( disconnect ).toHaveBeenCalled();
} );

it( 'should stop a running site', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( connect ).toHaveBeenCalled();
expect( isServerRunning ).toHaveBeenCalledWith( mockSiteData.id );
expect( mockLogger.reportStart ).toHaveBeenCalledWith(
'stopSite',
'Stopping WordPress site...'
);
expect( stopWordPressServer ).toHaveBeenCalledWith( mockSiteData.id );
expect( clearSiteLatestCliPid ).toHaveBeenCalledWith( mockSiteData.id );
expect( mockLogger.reportSuccess ).toHaveBeenCalledWith( 'WordPress site stopped' );
expect( disconnect ).toHaveBeenCalled();
} );

it( 'should call stopProxyIfNoSitesNeedIt after stopping a site', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( stopProxyIfNoSitesNeedIt ).toHaveBeenCalledWith( mockSiteData.id, mockLogger );
} );

it( 'should not call stopProxyIfNoSitesNeedIt if site is not running', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( stopProxyIfNoSitesNeedIt ).not.toHaveBeenCalled();
} );
} );

describe( 'Cleanup', () => {
it( 'should disconnect from PM2 even on error', async () => {
( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site error' ) );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( disconnect ).toHaveBeenCalled();
} );

it( 'should disconnect from PM2 on success', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( mockProcessDescription );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( disconnect ).toHaveBeenCalled();
} );

it( 'should disconnect from PM2 even if server was not running', async () => {
( getSiteByFolder as jest.Mock ).mockResolvedValue( mockSiteData );
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );

const { runCommand } = await import( '../stop' );
await runCommand( mockSiteFolder );

expect( disconnect ).toHaveBeenCalled();
} );
} );
} );
2 changes: 2 additions & 0 deletions cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/
import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list';
import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start';
import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status';
import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop';
import { readAppdata } from 'cli/lib/appdata';
import { loadTranslations } from 'cli/lib/i18n';
import { bumpAggregatedUniqueStat } from 'cli/lib/stats';
Expand Down Expand Up @@ -86,6 +87,7 @@ async function main() {
registerSiteCreateCommand( sitesYargs );
registerSiteListCommand( sitesYargs );
registerSiteStartCommand( sitesYargs );
registerSiteStopCommand( sitesYargs );
sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) );
} );
}
Expand Down
17 changes: 17 additions & 0 deletions cli/lib/appdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,20 @@ export async function updateSiteLatestCliPid( siteId: string, pid: number ): Pro
await unlockAppdata();
}
}

export async function clearSiteLatestCliPid( siteId: string ): Promise< void > {
try {
await lockAppdata();
const userData = await readAppdata();
const site = userData.sites.find( ( s ) => s.id === siteId );

if ( ! site ) {
throw new LoggerError( __( 'Site not found' ) );
}

delete site.latestCliPid;
await saveAppdata( userData );
} finally {
await unlockAppdata();
}
}
4 changes: 4 additions & 0 deletions cli/lib/pm2-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export async function isProxyProcessRunning(): Promise< ProcessDescription | und
return isProcessRunning( PROXY_PROCESS_NAME );
}

export async function stopProxyProcess(): Promise< void > {
return stopProcess( PROXY_PROCESS_NAME );
}

export async function isProcessRunning(
processName: string
): Promise< ProcessDescription | undefined > {
Expand Down
33 changes: 31 additions & 2 deletions cli/lib/site-utils.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { __ } from '@wordpress/i18n';
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
import { getSiteUrl, SiteData } from 'cli/lib/appdata';
import { getSiteUrl, readAppdata, SiteData } from 'cli/lib/appdata';
import { openBrowser } from 'cli/lib/browser';
import { generateSiteCertificate } from 'cli/lib/certificate-manager';
import { addDomainToHosts } from 'cli/lib/hosts-file';
import { isProxyProcessRunning, startProxyProcess } from 'cli/lib/pm2-manager';
import { isProxyProcessRunning, startProxyProcess, stopProxyProcess } from 'cli/lib/pm2-manager';
import { isServerRunning } from 'cli/lib/wordpress-server-manager';
import { Logger, LoggerError } from 'cli/logger';

/**
Expand Down Expand Up @@ -74,3 +75,31 @@ export async function setupCustomDomain(
throw new LoggerError( __( 'Failed to add domain to hosts file' ), error );
}
}

/**
* Stops the HTTP proxy server if no remaining running sites need it.
* A site needs the proxy if it has a custom domain configured.
*
* @param stoppedSiteId - The ID of the site that was just stopped (to exclude from the check)
*/
export async function stopProxyIfNoSitesNeedIt(
stoppedSiteId: string,
logger: Logger< LoggerAction >
): Promise< void > {
const proxyProcess = await isProxyProcessRunning();
if ( ! proxyProcess ) {
return;
}

const appdata = await readAppdata();

for ( const site of appdata.sites ) {
if ( site.id !== stoppedSiteId && site.customDomain && ( await isServerRunning( site.id ) ) ) {
return;
}
}

logger.reportStart( LoggerAction.STOP_PROXY, __( 'Stopping HTTP proxy server...' ) );
await stopProxyProcess();
logger.reportSuccess( __( 'HTTP proxy server stopped' ) );
}
Loading