Skip to content

Commit 47dce34

Browse files
committed
Studio CLI: Add support for xdebug beta flag (#2316)
1 parent ae79eee commit 47dce34

8 files changed

Lines changed: 197 additions & 12 deletions

File tree

cli/commands/site/set.ts

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
1212
import {
1313
getSiteByFolder,
14+
isXdebugBetaEnabled,
1415
lockAppdata,
1516
readAppdata,
1617
saveAppdata,
@@ -41,30 +42,38 @@ export interface SetCommandOptions {
4142
https?: boolean;
4243
php?: string;
4344
wp?: string;
45+
xdebug?: boolean;
4446
}
4547

4648
export async function runCommand(
4749
sitePath: string,
4850
options: SetCommandOptions
4951
): Promise< { usedWpCli: boolean } > {
50-
const { name, domain, https, php, wp } = options;
52+
const { name, domain, https, php, wp, xdebug } = options;
5153

5254
if (
5355
name === undefined &&
5456
domain === undefined &&
5557
https === undefined &&
5658
php === undefined &&
57-
wp === undefined
59+
wp === undefined &&
60+
xdebug === undefined
5861
) {
5962
throw new LoggerError(
60-
__( 'At least one option (--name, --domain, --https, --php, --wp) is required.' )
63+
__( 'At least one option (--name, --domain, --https, --php, --wp, --xdebug) is required.' )
6164
);
6265
}
6366

6467
if ( name !== undefined && ! name.trim() ) {
6568
throw new LoggerError( __( 'Site name cannot be empty.' ) );
6669
}
6770

71+
if ( xdebug !== undefined && ! ( await isXdebugBetaEnabled() ) ) {
72+
throw new LoggerError(
73+
__( 'Xdebug support is a beta feature. Enable it in Studio settings first.' )
74+
);
75+
}
76+
6877
try {
6978
logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) );
7079
let site = await getSiteByFolder( sitePath );
@@ -90,20 +99,37 @@ export async function runCommand(
9099
}
91100
}
92101

102+
if ( xdebug === true ) {
103+
const otherXdebugSite = initialAppdata.sites.find(
104+
( s ) => s.enableXdebug && s.id !== site.id
105+
);
106+
if ( otherXdebugSite ) {
107+
throw new LoggerError(
108+
sprintf(
109+
/* translators: %s: site name */
110+
__( 'Only one site can have Xdebug enabled at a time. Disable Xdebug on "%s" first.' ),
111+
otherXdebugSite.name
112+
)
113+
);
114+
}
115+
}
116+
93117
const nameChanged = name !== undefined && name !== site.name;
94118
const domainChanged = domain !== undefined && domain !== site.customDomain;
95119
const httpsChanged = https !== undefined && https !== site.enableHttps;
96120
const phpChanged = php !== undefined && php !== site.phpVersion;
97121
const wpChanged = wp !== undefined;
122+
const xdebugChanged = xdebug !== undefined && xdebug !== site.enableXdebug;
98123

99-
const hasChanges = nameChanged || domainChanged || httpsChanged || phpChanged || wpChanged;
124+
const hasChanges =
125+
nameChanged || domainChanged || httpsChanged || phpChanged || wpChanged || xdebugChanged;
100126
if ( ! hasChanges ) {
101127
throw new LoggerError(
102128
__( 'No changes to apply. The site already has the specified settings.' )
103129
);
104130
}
105131

106-
const needsRestart = domainChanged || httpsChanged || phpChanged || wpChanged;
132+
const needsRestart = domainChanged || httpsChanged || phpChanged || wpChanged || xdebugChanged;
107133
const oldDomain = site.customDomain;
108134

109135
try {
@@ -126,6 +152,9 @@ export async function runCommand(
126152
if ( phpChanged ) {
127153
foundSite.phpVersion = php!;
128154
}
155+
if ( xdebugChanged ) {
156+
foundSite.enableXdebug = xdebug;
157+
}
129158

130159
await saveAppdata( appdata );
131160
site = foundSite;
@@ -219,7 +248,8 @@ export const registerCommand = ( yargs: StudioArgv ) => {
219248
return yargs.command( {
220249
command: 'set',
221250
describe: __( 'Configure site settings' ),
222-
builder: ( yargs ) => {
251+
builder: async ( yargs ) => {
252+
const showXdebug = await isXdebugBetaEnabled();
223253
return yargs
224254
.option( 'name', {
225255
type: 'string',
@@ -260,6 +290,11 @@ export const registerCommand = ( yargs: StudioArgv ) => {
260290
}
261291
return value;
262292
},
293+
} )
294+
.option( 'xdebug', {
295+
type: 'boolean',
296+
description: __( 'Enable Xdebug (beta feature)' ),
297+
hidden: ! showXdebug,
263298
} );
264299
},
265300
handler: async ( argv ) => {
@@ -270,6 +305,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
270305
https: argv.https,
271306
php: argv.php,
272307
wp: argv.wp,
308+
xdebug: argv.xdebug,
273309
} );
274310
// WP-CLI leaves handles open, so we need to explicitly exit
275311
// See: cli/lib/run-wp-cli-command.ts FIXME comment

cli/commands/site/status.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { __, _n } from '@wordpress/i18n';
22
import CliTable3 from 'cli-table3';
33
import { getWordPressVersion } from 'common/lib/get-wordpress-version';
44
import { SiteCommandLoggerAction as LoggerAction } from 'common/logger-actions';
5-
import { getSiteByFolder, getSiteUrl } from 'cli/lib/appdata';
5+
import { getSiteByFolder, getSiteUrl, isXdebugBetaEnabled } from 'cli/lib/appdata';
66
import { connect, disconnect } from 'cli/lib/pm2-manager';
77
import { getPrettyPath } from 'cli/lib/utils';
88
import { isServerRunning } from 'cli/lib/wordpress-server-manager';
@@ -28,6 +28,9 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' )
2828
autoLoginUrl.pathname = `/studio-auto-login`;
2929
autoLoginUrl.searchParams.set( 'redirect_to', `/wp-admin/` );
3030

31+
const xdebugStatus = site.enableXdebug ? __( 'Enabled' ) : __( 'Disabled' );
32+
const showXdebug = await isXdebugBetaEnabled();
33+
3134
const siteData: {
3235
key: string;
3336
value: string | undefined;
@@ -45,6 +48,7 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' )
4548
{ key: __( 'Status' ), value: status },
4649
{ key: __( 'PHP version' ), value: site.phpVersion },
4750
{ key: __( 'WP version' ), value: wpVersion },
51+
{ key: __( 'Xdebug' ), value: xdebugStatus, hidden: ! showXdebug },
4852
{ key: __( 'Admin username' ), value: 'admin' },
4953
{ key: __( 'Admin password' ), value: site.adminPassword },
5054
].filter( ( { value, hidden } ) => value && ! hidden );

cli/commands/site/tests/set.test.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getDomainNameValidationError } from 'common/lib/domains';
22
import { arePathsEqual } from 'common/lib/fs-utils';
33
import {
44
getSiteByFolder,
5+
isXdebugBetaEnabled,
56
unlockAppdata,
67
readAppdata,
78
saveAppdata,
@@ -26,6 +27,7 @@ jest.mock( 'common/lib/fs-utils', () => ( {
2627
jest.mock( 'cli/lib/appdata', () => ( {
2728
...jest.requireActual( 'cli/lib/appdata' ),
2829
getSiteByFolder: jest.fn(),
30+
isXdebugBetaEnabled: jest.fn(),
2931
lockAppdata: jest.fn().mockResolvedValue( undefined ),
3032
unlockAppdata: jest.fn().mockResolvedValue( undefined ),
3133
readAppdata: jest.fn(),
@@ -70,6 +72,7 @@ describe( 'CLI: studio site set', () => {
7072

7173
( arePathsEqual as jest.Mock ).mockReturnValue( true );
7274
( getSiteByFolder as jest.Mock ).mockResolvedValue( getTestSite() );
75+
( isXdebugBetaEnabled as jest.Mock ).mockResolvedValue( true );
7376
( readAppdata as jest.Mock ).mockResolvedValue( testAppdata );
7477
( connect as jest.Mock ).mockResolvedValue( undefined );
7578
( disconnect as jest.Mock ).mockResolvedValue( undefined );
@@ -88,7 +91,7 @@ describe( 'CLI: studio site set', () => {
8891
describe( 'Validation', () => {
8992
it( 'should throw when no options provided', async () => {
9093
await expect( runCommand( testSitePath, {} ) ).rejects.toThrow(
91-
'At least one option (--name, --domain, --https, --php, --wp) is required.'
94+
'At least one option (--name, --domain, --https, --php, --wp, --xdebug) is required.'
9295
);
9396
} );
9497

@@ -329,6 +332,94 @@ describe( 'CLI: studio site set', () => {
329332
} );
330333
} );
331334

335+
describe( 'Xdebug changes', () => {
336+
it( 'should throw when beta feature is not enabled', async () => {
337+
( isXdebugBetaEnabled as jest.Mock ).mockResolvedValue( false );
338+
339+
await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow(
340+
'Xdebug support is a beta feature. Enable it in Studio settings first.'
341+
);
342+
} );
343+
344+
it( 'should throw when another site already has xdebug enabled', async () => {
345+
const testSite = getTestSite();
346+
const otherSite = {
347+
...getTestSite(),
348+
id: 'site-2',
349+
name: 'Other Site',
350+
path: '/other/site',
351+
enableXdebug: true,
352+
};
353+
( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite );
354+
( readAppdata as jest.Mock ).mockResolvedValue( {
355+
sites: [ testSite, otherSite ],
356+
snapshots: [],
357+
} );
358+
359+
await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow(
360+
'Only one site can have Xdebug enabled at a time. Disable Xdebug on "Other Site" first.'
361+
);
362+
} );
363+
364+
it( 'should update xdebug setting without restart when site is stopped', async () => {
365+
await runCommand( testSitePath, { xdebug: true } );
366+
367+
const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ];
368+
expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( true );
369+
expect( stopWordPressServer ).not.toHaveBeenCalled();
370+
expect( startWordPressServer ).not.toHaveBeenCalled();
371+
} );
372+
373+
it( 'should restart running site when xdebug changes', async () => {
374+
( isServerRunning as jest.Mock ).mockResolvedValue( testProcessDescription );
375+
376+
await runCommand( testSitePath, { xdebug: true } );
377+
378+
expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' );
379+
expect( startWordPressServer ).toHaveBeenCalled();
380+
} );
381+
382+
it( 'should disable xdebug', async () => {
383+
const siteWithXdebug = { ...getTestSite(), enableXdebug: true };
384+
( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithXdebug );
385+
( readAppdata as jest.Mock ).mockResolvedValue( {
386+
sites: [ siteWithXdebug ],
387+
snapshots: [],
388+
} );
389+
390+
await runCommand( testSitePath, { xdebug: false } );
391+
392+
const savedAppdata = ( saveAppdata as jest.Mock ).mock.calls[ 0 ][ 0 ];
393+
expect( savedAppdata.sites[ 0 ].enableXdebug ).toBe( false );
394+
} );
395+
396+
it( 'should throw when xdebug is already enabled', async () => {
397+
const siteWithXdebug = { ...getTestSite(), enableXdebug: true };
398+
( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithXdebug );
399+
( readAppdata as jest.Mock ).mockResolvedValue( {
400+
sites: [ siteWithXdebug ],
401+
snapshots: [],
402+
} );
403+
404+
await expect( runCommand( testSitePath, { xdebug: true } ) ).rejects.toThrow(
405+
'No changes to apply. The site already has the specified settings.'
406+
);
407+
} );
408+
409+
it( 'should throw when xdebug is already disabled', async () => {
410+
const siteWithXdebugDisabled = { ...getTestSite(), enableXdebug: false };
411+
( getSiteByFolder as jest.Mock ).mockResolvedValue( siteWithXdebugDisabled );
412+
( readAppdata as jest.Mock ).mockResolvedValue( {
413+
sites: [ siteWithXdebugDisabled ],
414+
snapshots: [],
415+
} );
416+
417+
await expect( runCommand( testSitePath, { xdebug: false } ) ).rejects.toThrow(
418+
'No changes to apply. The site already has the specified settings.'
419+
);
420+
} );
421+
} );
422+
332423
describe( 'Error handling', () => {
333424
it( 'should throw when site not found', async () => {
334425
( getSiteByFolder as jest.Mock ).mockRejectedValue( new Error( 'Site not found' ) );

cli/commands/site/tests/status.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getWordPressVersion } from 'common/lib/get-wordpress-version';
2-
import { getSiteByFolder, getSiteUrl } from 'cli/lib/appdata';
2+
import { getSiteByFolder, getSiteUrl, isXdebugBetaEnabled } from 'cli/lib/appdata';
33
import { connect, disconnect } from 'cli/lib/pm2-manager';
44
import { isServerRunning } from 'cli/lib/wordpress-server-manager';
55
import { runCommand } from '../status';
@@ -8,6 +8,7 @@ jest.mock( 'cli/lib/appdata', () => ( {
88
...jest.requireActual( 'cli/lib/appdata' ),
99
getSiteByFolder: jest.fn(),
1010
getSiteUrl: jest.fn(),
11+
isXdebugBetaEnabled: jest.fn(),
1112
getAppdataDirectory: jest.fn().mockReturnValue( '/test/appdata' ),
1213
} ) );
1314
jest.mock( 'cli/lib/pm2-manager' );
@@ -36,6 +37,7 @@ describe( 'CLI: studio site status', () => {
3637

3738
( getSiteByFolder as jest.Mock ).mockResolvedValue( testSite );
3839
( getSiteUrl as jest.Mock ).mockReturnValue( 'http://localhost:8080' );
40+
( isXdebugBetaEnabled as jest.Mock ).mockResolvedValue( true );
3941
( connect as jest.Mock ).mockResolvedValue( undefined );
4042
( disconnect as jest.Mock ).mockResolvedValue( undefined );
4143
( isServerRunning as jest.Mock ).mockResolvedValue( false );
@@ -78,6 +80,7 @@ describe( 'CLI: studio site status', () => {
7880
Status: '🔴 Offline',
7981
'PHP version': '8.0',
8082
'WP version': '6.4',
83+
Xdebug: 'Disabled',
8184
'Admin username': 'admin',
8285
'Admin password': 'password123',
8386
},
@@ -105,6 +108,7 @@ describe( 'CLI: studio site status', () => {
105108
Status: '🟢 Online',
106109
'PHP version': '8.0',
107110
'WP version': '6.4',
111+
Xdebug: 'Disabled',
108112
'Admin username': 'admin',
109113
'Admin password': 'password123',
110114
},
@@ -143,10 +147,35 @@ describe( 'CLI: studio site status', () => {
143147
'Site URL': 'http://localhost:8080/',
144148
'Site Path': '/path/to/site',
145149
Status: '🔴 Offline',
146-
'PHP version': undefined,
147150
'WP version': '6.4',
151+
Xdebug: 'Disabled',
148152
'Admin username': 'admin',
149-
'Admin password': undefined,
153+
},
154+
null,
155+
2
156+
)
157+
);
158+
159+
consoleSpy.mockRestore();
160+
} );
161+
162+
it( 'should hide xdebug when beta feature is disabled', async () => {
163+
( isXdebugBetaEnabled as jest.Mock ).mockResolvedValue( false );
164+
165+
const consoleSpy = jest.spyOn( console, 'log' ).mockImplementation();
166+
167+
await runCommand( '/path/to/site', 'json' );
168+
169+
expect( consoleSpy ).toHaveBeenCalledWith(
170+
JSON.stringify(
171+
{
172+
'Site URL': 'http://localhost:8080/',
173+
'Site Path': '/path/to/site',
174+
Status: '🔴 Offline',
175+
'PHP version': '8.0',
176+
'WP version': '6.4',
177+
'Admin username': 'admin',
178+
'Admin password': 'password123',
150179
},
151180
null,
152181
2

cli/lib/appdata.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ const siteSchema = z
2828
autoStart: z.boolean().optional(),
2929
url: z.string().optional(),
3030
latestCliPid: z.number().optional(),
31+
enableXdebug: z.boolean().optional(),
3132
} )
3233
.passthrough();
3334

3435
const betaFeaturesSchema = z
3536
.object( {
3637
multiWorkerSupport: z.boolean().optional(),
38+
xdebugSupport: z.boolean().optional(),
3739
} )
3840
.passthrough();
3941

@@ -148,6 +150,15 @@ export async function unlockAppdata(): Promise< void > {
148150
await unlockFileAsync( LOCKFILE_PATH );
149151
}
150152

153+
export async function isXdebugBetaEnabled(): Promise< boolean > {
154+
try {
155+
const appdata = await readAppdata();
156+
return appdata.betaFeatures?.xdebugSupport ?? false;
157+
} catch {
158+
return false;
159+
}
160+
}
161+
151162
export async function getAuthToken(): Promise< ValidatedAuthToken > {
152163
try {
153164
const { authToken } = await readAppdata();

0 commit comments

Comments
 (0)