Skip to content

Commit ea85c39

Browse files
CLI: Implement studio site set-php-version (#2154)
* Implement studio site stop CLI command with graceful shutdown * Fix types and remove sendStopMessage * stop proxy if needed * use getSiteByFolder * Fix issues from my previous commits * Clean up test file * fix lint and unit tests * CLI: Implement `studio site set-domain` and `studio site set-https` * Harden `sendMessage` cleanup logic * Cleanup * Disallow `set-https` command on sites without custom domains * Finish early if HTTPS config already matches * Simplify * Fix yargs option config * CLI: Implement `studio site set-php-version` * Address review comments * Disallow setting the same domain as before * Update tests to follow new paradigm * Adopt new test paradigm --------- Co-authored-by: bcotrim <[email protected]>
1 parent 6b374a6 commit ea85c39

File tree

3 files changed

+266
-3
lines changed

3 files changed

+266
-3
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { SupportedPHPVersions } from '@php-wasm/universal';
2+
import { __, _n } from '@wordpress/i18n';
3+
import { arePathsEqual } from 'common/lib/fs-utils';
4+
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
5+
import {
6+
getSiteByFolder,
7+
lockAppdata,
8+
readAppdata,
9+
saveAppdata,
10+
unlockAppdata,
11+
} from 'cli/lib/appdata';
12+
import { connect, disconnect } from 'cli/lib/pm2-manager';
13+
import {
14+
isServerRunning,
15+
startWordPressServer,
16+
stopWordPressServer,
17+
} from 'cli/lib/wordpress-server-manager';
18+
import { Logger, LoggerError } from 'cli/logger';
19+
import { StudioArgv } from 'cli/types';
20+
21+
const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ];
22+
23+
const logger = new Logger< LoggerAction >();
24+
25+
export async function runCommand(
26+
sitePath: string,
27+
phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ]
28+
): Promise< void > {
29+
try {
30+
logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) );
31+
const site = await getSiteByFolder( sitePath );
32+
logger.reportSuccess( __( 'Site loaded' ) );
33+
34+
if ( site.phpVersion === phpVersion ) {
35+
throw new LoggerError( __( 'Site is already using the specified PHP version.' ) );
36+
}
37+
38+
try {
39+
await lockAppdata();
40+
const appdata = await readAppdata();
41+
const site = appdata.sites.find( ( site ) => arePathsEqual( site.path, sitePath ) );
42+
if ( ! site ) {
43+
throw new LoggerError( __( 'The specified folder is not added to Studio.' ) );
44+
}
45+
site.phpVersion = phpVersion;
46+
await saveAppdata( appdata );
47+
} finally {
48+
await unlockAppdata();
49+
}
50+
51+
logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon...' ) );
52+
await connect();
53+
logger.reportSuccess( __( 'Process daemon started' ) );
54+
55+
const runningProcess = await isServerRunning( site.id );
56+
57+
if ( runningProcess ) {
58+
logger.reportStart( LoggerAction.START_SITE, __( 'Restarting site...' ) );
59+
await stopWordPressServer( site.id );
60+
await startWordPressServer( site );
61+
logger.reportSuccess( __( 'Site restarted' ) );
62+
}
63+
} finally {
64+
disconnect();
65+
}
66+
}
67+
68+
export const registerCommand = ( yargs: StudioArgv ) => {
69+
return yargs.command( {
70+
command: 'set-php-version <php-version>',
71+
describe: __( 'Set PHP version for a local site' ),
72+
builder: ( yargs ) => {
73+
return yargs.positional( 'php-version', {
74+
type: 'string',
75+
description: __( 'PHP version' ),
76+
demandOption: true,
77+
choices: ALLOWED_PHP_VERSIONS,
78+
} );
79+
},
80+
handler: async ( argv ) => {
81+
try {
82+
await runCommand( argv.path, argv.phpVersion );
83+
} catch ( error ) {
84+
if ( error instanceof LoggerError ) {
85+
logger.reportError( error );
86+
} else {
87+
const loggerError = new LoggerError( __( 'Failed to start site infrastructure' ), error );
88+
logger.reportError( loggerError );
89+
}
90+
}
91+
},
92+
} );
93+
};
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { arePathsEqual } from 'common/lib/fs-utils';
2+
import {
3+
SiteData,
4+
getSiteByFolder,
5+
lockAppdata,
6+
readAppdata,
7+
saveAppdata,
8+
unlockAppdata,
9+
} from 'cli/lib/appdata';
10+
import { connect, disconnect } from 'cli/lib/pm2-manager';
11+
import {
12+
isServerRunning,
13+
startWordPressServer,
14+
stopWordPressServer,
15+
} from 'cli/lib/wordpress-server-manager';
16+
17+
jest.mock( 'cli/lib/appdata', () => ( {
18+
...jest.requireActual( 'cli/lib/appdata' ),
19+
getSiteByFolder: jest.fn(),
20+
lockAppdata: jest.fn(),
21+
readAppdata: jest.fn(),
22+
saveAppdata: jest.fn(),
23+
unlockAppdata: jest.fn(),
24+
} ) );
25+
jest.mock( 'cli/lib/pm2-manager' );
26+
jest.mock( 'cli/lib/wordpress-server-manager' );
27+
jest.mock( 'common/lib/fs-utils' );
28+
29+
describe( 'Site Set-PHP-Version Command', () => {
30+
const testSitePath = '/test/site/path';
31+
32+
const createTestSite = (): SiteData => ( {
33+
id: 'test-site-id',
34+
name: 'Test Site',
35+
path: testSitePath,
36+
port: 8881,
37+
adminUsername: 'admin',
38+
adminPassword: 'password123',
39+
running: false,
40+
phpVersion: '8.0',
41+
url: `http://localhost:8881`,
42+
enableHttps: false,
43+
customDomain: 'test.local',
44+
} );
45+
46+
const testProcessDescription = {
47+
name: 'test-site-id',
48+
pmId: 0,
49+
status: 'online',
50+
pid: 12345,
51+
};
52+
53+
let testSite: SiteData;
54+
55+
beforeEach( () => {
56+
jest.clearAllMocks();
57+
58+
testSite = createTestSite();
59+
60+
( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite );
61+
( connect as jest.Mock ).mockResolvedValue( undefined );
62+
( disconnect as jest.Mock ).mockReturnValue( undefined );
63+
( lockAppdata as jest.Mock ).mockResolvedValue( undefined );
64+
( readAppdata as jest.Mock ).mockResolvedValue( {
65+
sites: [ testSite ],
66+
snapshots: [],
67+
} );
68+
( saveAppdata as jest.Mock ).mockResolvedValue( undefined );
69+
( unlockAppdata as jest.Mock ).mockResolvedValue( undefined );
70+
( isServerRunning as jest.Mock ).mockResolvedValue( undefined );
71+
( startWordPressServer as jest.Mock ).mockResolvedValue( testProcessDescription );
72+
( stopWordPressServer as jest.Mock ).mockResolvedValue( undefined );
73+
( arePathsEqual as jest.Mock ).mockImplementation( ( a: string, b: string ) => a === b );
74+
} );
75+
76+
afterEach( () => {
77+
jest.restoreAllMocks();
78+
} );
79+
80+
describe( 'Error Cases', () => {
81+
it( 'should throw when PHP version is identical to current version', async () => {
82+
const { runCommand } = await import( '../set-php-version' );
83+
84+
await expect( runCommand( testSitePath, '8.0' ) ).rejects.toThrow(
85+
'Site is already using the specified PHP version.'
86+
);
87+
expect( disconnect ).toHaveBeenCalled();
88+
} );
89+
90+
it( 'should throw when site not found in appdata', async () => {
91+
( readAppdata as jest.Mock ).mockResolvedValue( {
92+
sites: [],
93+
snapshots: [],
94+
} );
95+
96+
const { runCommand } = await import( '../set-php-version' );
97+
98+
await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow(
99+
'The specified folder is not added to Studio.'
100+
);
101+
expect( disconnect ).toHaveBeenCalled();
102+
} );
103+
104+
it( 'should throw when appdata save fails', async () => {
105+
( saveAppdata as jest.Mock ).mockRejectedValue( new Error( 'Save failed' ) );
106+
107+
const { runCommand } = await import( '../set-php-version' );
108+
109+
await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow();
110+
expect( disconnect ).toHaveBeenCalled();
111+
} );
112+
113+
it( 'should throw when PM2 connection fails', async () => {
114+
( connect as jest.Mock ).mockRejectedValue( new Error( 'PM2 connection failed' ) );
115+
116+
const { runCommand } = await import( '../set-php-version' );
117+
118+
await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow();
119+
expect( disconnect ).toHaveBeenCalled();
120+
} );
121+
122+
it( 'should throw when WordPress server stop fails', async () => {
123+
( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription );
124+
( stopWordPressServer as jest.Mock ).mockRejectedValue( new Error( 'Server stop failed' ) );
125+
126+
const { runCommand } = await import( '../set-php-version' );
127+
128+
await expect( runCommand( testSitePath, '7.4' ) ).rejects.toThrow();
129+
expect( disconnect ).toHaveBeenCalled();
130+
} );
131+
} );
132+
133+
describe( 'Success Cases', () => {
134+
it( 'should update PHP version on a stopped site', async () => {
135+
const { runCommand } = await import( '../set-php-version' );
136+
137+
await runCommand( testSitePath, '7.4' );
138+
139+
expect( getSiteByFolder ).toHaveBeenCalledWith( testSitePath );
140+
expect( lockAppdata ).toHaveBeenCalled();
141+
expect( readAppdata ).toHaveBeenCalled();
142+
expect( saveAppdata ).toHaveBeenCalled();
143+
144+
const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ];
145+
expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' );
146+
147+
expect( unlockAppdata ).toHaveBeenCalled();
148+
expect( isServerRunning ).toHaveBeenCalledWith( testSite.id );
149+
expect( stopWordPressServer ).not.toHaveBeenCalled();
150+
expect( startWordPressServer ).not.toHaveBeenCalled();
151+
expect( disconnect ).toHaveBeenCalled();
152+
} );
153+
154+
it( 'should update PHP version and restart a running site', async () => {
155+
( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription );
156+
157+
const { runCommand } = await import( '../set-php-version' );
158+
159+
await runCommand( testSitePath, '7.4' );
160+
161+
expect( saveAppdata ).toHaveBeenCalled();
162+
const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ];
163+
expect( savedAppdata.sites[ 0 ].phpVersion ).toBe( '7.4' );
164+
165+
expect( isServerRunning ).toHaveBeenCalledWith( testSite.id );
166+
expect( stopWordPressServer ).toHaveBeenCalledWith( testSite.id );
167+
expect( startWordPressServer ).toHaveBeenCalledWith( expect.any( Object ) );
168+
expect( disconnect ).toHaveBeenCalled();
169+
} );
170+
} );
171+
} );

