Skip to content

Commit e4e05b9

Browse files
committed
Add PM2-managed proxy server for custom domain support
1 parent 7bedd03 commit e4e05b9

File tree

9 files changed

+896
-19
lines changed

9 files changed

+896
-19
lines changed

cli/commands/proxy/boot.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import path from 'path';
2+
import { __ } from '@wordpress/i18n';
3+
import { PreviewCommandLoggerAction as LoggerAction } from 'common/logger-actions';
4+
import {
5+
startProxyProcess,
6+
isProxyProcessRunning,
7+
isDaemonRunning,
8+
startDaemon,
9+
} from 'cli/lib/pm2-manager';
10+
import { startProxyServers } from 'cli/lib/proxy-server';
11+
import { isRunningAsRoot, getElevatedPrivilegesMessage } from 'cli/lib/sudo-exec';
12+
import { Logger, LoggerError } from 'cli/logger';
13+
import { StudioArgv } from 'cli/types';
14+
15+
/**
16+
* Boot Command - Internal Use Only
17+
*
18+
* Ensures PM2 daemon and HTTP/HTTPS proxy are running.
19+
* This is idempotent - safe to call multiple times.
20+
*
21+
* Called by Studio automatically when custom domains are needed.
22+
*/
23+
24+
export async function runCommand( managed: boolean ): Promise< void > {
25+
const logger = new Logger< LoggerAction >();
26+
27+
try {
28+
// If --managed flag is set, we're being run by PM2
29+
// Just start the proxy servers and keep the process alive
30+
if ( managed ) {
31+
logger.reportStart( LoggerAction.LOAD, __( 'Booting proxy servers...' ) );
32+
await startProxyServers();
33+
logger.reportSuccess( __( 'Proxy servers running' ) );
34+
// Process stays alive via process.stdin.resume() in startProxyServers()
35+
return;
36+
}
37+
38+
// Verify this is being called by Studio (internal use only)
39+
if ( ! process.env.STUDIO_INTERNAL ) {
40+
console.warn( '⚠️ This is an internal Studio command.' );
41+
console.warn( '⚠️ It should only be called by the Studio application.' );
42+
console.warn( '' );
43+
}
44+
45+
// Step 1: Ensure PM2 daemon is running
46+
if ( ! isDaemonRunning() ) {
47+
logger.reportStart( LoggerAction.LOAD, __( 'Starting PM2 daemon...' ) );
48+
await startDaemon();
49+
logger.reportSuccess( __( 'PM2 daemon started' ) );
50+
}
51+
52+
// Step 2: Check if proxy is already running
53+
const isRunning = await isProxyProcessRunning();
54+
if ( isRunning ) {
55+
logger.reportSuccess( __( 'Proxy already running' ) );
56+
return;
57+
}
58+
59+
// Step 3: Check for elevated privileges
60+
if ( ! isRunningAsRoot() ) {
61+
throw new Error( getElevatedPrivilegesMessage() );
62+
}
63+
64+
// Step 4: Start proxy via PM2
65+
logger.reportStart( LoggerAction.LOAD, __( 'Starting proxy server...' ) );
66+
67+
// Get the CLI path (current executable)
68+
// __dirname is dist/cli when running the bundled CLI
69+
const cliPath = path.resolve( __dirname, 'main.js' );
70+
71+
await startProxyProcess( cliPath );
72+
73+
logger.reportSuccess( __( 'Proxy server started' ) );
74+
} catch ( error ) {
75+
if ( error instanceof LoggerError ) {
76+
logger.reportError( error );
77+
} else {
78+
const loggerError = new LoggerError( __( 'Failed to boot proxy infrastructure' ), error );
79+
logger.reportError( loggerError );
80+
}
81+
process.exit( 1 );
82+
}
83+
}
84+
85+
export const registerCommand = ( yargs: StudioArgv ) => {
86+
return yargs.command( {
87+
command: 'boot',
88+
hidden: true, // Don't show in help - internal command
89+
describe: __( 'Internal: Boot PM2 and proxy (Studio use only)' ),
90+
builder: ( yargs ) => {
91+
return yargs.option( 'managed', {
92+
type: 'boolean',
93+
default: false,
94+
hidden: true, // Internal flag used by PM2
95+
description: __( 'Run in managed mode (started by PM2)' ),
96+
} );
97+
},
98+
handler: async ( argv ) => {
99+
await runCommand( argv.managed as boolean );
100+
},
101+
} );
102+
};

