diff --git a/cli/commands/site/set-php-version.ts b/cli/commands/site/set-php-version.ts new file mode 100644 index 000000000..a266a3aa7 --- /dev/null +++ b/cli/commands/site/set-php-version.ts @@ -0,0 +1,93 @@ +import { SupportedPHPVersions } from '@php-wasm/universal'; +import { __, _n } from '@wordpress/i18n'; +import { arePathsEqual } from 'common/lib/fs-utils'; +import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions'; +import { + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { + isServerRunning, + startWordPressServer, + stopWordPressServer, +} from 'cli/lib/wordpress-server-manager'; +import { Logger, LoggerError } from 'cli/logger'; +import { StudioArgv } from 'cli/types'; + +const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; + +const logger = new Logger< LoggerAction >(); + +export async function runCommand( + sitePath: string, + phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ] +): Promise< void > { + try { + logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) ); + const site = await getSiteByFolder( sitePath ); + logger.reportSuccess( __( 'Site loaded' ) ); + + if ( site.phpVersion === phpVersion ) { + throw new LoggerError( __( 'Site is already using the specified PHP version.' ) ); + } + + try { + await lockAppdata(); + const appdata = await readAppdata(); + const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) ); + if ( ! site ) { + throw new LoggerError( __( 'The specified folder is not added to Studio.' ) ); + } + site.phpVersion = phpVersion; + await saveAppdata( appdata ); + } finally { + await unlockAppdata(); + } + + logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) ); + await connect(); + logger.reportSuccess( __( 'Process daemon started' ) ); + + const runningProcess = await isServerRunning( site.id ); + + if ( runningProcess ) { + logger.reportStart( LoggerAction.START_SITE, __( 'Restarting site...' ) ); + await stopWordPressServer( site.id ); + await startWordPressServer( site ); + logger.reportSuccess( __( 'Site restarted' ) ); + } + } finally { + disconnect(); + } +} + +export const registerCommand = ( yargs: StudioArgv ) => { + return yargs.command( { + command: 'set-php-version ', + describe: __( 'Set PHP version for a local site' ), + builder: ( yargs ) => { + return yargs.positional( 'php-version', { + type: 'string', + description: __( 'PHP version' ), + demandOption: true, + choices: ALLOWED_PHP_VERSIONS, + } ); + }, + handler: async ( argv ) => { + try { + await runCommand( argv.path, argv.phpVersion ); + } catch ( error ) { + if ( error instanceof LoggerError ) { + logger.reportError( error ); + } else { + const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error ); + logger.reportError( loggerError ); + } + } + }, + } ); +}; diff --git a/cli/commands/site/tests/set-php-version.test.ts b/cli/commands/site/tests/set-php-version.test.ts new file mode 100644 index 000000000..2f30ba2a1 --- /dev/null +++ b/cli/commands/site/tests/set-php-version.test.ts @@ -0,0 +1,171 @@ +import { arePathsEqual } from 'common/lib/fs-utils'; +import { + SiteData, + getSiteByFolder, + lockAppdata, + readAppdata, + saveAppdata, + unlockAppdata, +} from 'cli/lib/appdata'; +import { connect, disconnect } from 'cli/lib/pm2-manager'; +import { + isServerRunning, + startWordPressServer, + stopWordPressServer, +} from 'cli/lib/wordpress-server-manager'; + +jest.mock( 'cli/lib/appdata', () => ( { + ...jest.requireActual( 'cli/lib/appdata' ), + getSiteByFolder: jest.fn(), + lockAppdata: jest.fn(), + readAppdata: jest.fn(), + saveAppdata: jest.fn(), + unlockAppdata: jest.fn(), +} ) ); +jest.mock( 'cli/lib/pm2-manager' ); +jest.mock( 'cli/lib/wordpress-server-manager' ); +jest.mock( 'common/lib/fs-utils' ); + +describe( 'Site Set-PHP-Version Command', () => { + const testSitePath = '/test/site/path'; + + const createTestSite = (): SiteData => ( { + id: 'test-site-id', + name: 'Test Site', + path: testSitePath, + port: 8881, + adminUsername: 'admin', + adminPassword: 'password123', + running: false, + phpVersion: '8.0', + url: `http://localhost:8881`, + enableHttps: false, + customDomain: 'test.local', + } ); + + const testProcessDescription = { + name: 'test-site-id', + pmId: 0, + status: 'online', + pid: 12345, + }; + + let testSite: SiteData; + + beforeEach( () => { + jest.clearAllMocks(); + + testSite = createTestSite(); + + ( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite ); + ( connect as jest.Mock ).mockResolvedValue( undefined ); + ( disconnect as jest.Mock ).mockReturnValue( undefined ); + ( lockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [ testSite ], + snapshots: [], + } ); + ( saveAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( unlockAppdata as jest.Mock ).mockResolvedValue( undefined ); + ( isServerRunning as jest.Mock ).mockResolvedValue( undefined ); + ( startWordPressServer as jest.Mock ).mockResolvedValue( testProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined ); + ( arePathsEqual as jest.Mock ).mockImplementation( ( a: string, b: string ) => a === b ); + } ); + + afterEach( () => { + jest.restoreAllMocks(); + } ); + + describe( 'Error Cases', () => { + it( 'should throw when PHP version is identical to current version', async () => { + const { runCommand } = await import( '../set-php-version' ); + + await expect( runCommand( testSitePath, '8.0' ) ).rejects.toThrow( + 'Site is already using the specified PHP version.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when site not found in appdata', async () => { + ( readAppdata as jest.Mock ).mockResolvedValue( { + sites: [], + snapshots: [], + } ); + + const { runCommand } = await import( '../set-php-version' ); + + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow( + 'The specified folder is not added to Studio.' + ); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when appdata save fails', async () => { + ( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when PM2 connection fails', async () => { + ( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should throw when WordPress server stop fails', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); + ( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) ); + + const { runCommand } = await import( '../set-php-version' ); + + await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); + + describe( 'Success Cases', () => { + it( 'should update PHP version on a stopped site', async () => { + const { runCommand } = await import( '../set-php-version' ); + + await runCommand( testSitePath, '7.4' ); + + expect( getSiteByFolder ).toHaveBeenCalledWith( testSitePath ); + expect( lockAppdata ).toHaveBeenCalled(); + expect( readAppdata ).toHaveBeenCalled(); + expect( saveAppdata ).toHaveBeenCalled(); + + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' ); + + expect( unlockAppdata ).toHaveBeenCalled(); + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( stopWordPressServer ).not.toHaveBeenCalled(); + expect( startWordPressServer ).not.toHaveBeenCalled(); + expect( disconnect ).toHaveBeenCalled(); + } ); + + it( 'should update PHP version and restart a running site', async () => { + ( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription ); + + const { runCommand } = await import( '../set-php-version' ); + + await runCommand( testSitePath, '7.4' ); + + expect( saveAppdata ).toHaveBeenCalled(); + const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ]; + expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' ); + + expect( isServerRunning ).toHaveBeenCalledWith( testSite.id ); + expect( stopWordPressServer ).toHaveBeenCalledWith( testSite.id ); + expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) ); + expect( disconnect ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/cli/index.ts b/cli/index.ts index b31471510..4d8a57d6a 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -15,6 +15,7 @@ import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/ import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list'; import { registerCommand as registerSiteSetDomainCommand } from 'cli/commands/site/set-domain'; import { registerCommand as registerSiteSetHttpsCommand } from 'cli/commands/site/set-https'; +import { registerCommand as registerSiteSetPhpVersionCommand } from 'cli/commands/site/set-php-version'; 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'; @@ -82,9 +83,6 @@ async function main() { if ( isSitesCliEnabled ) { studioArgv.command( 'site', __( 'Manage local sites (Beta)' ), ( sitesYargs ) => { - sitesYargs.option( 'path', { - hidden: true, - } ); registerSiteStatusCommand( sitesYargs ); registerSiteCreateCommand( sitesYargs ); registerSiteListCommand( sitesYargs ); @@ -92,6 +90,7 @@ async function main() { registerSiteStopCommand( sitesYargs ); registerSiteSetHttpsCommand( sitesYargs ); registerSiteSetDomainCommand( sitesYargs ); + registerSiteSetPhpVersionCommand( sitesYargs ); sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) ); } ); }