cli/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/
1515
import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list';
1616
import { registerCommand as registerSiteSetDomainCommand } from 'cli/commands/site/set-domain';
1717
import { registerCommand as registerSiteSetHttpsCommand } from 'cli/commands/site/set-https';
18+
import { registerCommand as registerSiteSetPhpVersionCommand } from 'cli/commands/site/set-php-version';
1819
import { registerCommand as registerSiteStartCommand } from 'cli/commands/site/start';
1920
import { registerCommand as registerSiteStatusCommand } from 'cli/commands/site/status';
2021
import { registerCommand as registerSiteStopCommand } from 'cli/commands/site/stop';
@@ -82,16 +83,14 @@ async function main() {
8283

8384
if ( isSitesCliEnabled ) {
8485
studioArgv.command( 'site', __( 'Manage local sites (Beta)' ), ( sitesYargs ) => {
85-
sitesYargs.option( 'path', {
86-
hidden: true,
87-
} );
8886
registerSiteStatusCommand( sitesYargs );
8987
registerSiteCreateCommand( sitesYargs );
9088
registerSiteListCommand( sitesYargs );
9189
registerSiteStartCommand( sitesYargs );
9290
registerSiteStopCommand( sitesYargs );
9391
registerSiteSetHttpsCommand( sitesYargs );
9492
registerSiteSetDomainCommand( sitesYargs );
93+
registerSiteSetPhpVersionCommand( sitesYargs );
9594
sitesYargs.demandCommand( 1, __( 'You must provide a valid command' ) );
9695
} );
9796
}

0 commit comments

Comments
 (0)