cli/index.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import { __ } from '@wordpress/i18n';
44
import { suppressPunycodeWarning } from 'common/lib/suppress-punycode-warning';
55
import { StatsGroup, StatsMetric } from 'common/types/stats';
66
import yargs from 'yargs';
7-
import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create';
8-
import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete';
9-
import { registerCommand as registerListCommand } from 'cli/commands/preview/list';
10-
import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update';
117
import { registerCommand as registerPm2ListCommand } from 'cli/commands/pm2/list';
128
import { registerCommand as registerPm2StartCommand } from 'cli/commands/pm2/start';
139
import { registerCommand as registerPm2StatusCommand } from 'cli/commands/pm2/status';
1410
import { registerCommand as registerPm2StopCommand } from 'cli/commands/pm2/stop';
11+
import { registerCommand as registerCreateCommand } from 'cli/commands/preview/create';
12+
import { registerCommand as registerDeleteCommand } from 'cli/commands/preview/delete';
13+
import { registerCommand as registerListCommand } from 'cli/commands/preview/list';
14+
import { registerCommand as registerUpdateCommand } from 'cli/commands/preview/update';
15+
import { registerCommand as registerProxyBootCommand } from 'cli/commands/proxy/boot';
1516
import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list';
1617
import { readAppdata } from 'cli/lib/appdata';
1718
import { loadTranslations } from 'cli/lib/i18n';
@@ -56,12 +57,28 @@ async function main() {
5657
registerUpdateCommand( previewYargs );
5758
previewYargs.demandCommand( 1, __( 'You must provide a valid command' ) );
5859
} )
59-
.command( 'pm2', __( 'Manage PM2 daemon' ), ( pm2Yargs ) => {
60-
registerPm2StartCommand( pm2Yargs );
61-
registerPm2StopCommand( pm2Yargs );
62-
registerPm2StatusCommand( pm2Yargs );
63-
registerPm2ListCommand( pm2Yargs );
64-
pm2Yargs.demandCommand( 1, __( 'You must provide a valid command' ) );
60+
.command( {
61+
command: 'pm2',
62+
describe: __( 'Internal: PM2 daemon management (Studio use only)' ),
63+
hidden: true,
64+
builder: ( pm2Yargs ) => {
65+
registerPm2StartCommand( pm2Yargs );
66+
registerPm2StopCommand( pm2Yargs );
67+
registerPm2StatusCommand( pm2Yargs );
68+
registerPm2ListCommand( pm2Yargs );
69+
pm2Yargs.demandCommand( 1, __( 'You must provide a valid command' ) );
70+
return pm2Yargs;
71+
},
72+
} )
73+
.command( {
74+
command: 'proxy',
75+
describe: __( 'Internal: Proxy server management (Studio use only)' ),
76+
hidden: true,
77+
builder: ( proxyYargs ) => {
78+
registerProxyBootCommand( proxyYargs );
79+
proxyYargs.demandCommand( 1, __( 'You must provide a valid command' ) );
80+
return proxyYargs;
81+
},
6582
} )
6683
.demandCommand( 1, __( 'You must provide a valid command' ) )
6784
.strict();

cli/lib/pm2-manager.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import path from 'path';
21
import fs from 'fs';
32
import os from 'os';
3+
import path from 'path';
4+
import { getAppdataPath } from 'cli/lib/appdata';
45

5-
function resolvePm2(): typeof import( 'pm2' ) {
6+
function resolvePm2(): typeof import('pm2') {
67
try {
78
return require( 'pm2' );
89
} catch ( error ) {
@@ -26,7 +27,9 @@ function resolvePm2(): typeof import( 'pm2' ) {
2627
}
2728

2829
throw new Error(
29-
`pm2 module not found. Please ensure pm2 is installed in the CLI dependencies. Tried paths: ${ possiblePaths.join( ', ' ) }`
30+
`pm2 module not found. Please ensure pm2 is installed in the CLI dependencies. Tried paths: ${ possiblePaths.join(
31+
', '
32+
) }`
3033
);
3134
}
3235
}
@@ -133,7 +136,8 @@ export async function startDaemon(): Promise< void > {
133136
return;
134137
}
135138
await connect();
136-
disconnect();
139+
// Keep connection open - subsequent operations will reuse it
140+
// The isConnected flag prevents duplicate connections
137141
}
138142

139143
export async function stopDaemon(): Promise< void > {
@@ -262,3 +266,116 @@ process.on( 'exit', cleanup );
262266
process.on( 'SIGINT', cleanup );
263267
process.on( 'SIGTERM', cleanup );
264268

269+
/**
270+
* Proxy Server Management Functions
271+
*
272+
* The proxy runs as a PM2-managed CLI process. When `studio proxy start` is called,
273+
* PM2 starts the CLI with the proxy command and keeps it running persistently.
274+
*/
275+
276+
const PROXY_PROCESS_NAME = 'studio-proxy';
277+
278+
/**
279+
* Start the proxy server via PM2
280+
* This launches the CLI with `proxy start --managed` which runs the proxy servers
281+
*/
282+
export async function startProxyProcess( cliPath: string ): Promise< ProcessDescription > {
283+
await ensureDaemonRunning();
284+
285+
return new Promise( ( resolve, reject ) => {
286+
const processConfig: pm2.StartOptions = {
287+
name: PROXY_PROCESS_NAME,
288+
script: cliPath,
289+
args: [ 'proxy', 'boot', '--managed' ],
290+
instances: 1,
291+
exec_mode: 'fork',
292+
autorestart: true,
293+
max_restarts: 10,
294+
min_uptime: '10s',
295+
restart_delay: 3000,
296+
kill_timeout: 5000,
297+
uid: 0, // Run as root to bind to ports 80 and 443
298+
env: {
299+
// Pass the real user's home directory so proxy can find appdata
300+
// When running as root, os.homedir() returns /var/root instead of the user's home
301+
STUDIO_USER_HOME: os.homedir(),
302+
// Pass the actual appdata file path directly from CLI
303+
STUDIO_APPDATA_PATH: getAppdataPath(),
304+
},
305+
};
306+
307+
pm2.start( processConfig, ( error, apps ) => {
308+
disconnect();
309+
if ( error ) {
310+
reject( error );
311+
return;
312+
}
313+
314+
if ( ! apps || apps.length === 0 ) {
315+
reject( new Error( 'Failed to start proxy process' ) );
316+
return;
317+
}
318+
319+
resolve( apps[ 0 ] as ProcessDescription );
320+
} );
321+
} );
322+
}
323+
324+
/**
325+
* Check if the proxy process is running
326+
*/
327+
export async function isProxyProcessRunning(): Promise< boolean > {
328+
try {
329+
if ( ! isDaemonRunning() ) {
330+
return false;
331+
}
332+
333+
const processes = await listProcesses( false );
334+
return processes.some( ( p ) => p.name === PROXY_PROCESS_NAME && p.status === 'online' );
335+
} catch ( error ) {
336+
console.error( 'Error checking if proxy is running:', error );
337+
return false;
338+
}
339+
}
340+
341+
/**
342+
* Get the proxy process status
343+
*/
344+
export async function getProxyProcessStatus(): Promise< ProcessDescription | null > {
345+
return describeProcess( PROXY_PROCESS_NAME );
346+
}
347+
348+
/**
349+
* Stop the proxy server
350+
*/
351+
export async function stopProxyProcess(): Promise< void > {
352+
await stopProcess( PROXY_PROCESS_NAME );
353+
}
354+
355+
/**
356+
* Restart the proxy server
357+
*/
358+
export async function restartProxyProcess(): Promise< void > {
359+
await restartProcess( PROXY_PROCESS_NAME );
360+
}
361+
362+
/**
363+
* Delete the proxy process from PM2
364+
*/
365+
export async function deleteProxyProcess(): Promise< void > {
366+
await deleteProcess( PROXY_PROCESS_NAME );
367+
}
368+
369+
/**
370+
* Ensure the proxy is running, start it if not (idempotent)
371+
*/
372+
export async function ensureProxyRunning( cliPath: string ): Promise< void > {
373+
const isRunning = await isProxyProcessRunning();
374+
if ( isRunning ) {
375+
console.log( 'Proxy is already running' );
376+
return;
377+
}
378+
379+
console.log( 'Starting proxy server...' );
380+
await startProxyProcess( cliPath );
381+
}

0 commit comments

Comments
 (0)