diff --git a/apps/cli/commands/site/convert.ts b/apps/cli/commands/site/convert.ts
new file mode 100644
index 0000000000..8115e4e07d
--- /dev/null
+++ b/apps/cli/commands/site/convert.ts
@@ -0,0 +1,419 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import {
+ DATABASE_ENGINE_MYSQL,
+ DATABASE_ENGINE_SQLITE,
+ getSiteDatabaseEngine,
+ isMysqlSite,
+ type DatabaseEngine,
+ type MysqlSiteConfig,
+} from '@studio/common/lib/database-engine';
+import { generateBackupFilename } from '@studio/common/lib/generate-backup-filename';
+import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata';
+import { portFinder } from '@studio/common/lib/port-finder';
+import { SITE_RUNTIME_NATIVE_PHP, getSiteRuntime } from '@studio/common/lib/site-runtime';
+import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ lockCliConfig,
+ readCliConfig,
+ saveCliConfig,
+ SiteData,
+ unlockCliConfig,
+} from 'cli/lib/cli-config/core';
+import { getSiteByFolder } from 'cli/lib/cli-config/sites';
+import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client';
+import { getWpConfigTransformerPath } from 'cli/lib/dependency-management/paths';
+import { exportDatabaseToFile } from 'cli/lib/import-export/export/export-database';
+import {
+ canConnectToMysql,
+ ensureMysqlServerRunning,
+ importSqlFileIntoMysql,
+ type ManagedMysqlServer,
+} from 'cli/lib/mysql/mysql-process';
+import {
+ createMysqlSiteConfig,
+ provisionMysqlDatabase,
+ removeSqliteIntegrationForMysql,
+} from 'cli/lib/mysql/mysql-site';
+import { ensureWpConfig, isWordPressInstalled } from 'cli/lib/native-php/site-setup';
+import { isServerRunning, stopWordPressServer } from 'cli/lib/wordpress-server-manager';
+import { Logger, LoggerError } from 'cli/logger';
+import { StudioArgv } from 'cli/types';
+
+const logger = new Logger< LoggerAction >();
+
+// The export driver emits per-table `COLLATE=utf8mb4_0900_ai_ci`. Provision the
+// database with the same collation so the schema default and imported tables
+// agree instead of silently diverging (see mysql-support design notes).
+const CONVERT_DATABASE_COLLATION = 'utf8mb4_0900_ai_ci';
+
+type ConvertBackup = {
+ dir: string;
+ dbPhpPath?: string;
+ sqliteMuPluginDir?: string;
+ wpConfigPath?: string;
+ priorSite: SiteData;
+};
+
+export async function runCommand( sitePath: string, to: DatabaseEngine ): Promise< void > {
+ if ( to !== DATABASE_ENGINE_MYSQL ) {
+ throw new LoggerError(
+ sprintf( __( 'Unsupported conversion target "%s". Only "mysql" is supported.' ), to )
+ );
+ }
+
+ try {
+ logger.reportStart( LoggerAction.START_DAEMON, __( 'Starting process daemon…' ) );
+ await connectToDaemon();
+ logger.reportSuccess( __( 'Process daemon started' ) );
+
+ logger.reportStart( LoggerAction.LOAD_SITES, __( 'Loading site…' ) );
+ const site = await getSiteByFolder( sitePath );
+ logger.reportSuccess( __( 'Site loaded' ) );
+
+ // Preflight validation ---------------------------------------------------
+ if ( isMysqlSite( site ) ) {
+ throw new LoggerError( __( 'This site already uses MySQL. Nothing to convert.' ) );
+ }
+ if ( getSiteRuntime( site ) !== SITE_RUNTIME_NATIVE_PHP ) {
+ throw new LoggerError(
+ __( 'MySQL requires the native PHP runtime. Use "studio set --runtime native" first.' )
+ );
+ }
+ const dbPhpPath = path.join( site.path, 'wp-content', 'db.php' );
+ if ( fs.existsSync( dbPhpPath ) ) {
+ const dropinContent = await fs.promises.readFile( dbPhpPath, 'utf8' );
+ if ( dropinContent.includes( '@studio-keep' ) ) {
+ throw new LoggerError(
+ __( 'Cannot convert while wp-content/db.php is marked @studio-keep.' )
+ );
+ }
+ if (
+ ! dropinContent.includes( 'sqlite-database-integration' ) &&
+ ! dropinContent.includes( 'SQLITE_DB_DROPIN_VERSION' )
+ ) {
+ throw new LoggerError( __( 'Cannot convert with an unknown wp-content/db.php drop-in.' ) );
+ }
+ }
+
+ const phpVersion = resolveNativePhpVersion( site.phpVersion ?? '' );
+
+ // The convert flow provisions and boots MySQL directly (outside the site
+ // server process), so the site must not be running under SQLite while we
+ // swap its drop-in and config out from under it.
+ const wasRunning = Boolean( await isServerRunning( site.id ) );
+ if ( wasRunning ) {
+ logger.reportStart( LoggerAction.STOP_SITE, __( 'Stopping WordPress server…' ) );
+ await stopWordPressServer( site.id );
+ logger.reportSuccess( __( 'WordPress server stopped' ) );
+ }
+
+ // Step 0 — backup --------------------------------------------------------
+ logger.reportStart( LoggerAction.CREATE_BACKUP, __( 'Backing up SQLite site…' ) );
+ const backup = await createBackup( site );
+ logger.reportSuccess( sprintf( __( 'Backup created at %s' ), backup.dir ) );
+
+ let mysqlServer: ManagedMysqlServer | null = null;
+ let mysqlConfig: MysqlSiteConfig | undefined;
+ let swappedConfig = false;
+
+ try {
+ // Step 1 — export portable MySQL SQL from the SQLite site --------------
+ logger.reportStart( LoggerAction.EXPORT_DATABASE, __( 'Exporting database to MySQL SQL…' ) );
+ const dumpPath = path.join( backup.dir, `${ generateBackupFilename( 'convert-db' ) }.sql` );
+ await exportDatabaseToFile( site, dumpPath );
+ const dumpBytes = fs.statSync( dumpPath ).size;
+ logger.reportSuccess(
+ sprintf( __( 'Database exported (%s bytes)' ), dumpBytes.toLocaleString() )
+ );
+
+ // Step 2 — provision MySQL --------------------------------------------
+ logger.reportStart( LoggerAction.SAVE_SITE, __( 'Provisioning MySQL database…' ) );
+ // Reserve every port already claimed by a site (its WordPress server
+ // port, and any existing MySQL port) so the MySQL port we allocate here
+ // can't collide with this site's own server port or another site.
+ const cliConfigForPorts = await readCliConfig();
+ for ( const existing of cliConfigForPorts.sites ) {
+ portFinder.addUnavailablePort( existing.port );
+ if ( existing.mysql ) {
+ portFinder.addUnavailablePort( existing.mysql.port );
+ }
+ }
+ mysqlConfig = createMysqlSiteConfig( site.id, await portFinder.getOpenPort() );
+ mysqlServer = await ensureMysqlServerRunning( mysqlConfig, ( message ) =>
+ logger.reportProgress( String( message ) )
+ );
+ await provisionMysqlDatabase( mysqlConfig, { collation: CONVERT_DATABASE_COLLATION } );
+ logger.reportSuccess( __( 'MySQL database provisioned' ) );
+
+ // Step 3 — import the dump INTO MySQL ---------------------------------
+ logger.reportStart( LoggerAction.IMPORT_DATABASE, __( 'Importing data into MySQL…' ) );
+ await importSqlFileIntoMysql( mysqlConfig, dumpPath );
+ logger.reportSuccess( __( 'Data imported into MySQL' ) );
+
+ // Step 4 — swap config to MySQL ---------------------------------------
+ logger.reportStart( LoggerAction.SAVE_SITE, __( 'Switching site to MySQL…' ) );
+ await removeSqliteIntegrationForMysql( site.path );
+ swappedConfig = true;
+ await ensureWpConfig(
+ site.path,
+ phpVersion,
+ new AbortController().signal,
+ getWpConfigTransformerPath(),
+ {
+ databaseEngine: DATABASE_ENGINE_MYSQL,
+ mysql: mysqlConfig,
+ enableDebugLog: site.enableDebugLog,
+ enableDebugDisplay: site.enableDebugDisplay,
+ }
+ );
+ logger.reportSuccess( __( 'Site configuration switched to MySQL' ) );
+
+ // Step 5 — verify boot on MySQL (hard accept gate) --------------------
+ logger.reportStart( LoggerAction.VALIDATE, __( 'Verifying WordPress boots on MySQL…' ) );
+ // Confirm mysqld is actually accepting connections before booting PHP
+ // against it, so a transient not-yet-ready socket doesn't read as a
+ // failed conversion.
+ if ( ! ( await waitForMysqlReachable( mysqlConfig ) ) ) {
+ throw new Error( 'MySQL server was not reachable before the boot check.' );
+ }
+ const installed = await verifyWordPressBoots( site.path, phpVersion );
+ if ( ! installed ) {
+ throw new Error(
+ 'WordPress did not report as installed against MySQL (is_blog_installed() was false).'
+ );
+ }
+ logger.reportSuccess( __( 'WordPress boots on MySQL' ) );
+
+ // Step 6 — commit config flip -----------------------------------------
+ logger.reportStart( LoggerAction.SAVE_SITE, __( 'Saving site configuration…' ) );
+ await commitConfigFlip( site.id, mysqlConfig );
+ logger.reportSuccess( __( 'Site configuration saved' ) );
+ } catch ( error ) {
+ logger.reportError(
+ new LoggerError( __( 'Conversion failed — rolling back to SQLite…' ), error ),
+ false
+ );
+ await rollback( backup, mysqlConfig, swappedConfig, mysqlServer, phpVersion );
+ throw new LoggerError(
+ __( 'Conversion failed and the site was rolled back to SQLite. The site is unchanged.' ),
+ error
+ );
+ } finally {
+ await mysqlServer?.stop().catch( () => undefined );
+ }
+
+ console.log( '' );
+ console.log(
+ sprintf(
+ __(
+ 'Site "%s" was converted to MySQL. Run "studio start" to serve it on the MySQL stack.'
+ ),
+ site.name
+ )
+ );
+ console.log( sprintf( __( 'SQLite backup retained at: %s' ), backup.dir ) );
+ } finally {
+ await disconnectFromDaemon();
+ }
+}
+
+async function createBackup( site: SiteData ): Promise< ConvertBackup > {
+ const dir = path.join( os.tmpdir(), `studio-convert-backup-${ site.id }-${ Date.now() }` );
+ await fs.promises.mkdir( dir, { recursive: true } );
+
+ const backup: ConvertBackup = { dir, priorSite: structuredClone( site ) };
+
+ const dbPhpPath = path.join( site.path, 'wp-content', 'db.php' );
+ if ( fs.existsSync( dbPhpPath ) ) {
+ backup.dbPhpPath = path.join( dir, 'db.php' );
+ await fs.promises.copyFile( dbPhpPath, backup.dbPhpPath );
+ }
+
+ const sqliteMuPluginDir = path.join(
+ site.path,
+ 'wp-content',
+ 'mu-plugins',
+ 'sqlite-database-integration'
+ );
+ if ( fs.existsSync( sqliteMuPluginDir ) ) {
+ backup.sqliteMuPluginDir = path.join( dir, 'sqlite-database-integration' );
+ await fs.promises.cp( sqliteMuPluginDir, backup.sqliteMuPluginDir, { recursive: true } );
+ }
+
+ const wpConfigPath = path.join( site.path, 'wp-config.php' );
+ if ( fs.existsSync( wpConfigPath ) ) {
+ backup.wpConfigPath = path.join( dir, 'wp-config.php' );
+ await fs.promises.copyFile( wpConfigPath, backup.wpConfigPath );
+ }
+
+ // Record the prior config so rollback can restore engine + mysql fields.
+ await fs.promises.writeFile(
+ path.join( dir, 'prior-site.json' ),
+ JSON.stringify( backup.priorSite, null, 2 ) + '\n',
+ 'utf8'
+ );
+
+ return backup;
+}
+
+async function waitForMysqlReachable( mysqlConfig: MysqlSiteConfig ): Promise< boolean > {
+ const deadline = Date.now() + 30_000;
+ while ( Date.now() < deadline ) {
+ if ( await canConnectToMysql( mysqlConfig ) ) {
+ return true;
+ }
+ await new Promise( ( resolve ) => setTimeout( resolve, 250 ) );
+ }
+ return false;
+}
+
+async function verifyWordPressBoots(
+ sitePath: string,
+ phpVersion: ReturnType< typeof resolveNativePhpVersion >
+): Promise< boolean > {
+ // The check spawns a fresh PHP process; give it a couple of attempts so a
+ // single transient first-connection hiccup doesn't fail an otherwise-good
+ // conversion. A thrown error on the final attempt is surfaced to the caller.
+ let lastError: unknown;
+ for ( let attempt = 0; attempt < 3; attempt++ ) {
+ try {
+ if ( await isWordPressInstalled( sitePath, phpVersion, new AbortController().signal ) ) {
+ return true;
+ }
+ lastError = undefined;
+ } catch ( error ) {
+ lastError = error;
+ }
+ await new Promise( ( resolve ) => setTimeout( resolve, 500 ) );
+ }
+ if ( lastError ) {
+ throw lastError;
+ }
+ return false;
+}
+
+async function commitConfigFlip( siteId: string, mysqlConfig: MysqlSiteConfig ): Promise< void > {
+ try {
+ await lockCliConfig();
+ const cliConfig = await readCliConfig();
+ const target = cliConfig.sites.find( ( s ) => s.id === siteId );
+ if ( ! target ) {
+ throw new Error( `Site ${ siteId } no longer present in config during commit.` );
+ }
+ target.databaseEngine = DATABASE_ENGINE_MYSQL;
+ target.mysql = mysqlConfig;
+ await saveCliConfig( cliConfig );
+ } finally {
+ await unlockCliConfig();
+ }
+}
+
+async function rollback(
+ backup: ConvertBackup,
+ mysqlConfig: MysqlSiteConfig | undefined,
+ swappedConfig: boolean,
+ mysqlServer: ManagedMysqlServer | null,
+ phpVersion: ReturnType< typeof resolveNativePhpVersion >
+): Promise< void > {
+ const sitePath = backup.priorSite.path;
+
+ // Restore the SQLite drop-in + mu-plugin + wp-config if we swapped them.
+ if ( swappedConfig ) {
+ try {
+ if ( backup.dbPhpPath ) {
+ await fs.promises.copyFile(
+ backup.dbPhpPath,
+ path.join( sitePath, 'wp-content', 'db.php' )
+ );
+ }
+ if ( backup.sqliteMuPluginDir ) {
+ const dest = path.join(
+ sitePath,
+ 'wp-content',
+ 'mu-plugins',
+ 'sqlite-database-integration'
+ );
+ await fs.promises.rm( dest, { recursive: true, force: true } );
+ await fs.promises.cp( backup.sqliteMuPluginDir, dest, { recursive: true } );
+ }
+ if ( backup.wpConfigPath ) {
+ await fs.promises.copyFile( backup.wpConfigPath, path.join( sitePath, 'wp-config.php' ) );
+ }
+ } catch ( error ) {
+ logger.reportError(
+ new LoggerError(
+ sprintf(
+ __( 'Failed to restore SQLite drop-in during rollback. Manual backup: %s' ),
+ backup.dir
+ ),
+ error
+ ),
+ false
+ );
+ }
+ }
+
+ // Ensure config still reflects SQLite (never left flipped to MySQL).
+ try {
+ await lockCliConfig();
+ const cliConfig = await readCliConfig();
+ const target = cliConfig.sites.find( ( s ) => s.id === backup.priorSite.id );
+ if ( target ) {
+ target.databaseEngine =
+ getSiteDatabaseEngine( backup.priorSite ) === DATABASE_ENGINE_MYSQL
+ ? DATABASE_ENGINE_MYSQL
+ : DATABASE_ENGINE_SQLITE;
+ // A previously-SQLite site should carry no engine/mysql metadata.
+ if ( target.databaseEngine === DATABASE_ENGINE_SQLITE ) {
+ target.databaseEngine = backup.priorSite.databaseEngine;
+ target.mysql = backup.priorSite.mysql;
+ }
+ await saveCliConfig( cliConfig );
+ }
+ } finally {
+ await unlockCliConfig();
+ }
+
+ // Tear down the half-populated MySQL database + datadir.
+ if ( mysqlConfig ) {
+ await mysqlServer?.stop().catch( () => undefined );
+ try {
+ await fs.promises.rm( mysqlConfig.dataDir, { recursive: true, force: true } );
+ } catch {
+ // dataDir may not have been created; ignore.
+ }
+ }
+
+ void phpVersion;
+}
+
+export const registerCommand = ( yargs: StudioArgv ) => {
+ return yargs.command( {
+ command: 'convert',
+ describe: __( 'Convert an existing site to a different database engine' ),
+ builder: ( yargs ) => {
+ return yargs.option( 'to', {
+ type: 'string',
+ describe: __( 'Target database engine' ),
+ choices: [ DATABASE_ENGINE_MYSQL ] as const,
+ demandOption: true,
+ } );
+ },
+ handler: async ( argv ) => {
+ try {
+ await runCommand( argv.path, argv.to as DatabaseEngine );
+ } catch ( error ) {
+ if ( error instanceof LoggerError ) {
+ logger.reportError( error );
+ } else {
+ const loggerError = new LoggerError( __( 'Failed to convert site' ), error );
+ logger.reportError( loggerError );
+ }
+ process.exit( 1 );
+ }
+ },
+ } );
+};
diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts
index 353dd47bfd..b7fbb6d559 100644
--- a/apps/cli/commands/site/create.ts
+++ b/apps/cli/commands/site/create.ts
@@ -7,6 +7,11 @@ import { installAiInstructionsToSite } from '@studio/common/lib/agent-skills';
import { extractFormValuesFromBlueprint } from '@studio/common/lib/blueprint-settings';
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
import { SITE_EVENTS } from '@studio/common/lib/cli-events';
+import {
+ DATABASE_ENGINE_MYSQL,
+ DATABASE_ENGINE_SQLITE,
+ type DatabaseEngine,
+} from '@studio/common/lib/database-engine';
import { getDomainNameValidationError } from '@studio/common/lib/domains';
import {
arePathsEqual,
@@ -75,6 +80,7 @@ import {
import { updateServerFiles } from 'cli/lib/dependency-management/setup';
import { downloadWordPress } from 'cli/lib/dependency-management/wordpress';
import { copyLanguagePackToSite } from 'cli/lib/language-packs';
+import { createMysqlSiteConfig } from 'cli/lib/mysql/mysql-site';
import { validateSupportedPhpVersion } from 'cli/lib/php-versions';
import { getPreferredSiteLanguage } from 'cli/lib/site-language';
import { generateSiteName } from 'cli/lib/site-name';
@@ -97,6 +103,7 @@ export type CreateCommandOptions = {
phpVersion: SupportedPHPVersion;
runtime: SiteRuntime;
fileAccess: SiteFileAccess;
+ databaseEngine?: DatabaseEngine;
customDomain?: string;
enableHttps: boolean;
blueprint?: {
@@ -116,6 +123,7 @@ export async function runCommand(
options: CreateCommandOptions
): Promise< void > {
const siteRuntime = options.runtime;
+ const databaseEngine = options.databaseEngine ?? DATABASE_ENGINE_SQLITE;
if ( ! isFileAccessAllowedForRuntime( siteRuntime, options.fileAccess ) ) {
throw new LoggerError(
__(
@@ -123,6 +131,9 @@ export async function runCommand(
)
);
}
+ if ( databaseEngine === DATABASE_ENGINE_MYSQL && siteRuntime !== SITE_RUNTIME_NATIVE_PHP ) {
+ throw new LoggerError( __( 'MySQL requires the native PHP runtime.' ) );
+ }
const phpVersion = validateSupportedPhpVersion( options.phpVersion );
const isOnlineStatus = await isOnline();
@@ -260,9 +271,11 @@ export async function runCommand(
logger.reportSuccess( __( 'WordPress files copied' ) );
}
- logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration…' ) );
- await keepSqliteIntegrationUpdated( sitePath );
- logger.reportSuccess( __( 'SQLite integration configured' ) );
+ if ( databaseEngine === DATABASE_ENGINE_SQLITE ) {
+ logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration…' ) );
+ await keepSqliteIntegrationUpdated( sitePath );
+ logger.reportSuccess( __( 'SQLite integration configured' ) );
+ }
try {
const sharedConfig = await readSharedConfig();
@@ -282,6 +295,10 @@ export async function runCommand(
const siteName = options.name || path.basename( sitePath );
const siteId = options.siteId || crypto.randomUUID();
+ const mysqlConfig =
+ databaseEngine === DATABASE_ENGINE_MYSQL
+ ? createMysqlSiteConfig( siteId, await portFinder.getOpenPort() )
+ : undefined;
// Determine admin credentials: CLI args > Blueprint > defaults
// External passwords need to be encoded; createPassword() already returns encoded
@@ -324,6 +341,8 @@ export async function runCommand(
phpVersion,
runtime: siteRuntime,
fileAccess: options.fileAccess,
+ databaseEngine: mysqlConfig ? DATABASE_ENGINE_MYSQL : undefined,
+ mysql: mysqlConfig,
running: false,
status: 'ready',
isWpAutoUpdating: options.wpVersion === DEFAULT_WORDPRESS_VERSION,
@@ -367,7 +386,9 @@ export async function runCommand(
} );
logger.reportSuccess( __( 'WordPress server started' ) );
- stripWpConfigDbConstants( sitePath );
+ if ( databaseEngine === DATABASE_ENGINE_SQLITE ) {
+ stripWpConfigDbConstants( sitePath );
+ }
if ( processDesc.status === 'online' ) {
await updateSiteLatestCliPid( siteDetails.id, processDesc.pid );
@@ -386,6 +407,9 @@ export async function runCommand(
}
} catch ( error ) {
await removeSiteFromConfig( siteDetails.id );
+ if ( mysqlConfig ) {
+ await fs.promises.rm( mysqlConfig.dataDir, { recursive: true, force: true } );
+ }
if ( ! isWordPressDirResult ) {
await fs.promises.rm( sitePath, { recursive: true, force: true } );
}
@@ -410,9 +434,14 @@ export async function runCommand(
} );
logger.reportSuccess( __( 'Blueprint applied successfully' ) );
- stripWpConfigDbConstants( sitePath );
+ if ( databaseEngine === DATABASE_ENGINE_SQLITE ) {
+ stripWpConfigDbConstants( sitePath );
+ }
} catch ( error ) {
await removeSiteFromConfig( siteDetails.id );
+ if ( mysqlConfig ) {
+ await fs.promises.rm( mysqlConfig.dataDir, { recursive: true, force: true } );
+ }
if ( ! isWordPressDirResult ) {
await fs.promises.rm( sitePath, { recursive: true, force: true } );
}
@@ -553,6 +582,12 @@ export const registerCommand = ( yargs: StudioArgv ) => {
choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ] as const,
default: SITE_FILE_ACCESS_SITE_DIRECTORY,
} )
+ .option( 'database-engine', {
+ type: 'string',
+ describe: __( 'Database engine for the site' ),
+ choices: [ DATABASE_ENGINE_SQLITE, DATABASE_ENGINE_MYSQL ] as const,
+ default: DATABASE_ENGINE_SQLITE,
+ } )
.option( 'domain', {
type: 'string',
describe: __( 'Custom domain (e.g., "mysite.local")' ),
@@ -612,6 +647,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
let adminEmail = argv.adminEmail;
const runtime = siteRuntimeFromMode( argv.runtime );
const fileAccess = argv.fileAccess;
+ const databaseEngine = argv.databaseEngine as DatabaseEngine;
if ( ! isFileAccessAllowedForRuntime( runtime, fileAccess ) ) {
logger.reportError(
new LoggerError(
@@ -789,6 +825,7 @@ export const registerCommand = ( yargs: StudioArgv ) => {
phpVersion: phpVersion ?? RecommendedPHPVersion,
runtime,
fileAccess,
+ databaseEngine,
customDomain,
enableHttps,
adminUsername,
diff --git a/apps/cli/commands/site/start.ts b/apps/cli/commands/site/start.ts
index 3a80a5e3f2..13fbc28084 100644
--- a/apps/cli/commands/site/start.ts
+++ b/apps/cli/commands/site/start.ts
@@ -1,4 +1,5 @@
import { updateManagedInstructionFiles } from '@studio/common/lib/agent-skills';
+import { isMysqlSite } from '@studio/common/lib/database-engine';
import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
import { __, sprintf } from '@wordpress/i18n';
import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites';
@@ -63,12 +64,14 @@ export async function runCommand(
await setupCustomDomain( site, logger );
- logger.reportStart(
- LoggerAction.INSTALL_SQLITE,
- __( 'Setting up SQLite integration, if needed…' )
- );
- await keepSqliteIntegrationUpdated( sitePath );
- logger.reportSuccess( __( 'SQLite integration configured as needed' ) );
+ if ( ! isMysqlSite( site ) ) {
+ logger.reportStart(
+ LoggerAction.INSTALL_SQLITE,
+ __( 'Setting up SQLite integration, if needed…' )
+ );
+ await keepSqliteIntegrationUpdated( sitePath );
+ logger.reportSuccess( __( 'SQLite integration configured as needed' ) );
+ }
try {
await updateManagedInstructionFiles( sitePath, getAiInstructionsPath() );
diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts
index cd9c3455da..dc3a553a3d 100644
--- a/apps/cli/commands/site/tests/create.test.ts
+++ b/apps/cli/commands/site/tests/create.test.ts
@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { validateBlueprintData } from '@studio/common/lib/blueprint-validation';
+import { DATABASE_ENGINE_MYSQL } from '@studio/common/lib/database-engine';
import {
isEmptyDir,
isWordPressDirectory,
@@ -54,6 +55,7 @@ vi.mock( '@studio/common/lib/port-finder', () => ( {
} ) );
vi.mock( '@studio/common/lib/passwords', () => ( {
createPassword: vi.fn().mockReturnValue( 'generated-password-123' ),
+ encodePassword: vi.fn( ( password: string ) => password ),
} ) );
vi.mock( '@studio/common/lib/blueprint-validation' );
vi.mock( 'cli/lib/cli-config/core', async () => {
@@ -325,6 +327,38 @@ describe( 'CLI: studio site create', () => {
);
} );
+ it( 'should persist MySQL configuration and skip SQLite integration for native PHP sites', async () => {
+ vi.mocked( portFinder.getOpenPort )
+ .mockResolvedValueOnce( mockPort )
+ .mockResolvedValueOnce( 8899 );
+
+ await runCommand( mockSitePath, {
+ ...defaultTestOptions,
+ runtime: SITE_RUNTIME_NATIVE_PHP,
+ phpVersion: '8.4',
+ databaseEngine: DATABASE_ENGINE_MYSQL,
+ noStart: true,
+ } );
+
+ expect( keepSqliteIntegrationUpdated ).not.toHaveBeenCalled();
+ expect( saveCliConfig ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ sites: expect.arrayContaining( [
+ expect.objectContaining( {
+ runtime: SITE_RUNTIME_NATIVE_PHP,
+ databaseEngine: DATABASE_ENGINE_MYSQL,
+ mysql: expect.objectContaining( {
+ host: '127.0.0.1',
+ port: 8899,
+ serverVersion: '8.4.10',
+ } ),
+ } ),
+ ] ),
+ } )
+ );
+ expect( startWordPressServer ).not.toHaveBeenCalled();
+ } );
+
it( 'should reject "all-files" file access for sandbox sites', async () => {
await expect(
runCommand( mockSitePath, {
@@ -336,6 +370,17 @@ describe( 'CLI: studio site create', () => {
expect( saveCliConfig ).not.toHaveBeenCalled();
} );
+ it( 'should reject MySQL for sandbox sites', async () => {
+ await expect(
+ runCommand( mockSitePath, {
+ ...defaultTestOptions,
+ databaseEngine: DATABASE_ENGINE_MYSQL,
+ } )
+ ).rejects.toThrow( 'MySQL requires the native PHP runtime.' );
+
+ expect( saveCliConfig ).not.toHaveBeenCalled();
+ } );
+
it( 'should create site with custom name', async () => {
await runCommand( mockSitePath, {
...defaultTestOptions,
diff --git a/apps/cli/index.ts b/apps/cli/index.ts
index 8ea3c58292..c0b505527e 100644
--- a/apps/cli/index.ts
+++ b/apps/cli/index.ts
@@ -9,6 +9,7 @@ import { registerCommand as registerMcpCommand } from 'cli/commands/mcp';
import { registerCommand as registerPullCommand } from 'cli/commands/pull';
import { registerCommand as registerPullReprintCommand } from 'cli/commands/pull-reprint';
import { registerCommand as registerPushCommand } from 'cli/commands/push';
+import { registerCommand as registerSiteConvertCommand } from 'cli/commands/site/convert';
import { registerCommand as registerSiteCreateCommand } from 'cli/commands/site/create';
import { registerCommand as registerSiteDeleteCommand } from 'cli/commands/site/delete';
import { registerCommand as registerSiteListCommand } from 'cli/commands/site/list';
@@ -206,6 +207,7 @@ async function main() {
registerSiteStopCommand( studioArgv );
registerSiteDeleteCommand( studioArgv );
registerSiteStatusCommand( studioArgv );
+ registerSiteConvertCommand( studioArgv );
registerPushCommand( studioArgv );
registerPullCommand( studioArgv );
@@ -303,6 +305,7 @@ async function main() {
registerSiteStartCommand( sitesYargs );
registerSiteStopCommand( sitesYargs );
registerSiteDeleteCommand( sitesYargs );
+ registerSiteConvertCommand( sitesYargs );
registerSiteSetCommand( sitesYargs );
sitesYargs.version( false ).demandCommand( 1, __( 'You must provide a valid command' ) );
diff --git a/apps/cli/lib/dependency-management/mysql-binary.ts b/apps/cli/lib/dependency-management/mysql-binary.ts
new file mode 100644
index 0000000000..90a5e9cdd8
--- /dev/null
+++ b/apps/cli/lib/dependency-management/mysql-binary.ts
@@ -0,0 +1,197 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { downloadFile } from '@studio/common/lib/download-file';
+import { extractZip } from '@studio/common/lib/extract-zip';
+import { isErrnoException } from '@studio/common/lib/is-errno-exception';
+import {
+ DefaultMysqlSupportedVersion,
+ getMysqlBinaryDownloadInfo,
+ type MysqlBinaryDownloadInfo,
+ type MysqlSupportedVersion,
+} from '@studio/common/lib/mysql-binary-metadata';
+import * as tar from 'tar';
+import { getMysqlBinaryRoot, getMysqlServerBinaryPath } from './paths';
+
+const WAIT_POLL_INTERVAL_MS = 1_000;
+const WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
+
+export async function ensureMysqlBinaryAvailable(
+ version: MysqlSupportedVersion = DefaultMysqlSupportedVersion,
+ onProgress?: ( downloaded: number, total: number ) => void
+): Promise< string > {
+ const downloadInfo = await resolveMysqlBinaryDownloadInfo(
+ version,
+ process.platform,
+ process.arch
+ );
+ const binaryPath = getMysqlServerBinaryPath( downloadInfo.patchVersion );
+
+ if ( ! fs.existsSync( binaryPath ) ) {
+ await downloadAndInstall( downloadInfo, onProgress );
+ }
+
+ return downloadInfo.patchVersion;
+}
+
+export async function resolveMysqlBinaryDownloadInfo(
+ version: MysqlSupportedVersion,
+ platform: NodeJS.Platform,
+ arch: string
+): Promise< MysqlBinaryDownloadInfo > {
+ const downloadInfo = getMysqlBinaryDownloadInfo( version, platform, arch );
+ if ( downloadInfo ) {
+ return downloadInfo;
+ }
+
+ throw new Error( `MySQL ${ version } is not available for this platform yet.` );
+}
+
+async function waitForBinary( binaryPath: string ): Promise< void > {
+ const deadline = Date.now() + WAIT_TIMEOUT_MS;
+ while ( Date.now() < deadline ) {
+ if ( fs.existsSync( binaryPath ) ) {
+ return;
+ }
+ await new Promise( ( resolve ) => setTimeout( resolve, WAIT_POLL_INTERVAL_MS ) );
+ }
+ throw new Error(
+ `Timed out waiting for MySQL binary at ${ binaryPath }. ` +
+ `Another process may have failed to install it. ` +
+ `Delete ${ path.dirname( path.dirname( binaryPath ) ) } and retry.`
+ );
+}
+
+async function downloadAndInstall(
+ downloadInfo: MysqlBinaryDownloadInfo,
+ onProgress?: ( downloaded: number, total: number ) => void
+): Promise< void > {
+ const destPath = getMysqlServerBinaryPath( downloadInfo.patchVersion );
+ const destDir = path.dirname( path.dirname( destPath ) );
+ const mysqlBinRoot = getMysqlBinaryRoot();
+
+ fs.mkdirSync( mysqlBinRoot, { recursive: true } );
+
+ try {
+ fs.mkdirSync( destDir );
+ } catch ( err ) {
+ if ( isErrnoException( err ) && err.code === 'EEXIST' ) {
+ await waitForBinary( destPath );
+ return;
+ }
+ throw err;
+ }
+
+ const downloadPath = path.join( destDir, getArchiveFileName( downloadInfo.url ) );
+
+ try {
+ await downloadFile( downloadInfo.url, downloadPath, onProgress );
+ await verifyHash( downloadPath, downloadInfo.sha, downloadInfo.patchVersion );
+ await extractAndInstall( downloadPath, destDir, downloadInfo );
+ } catch ( err ) {
+ fs.rmSync( destDir, { recursive: true, force: true } );
+ throw err;
+ } finally {
+ if ( fs.existsSync( downloadPath ) ) {
+ fs.unlinkSync( downloadPath );
+ }
+ }
+}
+
+function getArchiveFileName( url: string ): string {
+ try {
+ return path.basename( new URL( url ).pathname );
+ } catch {
+ return path.basename( url );
+ }
+}
+
+async function verifyHash( filePath: string, expected: string, version: string ): Promise< void > {
+ const data = await fs.promises.readFile( filePath );
+ const actual = crypto.createHash( 'sha256' ).update( data ).digest( 'hex' );
+ if ( actual !== expected ) {
+ throw new Error(
+ `SHA-256 mismatch for MySQL ${ version }:\n` +
+ ` expected ${ expected }\n` +
+ ` got ${ actual }\n`
+ );
+ }
+}
+
+async function extractAndInstall(
+ archivePath: string,
+ destDir: string,
+ downloadInfo: MysqlBinaryDownloadInfo
+): Promise< void > {
+ const extractDir = fs.mkdtempSync(
+ path.join( os.tmpdir(), `mysql-${ downloadInfo.patchVersion }-` )
+ );
+ try {
+ if ( downloadInfo.archiveType === 'zip' ) {
+ await extractZip( archivePath, extractDir );
+ } else {
+ await extractTarGz( archivePath, extractDir );
+ }
+
+ const src = path.join( extractDir, downloadInfo.rootDir );
+ if ( ! fs.existsSync( path.join( src, 'bin' ) ) ) {
+ throw new Error( `MySQL archive did not contain expected root: ${ downloadInfo.rootDir }` );
+ }
+
+ copyDirectoryContents( src, destDir );
+ if ( process.platform !== 'win32' ) {
+ chmodBinFiles( path.join( destDir, 'bin' ) );
+ }
+ } finally {
+ fs.rmSync( extractDir, { recursive: true, force: true } );
+ }
+}
+
+async function extractTarGz( archivePath: string, destinationFolder: string ): Promise< void > {
+ const resolvedDestination = path.resolve( destinationFolder );
+ await tar.x( {
+ file: archivePath,
+ cwd: resolvedDestination,
+ filter: ( entryPath: string ) => {
+ const normalized = path.normalize( entryPath );
+ return ! path.isAbsolute( normalized ) && ! normalized.startsWith( `..${ path.sep }` );
+ },
+ } );
+}
+
+function copyDirectoryContents( sourceDir: string, destDir: string ): void {
+ for ( const entry of fs.readdirSync( sourceDir ) ) {
+ copyDereferenced( path.join( sourceDir, entry ), path.join( destDir, entry ) );
+ }
+}
+
+function copyDereferenced( sourcePath: string, destPath: string ): void {
+ const stat = fs.statSync( sourcePath );
+
+ if ( stat.isDirectory() ) {
+ fs.mkdirSync( destPath, { recursive: true, mode: stat.mode } );
+ for ( const entry of fs.readdirSync( sourcePath ) ) {
+ copyDereferenced( path.join( sourcePath, entry ), path.join( destPath, entry ) );
+ }
+ return;
+ }
+
+ if ( stat.isFile() ) {
+ fs.copyFileSync( sourcePath, destPath );
+ fs.chmodSync( destPath, stat.mode );
+ }
+}
+
+function chmodBinFiles( binDir: string ): void {
+ if ( ! fs.existsSync( binDir ) ) {
+ return;
+ }
+
+ for ( const entry of fs.readdirSync( binDir ) ) {
+ const filePath = path.join( binDir, entry );
+ if ( fs.statSync( filePath ).isFile() ) {
+ fs.chmodSync( filePath, 0o755 );
+ }
+ }
+}
diff --git a/apps/cli/lib/dependency-management/paths.ts b/apps/cli/lib/dependency-management/paths.ts
index f128e7363b..5aa856c512 100644
--- a/apps/cli/lib/dependency-management/paths.ts
+++ b/apps/cli/lib/dependency-management/paths.ts
@@ -7,6 +7,9 @@ import {
import { getConfigDirectory, getServerFilesPath } from '@studio/common/lib/well-known-paths';
const PHP_BINARY_FILENAME = process.platform === 'win32' ? 'php.exe' : 'php';
+const MYSQLD_BINARY_FILENAME = process.platform === 'win32' ? 'mysqld.exe' : 'mysqld';
+const MYSQLADMIN_BINARY_FILENAME = process.platform === 'win32' ? 'mysqladmin.exe' : 'mysqladmin';
+const MYSQL_CLIENT_BINARY_FILENAME = process.platform === 'win32' ? 'mysql.exe' : 'mysql';
function getPhpBinaryRoot(): string {
return path.join( getConfigDirectory(), 'php-bin' );
@@ -31,6 +34,38 @@ export function getPhpBinaryPath( version: NativePhpSupportedVersion | string ):
return getExactPhpBinaryPath( packageId ?? version );
}
+export function getMysqlBinaryRoot(): string {
+ return path.join( getConfigDirectory(), 'mysql-bin' );
+}
+
+export function getMysqlInstallRoot( version: string ): string {
+ return path.join( getMysqlBinaryRoot(), version );
+}
+
+export function getMysqlServerBinaryPath( version: string ): string {
+ return path.join( getMysqlInstallRoot( version ), 'bin', MYSQLD_BINARY_FILENAME );
+}
+
+export function getMysqlAdminBinaryPath( version: string ): string {
+ return path.join( getMysqlInstallRoot( version ), 'bin', MYSQLADMIN_BINARY_FILENAME );
+}
+
+export function getMysqlClientBinaryPath( version: string ): string {
+ return path.join( getMysqlInstallRoot( version ), 'bin', MYSQL_CLIENT_BINARY_FILENAME );
+}
+
+// Directory holding the bundled MySQL client binaries (mysql, mysqldump, …).
+// WP-CLI's `wp db` subcommands shell out to a bare `mysql`/`mysqldump` looked up
+// on PATH, so this dir must be prepended to PATH when running WP-CLI against a
+// MySQL-engine site — Studio bundles the client but never installs it globally.
+export function getMysqlClientBinaryDir( version: string ): string {
+ return path.join( getMysqlInstallRoot( version ), 'bin' );
+}
+
+export function getMysqlDataRoot(): string {
+ return path.join( getConfigDirectory(), 'mysql-data' );
+}
+
const WP_CLI_PHAR_FILENAME = 'wp-cli.phar';
const SQLITE_COMMAND_DIRNAME = 'sqlite-command';
@@ -44,6 +79,13 @@ export function getWordPressVersionPath( version: string ): string {
return path.join( getServerFilesPath(), 'wordpress-versions', version );
}
+// The `php/` helper scripts ship alongside the bundled CLI code (`dist/cli/php`).
+// Vite emits all chunks to the same output dir, so `import.meta.dirname` resolves
+// to that directory from any module.
+export function getWpConfigTransformerPath(): string {
+ return path.join( import.meta.dirname, 'php', 'wp-config-transformer.php' );
+}
+
// reprint.phar ships read-only with the CLI bundle (downloaded into `wp-files` at build time) and is
// mounted into the PHP-wasm VFS at `/tmp/reprint.phar` by the reprint child process.
export function getReprintPharPath(): string {
diff --git a/apps/cli/lib/mysql/mysql-process.ts b/apps/cli/lib/mysql/mysql-process.ts
new file mode 100644
index 0000000000..9326e762a9
--- /dev/null
+++ b/apps/cli/lib/mysql/mysql-process.ts
@@ -0,0 +1,393 @@
+import fs from 'fs';
+import { spawn, type ChildProcess } from 'node:child_process';
+import os from 'os';
+import path from 'path';
+import { ensureMysqlBinaryAvailable } from 'cli/lib/dependency-management/mysql-binary';
+import {
+ getMysqlAdminBinaryPath,
+ getMysqlClientBinaryPath,
+ getMysqlServerBinaryPath,
+} from 'cli/lib/dependency-management/paths';
+import type { MysqlSiteConfig } from '@studio/common/lib/database-engine';
+
+const MYSQL_START_TIMEOUT_MS = 60_000;
+const MYSQL_STOP_TIMEOUT_MS = 10_000;
+const MYSQL_COMMAND_TIMEOUT_MS = 30_000;
+// Importing a full-site dump can take much longer than a normal command.
+const MYSQL_IMPORT_TIMEOUT_MS = 20 * 60_000;
+const MYSQL_POLL_INTERVAL_MS = 250;
+const MYSQL_DOWNLOAD_PROGRESS_INTERVAL_BYTES = 5 * 1024 * 1024;
+
+type Logger = ( ...args: Parameters< typeof console.log > ) => void;
+
+export type ManagedMysqlServer = {
+ started: boolean;
+ stop: () => Promise< void >;
+};
+
+const runningServers = new Map< string, ChildProcess >();
+
+export async function ensureMysqlServerRunning(
+ config: MysqlSiteConfig,
+ logToConsole?: Logger,
+ signal?: AbortSignal
+): Promise< ManagedMysqlServer > {
+ if ( await canConnectToMysql( config ) ) {
+ return noopServer();
+ }
+
+ const existing = runningServers.get( config.dataDir );
+ if ( existing && existing.exitCode === null && existing.signalCode === null ) {
+ await waitForMysqlReady( config, existing, signal );
+ return {
+ started: true,
+ stop: () => stopMysqlChild( config, existing ),
+ };
+ }
+
+ let lastLoggedDownload = 0;
+ let loggedComplete = false;
+ const version = await ensureMysqlBinaryAvailable( undefined, ( downloaded, total ) => {
+ const isComplete = total > 0 && downloaded >= total;
+ if (
+ downloaded - lastLoggedDownload < MYSQL_DOWNLOAD_PROGRESS_INTERVAL_BYTES &&
+ ! ( isComplete && ! loggedComplete )
+ ) {
+ return;
+ }
+
+ lastLoggedDownload = downloaded;
+ loggedComplete ||= isComplete;
+ const dl = ( downloaded / 1024 / 1024 ).toFixed( 1 );
+ const tot = total ? ` / ${ ( total / 1024 / 1024 ).toFixed( 1 ) } MB` : '';
+ logToConsole?.( `Downloading MySQL ${ dl } MB${ tot }` );
+ } );
+ if ( config.serverVersion !== version ) {
+ throw new Error(
+ `MySQL site expects server ${ config.serverVersion }, but Studio installed ${ version }.`
+ );
+ }
+
+ await initializeDataDir( config, signal );
+
+ const runtimeDir = getRuntimeDir( config );
+ fs.mkdirSync( runtimeDir, { recursive: true } );
+
+ const mysqld = spawn(
+ getMysqlServerBinaryPath( config.serverVersion ),
+ [
+ '--no-defaults',
+ `--basedir=${ getMysqlInstallRootFromVersion( config.serverVersion ) }`,
+ `--datadir=${ config.dataDir }`,
+ `--port=${ config.port }`,
+ `--bind-address=${ config.host }`,
+ `--socket=${ path.join( runtimeDir, 'mysql.sock' ) }`,
+ `--pid-file=${ path.join( runtimeDir, 'mysql.pid' ) }`,
+ '--mysqlx=0',
+ // Pin the server clock to UTC. WordPress and Action Scheduler store all
+ // `*_date_gmt` / `*_gmt` columns as PHP-computed UTC (gmdate), so the
+ // database's own clock must be UTC too. Left at the default `SYSTEM`
+ // zone, `NOW()`/`CURRENT_TIMESTAMP` return the host's local time (e.g.
+ // EDT, UTC-4) while the stored values are UTC — a multi-hour disagreement
+ // that makes UTC timestamps look future-dated to any query comparing them
+ // against the DB clock and corrupts any WP/plugin logic mixing MySQL
+ // `NOW()` with a `_gmt` column.
+ '--default-time-zone=+00:00',
+ ],
+ {
+ stdio: [ 'ignore', 'pipe', 'pipe' ],
+ signal,
+ }
+ );
+
+ let stderr = '';
+ mysqld.stdout?.on( 'data', ( chunk ) => {
+ logToConsole?.( `[MySQL] ${ chunk.toString().trimEnd() }` );
+ } );
+ mysqld.stderr?.on( 'data', ( chunk ) => {
+ const text = chunk.toString();
+ stderr += text;
+ logToConsole?.( `[MySQL] ${ text.trimEnd() }` );
+ } );
+ mysqld.once( 'exit', () => {
+ if ( runningServers.get( config.dataDir ) === mysqld ) {
+ runningServers.delete( config.dataDir );
+ }
+ } );
+
+ runningServers.set( config.dataDir, mysqld );
+
+ try {
+ await waitForMysqlReady( config, mysqld, signal );
+ } catch ( error ) {
+ await stopMysqlChild( config, mysqld ).catch( () => undefined );
+ throw new Error(
+ `MySQL server failed to start: ${ error instanceof Error ? error.message : String( error ) }${
+ stderr.trim() ? `\n${ stderr.trim() }` : ''
+ }`
+ );
+ }
+
+ return {
+ started: true,
+ stop: () => stopMysqlChild( config, mysqld ),
+ };
+}
+
+export async function runMysqlQuery(
+ config: MysqlSiteConfig,
+ sql: string,
+ options: { user?: string; password?: string; database?: string } = {}
+): Promise< string > {
+ const args = [
+ '--batch',
+ '--skip-column-names',
+ '--protocol=tcp',
+ `--host=${ config.host }`,
+ `--port=${ config.port }`,
+ `--user=${ options.user ?? 'root' }`,
+ ...( options.password !== undefined ? [ `--password=${ options.password }` ] : [] ),
+ ...( options.database ? [ options.database ] : [] ),
+ '--execute',
+ sql,
+ ];
+ const result = await runMysqlCommand( getMysqlClientBinaryPath( config.serverVersion ), args );
+ return result.stdout;
+}
+
+// WordPress data legitimately carries `datetime DEFAULT '0000-00-00 00:00:00'`
+// columns, which MySQL 8's default strict SQL mode (NO_ZERO_DATE / NO_ZERO_IN_DATE
+// / STRICT_TRANS_TABLES) rejects at CREATE TABLE time. WordPress core itself runs
+// against MySQL with a permissive sql_mode for exactly this reason, so the import
+// session must relax those modes to load a genuine WordPress dump. This is the
+// same accommodation `wp db import` / mysqldump round-trips rely on — not a
+// workaround for a bad dump, but how WordPress data lives in MySQL.
+const MYSQL_IMPORT_SQL_MODE = 'NO_ENGINE_SUBSTITUTION';
+
+/**
+ * Loads a `.sql` file into a MySQL database by streaming the file on the client's
+ * stdin. The dump is typically hundreds of MB, so it must be piped rather than
+ * passed via `--execute`. Runs against the provisioned per-site database.
+ */
+export async function importSqlFileIntoMysql(
+ config: MysqlSiteConfig,
+ sqlFilePath: string,
+ options: { user?: string; password?: string; database?: string; timeoutMs?: number } = {}
+): Promise< void > {
+ if ( ! fs.existsSync( sqlFilePath ) ) {
+ throw new Error( `SQL file not found for MySQL import: ${ sqlFilePath }` );
+ }
+
+ const database = options.database ?? config.databaseName;
+ const args = [
+ '--protocol=tcp',
+ `--host=${ config.host }`,
+ `--port=${ config.port }`,
+ `--user=${ options.user ?? 'root' }`,
+ ...( options.password !== undefined ? [ `--password=${ options.password }` ] : [] ),
+ `--init-command=SET SESSION sql_mode='${ MYSQL_IMPORT_SQL_MODE }'`,
+ database,
+ ];
+
+ await runMysqlCommand( getMysqlClientBinaryPath( config.serverVersion ), args, {
+ stdinFile: sqlFilePath,
+ timeoutMs: options.timeoutMs ?? MYSQL_IMPORT_TIMEOUT_MS,
+ } );
+}
+
+export async function canConnectToMysql( config: MysqlSiteConfig ): Promise< boolean > {
+ const result = await runMysqlCommand(
+ getMysqlAdminBinaryPath( config.serverVersion ),
+ [
+ '--protocol=tcp',
+ `--host=${ config.host }`,
+ `--port=${ config.port }`,
+ '--user=root',
+ 'ping',
+ ],
+ { rejectOnExitCode: false, timeoutMs: 2_000 }
+ ).catch( () => undefined );
+ return result?.code === 0;
+}
+
+async function initializeDataDir( config: MysqlSiteConfig, signal?: AbortSignal ): Promise< void > {
+ if ( fs.existsSync( path.join( config.dataDir, 'mysql' ) ) ) {
+ return;
+ }
+
+ fs.mkdirSync( config.dataDir, { recursive: true } );
+ await runMysqlCommand(
+ getMysqlServerBinaryPath( config.serverVersion ),
+ [
+ '--no-defaults',
+ '--initialize-insecure',
+ `--basedir=${ getMysqlInstallRootFromVersion( config.serverVersion ) }`,
+ `--datadir=${ config.dataDir }`,
+ ],
+ { signal, timeoutMs: MYSQL_START_TIMEOUT_MS }
+ );
+}
+
+async function waitForMysqlReady(
+ config: MysqlSiteConfig,
+ child: ChildProcess,
+ signal?: AbortSignal
+): Promise< void > {
+ const deadline = Date.now() + MYSQL_START_TIMEOUT_MS;
+ while ( Date.now() < deadline ) {
+ signal?.throwIfAborted();
+ if ( child.exitCode !== null || child.signalCode !== null ) {
+ throw new Error( `mysqld exited before becoming ready` );
+ }
+ if ( await canConnectToMysql( config ) ) {
+ return;
+ }
+ await new Promise( ( resolve ) => setTimeout( resolve, MYSQL_POLL_INTERVAL_MS ) );
+ }
+ throw new Error( `Timed out waiting for mysqld on ${ config.host }:${ config.port }` );
+}
+
+async function stopMysqlChild( config: MysqlSiteConfig, child: ChildProcess ): Promise< void > {
+ if ( child.exitCode !== null || child.signalCode !== null ) {
+ runningServers.delete( config.dataDir );
+ return;
+ }
+
+ await runMysqlCommand(
+ getMysqlAdminBinaryPath( config.serverVersion ),
+ [
+ '--protocol=tcp',
+ `--host=${ config.host }`,
+ `--port=${ config.port }`,
+ '--user=root',
+ 'shutdown',
+ ],
+ { rejectOnExitCode: false, timeoutMs: MYSQL_STOP_TIMEOUT_MS }
+ ).catch( () => undefined );
+
+ await waitForExitOrKill( child, MYSQL_STOP_TIMEOUT_MS );
+ runningServers.delete( config.dataDir );
+}
+
+async function waitForExitOrKill( child: ChildProcess, timeoutMs: number ): Promise< void > {
+ if ( child.exitCode !== null || child.signalCode !== null ) {
+ return;
+ }
+
+ await new Promise< void >( ( resolve ) => {
+ const timeout = setTimeout( () => {
+ child.kill( 'SIGKILL' );
+ resolve();
+ }, timeoutMs );
+ child.once( 'exit', () => {
+ clearTimeout( timeout );
+ resolve();
+ } );
+ } );
+}
+
+async function runMysqlCommand(
+ command: string,
+ args: string[],
+ options: {
+ rejectOnExitCode?: boolean;
+ signal?: AbortSignal;
+ timeoutMs?: number;
+ stdinFile?: string;
+ } = {}
+): Promise< { stdout: string; stderr: string; code: number } > {
+ const {
+ rejectOnExitCode = true,
+ signal,
+ timeoutMs = MYSQL_COMMAND_TIMEOUT_MS,
+ stdinFile,
+ } = options;
+
+ return await new Promise< { stdout: string; stderr: string; code: number } >(
+ ( resolve, reject ) => {
+ const child = spawn( command, args, {
+ stdio: [ stdinFile ? 'pipe' : 'ignore', 'pipe', 'pipe' ],
+ signal,
+ } );
+ let stdout = '';
+ let stderr = '';
+ let settled = false;
+ const timeout = setTimeout( () => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ child.kill( 'SIGKILL' );
+ reject( new Error( `MySQL command timed out: ${ command }` ) );
+ }, timeoutMs );
+
+ if ( stdinFile && child.stdin ) {
+ const readStream = fs.createReadStream( stdinFile );
+ readStream.on( 'error', ( error ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timeout );
+ if ( ! child.killed ) {
+ child.kill( 'SIGKILL' );
+ }
+ reject( error );
+ } );
+ readStream.pipe( child.stdin );
+ }
+
+ child.stdout?.on( 'data', ( chunk ) => {
+ stdout += chunk.toString();
+ } );
+ child.stderr?.on( 'data', ( chunk ) => {
+ stderr += chunk.toString();
+ } );
+ child.once( 'error', ( error ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timeout );
+ reject( error );
+ } );
+ child.once( 'close', ( code ) => {
+ if ( settled ) {
+ return;
+ }
+ settled = true;
+ clearTimeout( timeout );
+ const exitCode = code ?? 1;
+ if ( rejectOnExitCode && exitCode !== 0 ) {
+ reject(
+ new Error(
+ `MySQL command failed (code: ${ exitCode }): ${
+ stderr.trim() || stdout.trim() || command
+ }`
+ )
+ );
+ return;
+ }
+ resolve( { stdout, stderr, code: exitCode } );
+ } );
+ }
+ );
+}
+
+function noopServer(): ManagedMysqlServer {
+ return {
+ started: false,
+ stop: async () => undefined,
+ };
+}
+
+function getRuntimeDir( config: MysqlSiteConfig ): string {
+ const safeName = path.basename( config.dataDir ).replace( /[^a-zA-Z0-9_.-]/g, '-' );
+ const shortName = safeName.slice( 0, 8 );
+ const tmpDir = process.platform === 'win32' ? os.tmpdir() : '/tmp';
+ return path.join( tmpDir, `studio-mysql-${ shortName }-${ config.port }` );
+}
+
+function getMysqlInstallRootFromVersion( version: string ): string {
+ return path.dirname( path.dirname( getMysqlServerBinaryPath( version ) ) );
+}
diff --git a/apps/cli/lib/mysql/mysql-site.ts b/apps/cli/lib/mysql/mysql-site.ts
new file mode 100644
index 0000000000..77f485accf
--- /dev/null
+++ b/apps/cli/lib/mysql/mysql-site.ts
@@ -0,0 +1,234 @@
+import crypto from 'crypto';
+import fs from 'fs';
+import path from 'path';
+import { DATABASE_ENGINE_MYSQL, type MysqlSiteConfig } from '@studio/common/lib/database-engine';
+import { getConfiguredMysqlBinaryVersion } from '@studio/common/lib/mysql-binary-metadata';
+import { decodePassword, encodePassword } from '@studio/common/lib/passwords';
+import { getMysqlDataRoot } from 'cli/lib/dependency-management/paths';
+import { runMysqlQuery } from './mysql-process';
+import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc';
+
+type MysqlProvisionMarker = {
+ databaseName: string;
+ username: string;
+ serverVersion: string;
+};
+
+const MYSQL_IDENTIFIER_MAX_LENGTH = 64;
+const MYSQL_USERNAME_MAX_LENGTH = 32;
+
+export function createMysqlSiteConfig( siteId: string, port: number ): MysqlSiteConfig {
+ const token = getSiteToken( siteId );
+ const serverVersion = getConfiguredMysqlBinaryVersion();
+ if ( ! serverVersion ) {
+ throw new Error( 'No managed MySQL server version is configured.' );
+ }
+
+ return {
+ host: '127.0.0.1',
+ port,
+ databaseName: trimIdentifier( `studio_${ token }`, MYSQL_IDENTIFIER_MAX_LENGTH ),
+ username: trimIdentifier( `stu_${ token }`, MYSQL_USERNAME_MAX_LENGTH ),
+ password: encodePassword( crypto.randomBytes( 24 ).toString( 'base64url' ) ),
+ serverVersion,
+ dataDir: path.join( getMysqlDataRoot(), siteId ),
+ };
+}
+
+export function getMysqlWpConfigConstants( config: MysqlSiteConfig ): Record< string, string > {
+ return {
+ DB_NAME: config.databaseName,
+ DB_USER: config.username,
+ DB_PASSWORD: decodePassword( config.password ),
+ DB_HOST: `${ config.host }:${ config.port }`,
+ };
+}
+
+export function getMysqlConfigFromServerConfig(
+ config: ServerConfig
+): MysqlSiteConfig | undefined {
+ if ( config.databaseEngine !== DATABASE_ENGINE_MYSQL ) {
+ return undefined;
+ }
+ if ( ! config.mysql ) {
+ throw new Error( 'MySQL site is missing database configuration.' );
+ }
+ return config.mysql;
+}
+
+export async function prepareMysqlSite(
+ config: MysqlSiteConfig,
+ sitePath: string
+): Promise< void > {
+ await removeSqliteIntegrationForMysql( sitePath );
+ await provisionMysqlDatabase( config );
+}
+
+// Default collation for freshly created databases. The `studio create` path
+// uses this. The SQLite→MySQL convert path overrides it to match the collation
+// the export driver emits per-table (utf8mb4_0900_ai_ci), so the database
+// default and the imported tables agree instead of silently diverging.
+const DEFAULT_DATABASE_COLLATION = 'utf8mb4_unicode_ci';
+
+export async function provisionMysqlDatabase(
+ config: MysqlSiteConfig,
+ options: { collation?: string } = {}
+): Promise< void > {
+ const collation = options.collation ?? DEFAULT_DATABASE_COLLATION;
+ assertSafeCollation( collation );
+ const markerPath = getProvisionMarkerPath( config );
+ const hasMarker = fs.existsSync( markerPath );
+ const databaseExists = await mysqlDatabaseExists( config );
+ const userExists = await mysqlUserExists( config );
+
+ if ( ! hasMarker && ( databaseExists || userExists ) ) {
+ throw new Error(
+ `Refusing to attach MySQL site to existing database/user without Studio marker: ${ config.databaseName }`
+ );
+ }
+
+ if ( ! hasMarker ) {
+ await runMysqlQuery(
+ config,
+ [
+ `CREATE DATABASE ${ sqlIdentifier(
+ config.databaseName
+ ) } CHARACTER SET utf8mb4 COLLATE ${ collation }`,
+ ...createUserStatements( config, false ),
+ ...grantStatements( config ),
+ 'FLUSH PRIVILEGES',
+ ].join( ';\n' ) + ';'
+ );
+ await writeProvisionMarker( config, markerPath );
+ return;
+ }
+
+ if ( ! databaseExists ) {
+ await runMysqlQuery(
+ config,
+ `CREATE DATABASE ${ sqlIdentifier(
+ config.databaseName
+ ) } CHARACTER SET utf8mb4 COLLATE ${ collation };`
+ );
+ }
+
+ if ( ! userExists ) {
+ await runMysqlQuery( config, createUserStatements( config, true ).join( ';\n' ) + ';' );
+ }
+
+ await runMysqlQuery(
+ config,
+ [ ...grantStatements( config ), 'FLUSH PRIVILEGES' ].join( ';\n' ) + ';'
+ );
+}
+
+export async function removeSqliteIntegrationForMysql( sitePath: string ): Promise< void > {
+ const dbPhpPath = path.join( sitePath, 'wp-content', 'db.php' );
+ if ( fs.existsSync( dbPhpPath ) ) {
+ const content = await fs.promises.readFile( dbPhpPath, 'utf8' );
+ if ( content.includes( '@studio-keep' ) ) {
+ throw new Error( 'Cannot use MySQL while wp-content/db.php is marked @studio-keep.' );
+ }
+ if (
+ ! content.includes( 'sqlite-database-integration' ) &&
+ ! content.includes( 'SQLITE_DB_DROPIN_VERSION' )
+ ) {
+ throw new Error( 'Cannot use MySQL with an unknown wp-content/db.php drop-in.' );
+ }
+ await fs.promises.rm( dbPhpPath, { force: true } );
+ }
+
+ await fs.promises.rm(
+ path.join( sitePath, 'wp-content', 'mu-plugins', 'sqlite-database-integration' ),
+ {
+ recursive: true,
+ force: true,
+ }
+ );
+}
+
+async function mysqlDatabaseExists( config: MysqlSiteConfig ): Promise< boolean > {
+ const stdout = await runMysqlQuery(
+ config,
+ `SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = ${ sqlLiteral(
+ config.databaseName
+ ) };`
+ );
+ return Number( stdout.trim() ) > 0;
+}
+
+async function mysqlUserExists( config: MysqlSiteConfig ): Promise< boolean > {
+ const stdout = await runMysqlQuery(
+ config,
+ `SELECT COUNT(*) FROM mysql.user WHERE User = ${ sqlLiteral(
+ config.username
+ ) } AND Host IN ('localhost', '127.0.0.1');`
+ );
+ return Number( stdout.trim() ) > 0;
+}
+
+function createUserStatements( config: MysqlSiteConfig, ifNotExists: boolean ): string[] {
+ const password = decodePassword( config.password );
+ const existsClause = ifNotExists ? ' IF NOT EXISTS' : '';
+ return [ 'localhost', '127.0.0.1' ].map(
+ ( host ) =>
+ `CREATE USER${ existsClause } ${ sqlUser(
+ config.username,
+ host
+ ) } IDENTIFIED BY ${ sqlLiteral( password ) }`
+ );
+}
+
+function grantStatements( config: MysqlSiteConfig ): string[] {
+ return [ 'localhost', '127.0.0.1' ].map(
+ ( host ) =>
+ `GRANT ALL PRIVILEGES ON ${ sqlIdentifier( config.databaseName ) }.* TO ${ sqlUser(
+ config.username,
+ host
+ ) }`
+ );
+}
+
+function writeProvisionMarker( config: MysqlSiteConfig, markerPath: string ): Promise< void > {
+ const marker: MysqlProvisionMarker = {
+ databaseName: config.databaseName,
+ username: config.username,
+ serverVersion: config.serverVersion,
+ };
+ fs.mkdirSync( path.dirname( markerPath ), { recursive: true } );
+ return fs.promises.writeFile( markerPath, JSON.stringify( marker, null, 2 ) + '\n', 'utf8' );
+}
+
+function getProvisionMarkerPath( config: MysqlSiteConfig ): string {
+ return path.join( config.dataDir, '.studio-mysql-provisioned.json' );
+}
+
+function getSiteToken( siteId: string ): string {
+ const normalized = siteId.replace( /[^a-zA-Z0-9]/g, '' ).toLowerCase();
+ return normalized.slice( 0, 24 ) || crypto.randomBytes( 12 ).toString( 'hex' );
+}
+
+function trimIdentifier( value: string, maxLength: number ): string {
+ return value.slice( 0, maxLength );
+}
+
+function assertSafeCollation( value: string ): void {
+ if ( ! /^[a-zA-Z0-9_]+$/.test( value ) ) {
+ throw new Error( `Invalid MySQL collation: ${ value }` );
+ }
+}
+
+function sqlIdentifier( value: string ): string {
+ if ( ! /^[a-zA-Z0-9_]+$/.test( value ) ) {
+ throw new Error( `Invalid MySQL identifier: ${ value }` );
+ }
+ return `\`${ value }\``;
+}
+
+function sqlLiteral( value: string ): string {
+ return `'${ value.replace( /\\/g, '\\\\' ).replace( /'/g, "\\'" ) }'`;
+}
+
+function sqlUser( username: string, host: string ): string {
+ return `${ sqlLiteral( username ) }@${ sqlLiteral( host ) }`;
+}
diff --git a/apps/cli/lib/native-php/blueprints.ts b/apps/cli/lib/native-php/blueprints.ts
index f6c36dfb85..2a66de4a30 100644
--- a/apps/cli/lib/native-php/blueprints.ts
+++ b/apps/cli/lib/native-php/blueprints.ts
@@ -5,7 +5,12 @@ import {
removeBlueprintTempDir,
} from '@studio/common/lib/blueprint-bundle';
import { getBlueprintsPharPath, getPhpBinaryPath } from 'cli/lib/dependency-management/paths';
+import {
+ getMysqlConfigFromServerConfig,
+ getMysqlWpConfigConstants,
+} from 'cli/lib/mysql/mysql-site';
import { runPhpCommand } from './php-process';
+import type { MysqlSiteConfig } from '@studio/common/lib/database-engine';
import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata';
import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc';
@@ -14,6 +19,21 @@ function isWriteAccessError( error: unknown ): boolean {
return code === 'EACCES' || code === 'EPERM' || code === 'EROFS';
}
+function getBlueprintDatabaseArgs( mysqlConfig: MysqlSiteConfig | undefined ): string[] {
+ if ( ! mysqlConfig ) {
+ return [ '--db-engine=sqlite' ];
+ }
+
+ const constants = getMysqlWpConfigConstants( mysqlConfig );
+ return [
+ '--db-engine=mysql',
+ `--db-host=${ constants.DB_HOST }`,
+ `--db-user=${ constants.DB_USER }`,
+ `--db-pass=${ constants.DB_PASSWORD }`,
+ `--db-name=${ constants.DB_NAME }`,
+ ];
+}
+
export async function runBlueprint(
config: ServerConfig,
blueprint: NonNullable< ServerConfig[ 'blueprint' ] >,
@@ -29,9 +49,14 @@ export async function runBlueprint(
const enableDebugLog = config.enableDebugLog ?? false;
const enableDebugDisplay = config.enableDebugDisplay ?? false;
+ const mysqlConfig = getMysqlConfigFromServerConfig( config );
const defaultConstants: Record< string, boolean | string > = {
- // The SQLite driver requires a non-empty DB_NAME at runtime.
- DB_NAME: 'wordpress',
+ ...( mysqlConfig
+ ? getMysqlWpConfigConstants( mysqlConfig )
+ : {
+ // The SQLite driver requires a non-empty DB_NAME at runtime.
+ DB_NAME: 'wordpress',
+ } ),
WP_DEBUG: enableDebugLog || enableDebugDisplay,
WP_DEBUG_LOG: enableDebugLog,
WP_DEBUG_DISPLAY: enableDebugDisplay,
@@ -82,7 +107,8 @@ export async function runBlueprint(
'plugins',
'sqlite-database-integration'
);
- const needsSymlink = fs.existsSync( muPluginsSqlite ) && ! fs.existsSync( pluginsSqlite );
+ const needsSymlink =
+ ! mysqlConfig && fs.existsSync( muPluginsSqlite ) && ! fs.existsSync( pluginsSqlite );
let symlinkIno: number | undefined;
if ( needsSymlink ) {
fs.symlinkSync( muPluginsSqlite, pluginsSqlite, 'junction' );
@@ -99,7 +125,7 @@ export async function runBlueprint(
'--mode=apply-to-existing-site',
`--site-path=${ config.sitePath }`,
`--site-url=${ config.absoluteUrl ?? `http://localhost:${ config.port }` }`,
- '--db-engine=sqlite',
+ ...getBlueprintDatabaseArgs( mysqlConfig ),
],
{
phpVersion,
diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts
index 3d9621cf76..9fc3679826 100644
--- a/apps/cli/lib/native-php/php-process.ts
+++ b/apps/cli/lib/native-php/php-process.ts
@@ -168,17 +168,13 @@ export async function runPhpCommand(
let stdout = '';
phpScriptProcess.stdout?.on( 'data', ( chunk ) => {
reportActivity();
- if ( options.mode === 'capture' ) {
- stdout += chunk.toString();
- }
+ stdout += chunk.toString();
} );
let stderr = '';
phpScriptProcess.stderr?.on( 'data', ( chunk ) => {
reportActivity();
- if ( options.mode === 'capture' ) {
- stderr += chunk.toString();
- }
+ stderr += chunk.toString();
} );
phpScriptProcess.once( 'error', ( error: Error ) => {
@@ -190,7 +186,10 @@ export async function runPhpCommand(
return;
}
- reject( new Error( `PHP command failed (code: ${ code })` ) );
+ const output = [ stderr.trim(), stdout.trim() ].filter( Boolean ).join( '\n' );
+ reject(
+ new Error( `PHP command failed (code: ${ code })${ output ? `:\n${ output }` : '' }` )
+ );
} );
} );
}
diff --git a/apps/cli/lib/native-php/site-setup.ts b/apps/cli/lib/native-php/site-setup.ts
index 9337c193f0..d58b0f3305 100644
--- a/apps/cli/lib/native-php/site-setup.ts
+++ b/apps/cli/lib/native-php/site-setup.ts
@@ -5,6 +5,10 @@ import { DEFAULT_LOCALE } from '@studio/common/lib/locale';
import { escapePhpSingleQuotedString } from '@studio/common/lib/mu-plugins';
import { decodePassword } from '@studio/common/lib/passwords';
import { getWpCliPharPath } from 'cli/lib/dependency-management/paths';
+import {
+ getMysqlConfigFromServerConfig,
+ getMysqlWpConfigConstants,
+} from 'cli/lib/mysql/mysql-site';
import { runPhpCommand } from './php-process';
import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata';
import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc';
@@ -18,7 +22,10 @@ export async function ensureWpConfig(
phpVersion: NativePhpSupportedVersion,
signal: AbortSignal,
wpConfigTransformerPath: string,
- config?: Pick< ServerConfig, 'enableDebugLog' | 'enableDebugDisplay' >
+ config?: Pick<
+ ServerConfig,
+ 'enableDebugLog' | 'enableDebugDisplay' | 'databaseEngine' | 'mysql'
+ >
): Promise< void > {
const wpConfigPath = path.join( siteFolder, 'wp-config.php' );
const wpConfigSamplePath = path.join( siteFolder, 'wp-config-sample.php' );
@@ -40,8 +47,26 @@ $transformer->to_file( $wp_config_path );
const enableDebugLog = config?.enableDebugLog ?? false;
const enableDebugDisplay = config?.enableDebugDisplay ?? false;
+ const mysqlConfig = config ? getMysqlConfigFromServerConfig( config as ServerConfig ) : undefined;
+
+ // Guard against silently clobbering a real database config. If we're about to
+ // write the SQLite default (DB_NAME='wordpress') over a wp-config.php that
+ // already points at a different database, the engine flag was dropped
+ // somewhere upstream and writing the default would sever a live MySQL site
+ // from its data. Fail loud instead of corrupting the config.
+ if ( ! mysqlConfig && fs.existsSync( wpConfigPath ) ) {
+ const existingDbName = readDefinedDbName( wpConfigPath );
+ if ( existingDbName && existingDbName !== DEFAULT_WP_CONFIG_CONSTANTS.DB_NAME ) {
+ throw new Error(
+ `Refusing to reset wp-config.php DB_NAME to '${ DEFAULT_WP_CONFIG_CONSTANTS.DB_NAME }' ` +
+ `over an existing '${ existingDbName }'. The site's database engine config was not ` +
+ `provided, which would sever the site from its database.`
+ );
+ }
+ }
+
const constants = {
- ...DEFAULT_WP_CONFIG_CONSTANTS,
+ ...( mysqlConfig ? getMysqlWpConfigConstants( mysqlConfig ) : DEFAULT_WP_CONFIG_CONSTANTS ),
WP_DEBUG: enableDebugLog || enableDebugDisplay,
WP_DEBUG_LOG: enableDebugLog,
WP_DEBUG_DISPLAY: enableDebugDisplay,
@@ -67,6 +92,20 @@ $transformer->to_file( $wp_config_path );
}
}
+// Reads the DB_NAME currently defined in a wp-config.php, or undefined if the
+// file has no DB_NAME define yet. Used only to detect the clobber case above;
+// a null result is treated as "safe to write the default".
+function readDefinedDbName( wpConfigPath: string ): string | undefined {
+ let contents: string;
+ try {
+ contents = fs.readFileSync( wpConfigPath, 'utf8' );
+ } catch {
+ return undefined;
+ }
+ const match = contents.match( /define\(\s*(['"])DB_NAME\1\s*,\s*(['"])([^'"]*)\2\s*\)/ );
+ return match ? match[ 3 ] : undefined;
+}
+
export function getSiteUrlPrependContent(
siteUrl: string,
originalAutoPrependFile?: string
diff --git a/apps/cli/lib/native-php/tests/blueprints.test.ts b/apps/cli/lib/native-php/tests/blueprints.test.ts
new file mode 100644
index 0000000000..8a9c1687c2
--- /dev/null
+++ b/apps/cli/lib/native-php/tests/blueprints.test.ts
@@ -0,0 +1,75 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { DATABASE_ENGINE_MYSQL } from '@studio/common/lib/database-engine';
+import { encodePassword } from '@studio/common/lib/passwords';
+import { vi } from 'vitest';
+import { runBlueprint } from 'cli/lib/native-php/blueprints';
+import { runPhpCommand } from 'cli/lib/native-php/php-process';
+
+vi.mock( '@studio/common/lib/blueprint-bundle', () => ( {
+ createBlueprintTempDir: vi.fn(),
+ removeBlueprintTempDir: vi.fn(),
+} ) );
+
+vi.mock( 'cli/lib/dependency-management/paths', () => ( {
+ getBlueprintsPharPath: vi.fn( () => '/server-files/blueprints.phar' ),
+ getPhpBinaryPath: vi.fn( () => '/server-files/php/bin/php' ),
+} ) );
+
+vi.mock( 'cli/lib/native-php/php-process', () => ( {
+ runPhpCommand: vi.fn().mockResolvedValue( undefined ),
+} ) );
+
+describe( 'native PHP blueprints', () => {
+ let tempDir: string;
+
+ beforeEach( () => {
+ tempDir = fs.mkdtempSync( path.join( os.tmpdir(), 'studio-blueprints-test-' ) );
+ vi.clearAllMocks();
+ } );
+
+ afterEach( () => {
+ fs.rmSync( tempDir, { recursive: true, force: true } );
+ } );
+
+ it( 'passes generated MySQL connection details to blueprints.phar', async () => {
+ const blueprintPath = path.join( tempDir, 'blueprint.json' );
+ fs.writeFileSync( blueprintPath, JSON.stringify( { steps: [] } ) );
+
+ await runBlueprint(
+ {
+ siteId: 'test-site',
+ sitePath: path.join( tempDir, 'site' ),
+ port: 8881,
+ databaseEngine: DATABASE_ENGINE_MYSQL,
+ mysql: {
+ host: '127.0.0.1',
+ port: 8890,
+ databaseName: 'studio_testsite',
+ username: 'stu_testsite',
+ password: encodePassword( 'mysql-password' ),
+ serverVersion: '8.4.10',
+ dataDir: path.join( tempDir, 'mysql' ),
+ },
+ },
+ {
+ uri: blueprintPath,
+ contents: { steps: [] },
+ },
+ '8.4',
+ new AbortController().signal
+ );
+
+ expect( runPhpCommand ).toHaveBeenCalledWith(
+ expect.arrayContaining( [
+ '--db-engine=mysql',
+ '--db-host=127.0.0.1:8890',
+ '--db-user=stu_testsite',
+ '--db-pass=mysql-password',
+ '--db-name=studio_testsite',
+ ] ),
+ expect.any( Object )
+ );
+ } );
+} );
diff --git a/apps/cli/lib/native-php/tests/site-setup.test.ts b/apps/cli/lib/native-php/tests/site-setup.test.ts
new file mode 100644
index 0000000000..b9cbf20481
--- /dev/null
+++ b/apps/cli/lib/native-php/tests/site-setup.test.ts
@@ -0,0 +1,74 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { ensureWpConfig } from '../site-setup';
+
+// runPhpCommand is only reached on the healthy paths; the clobber guard throws
+// before it. Mock it so "healthy path" tests don't need a real PHP binary and
+// so we can assert whether the write was attempted at all.
+const { runPhpCommand } = vi.hoisted( () => ( {
+ runPhpCommand: vi.fn( async () => undefined ),
+} ) );
+vi.mock( '../php-process', () => ( {
+ runPhpCommand,
+} ) );
+
+const PHP_VERSION = '8.3' as Parameters< typeof ensureWpConfig >[ 1 ];
+const TRANSFORMER = '/does/not/matter/wp-config-transformer.php';
+
+function writeWpConfig( dir: string, dbName: string ): string {
+ const file = path.join( dir, 'wp-config.php' );
+ fs.writeFileSync( file, ` {
+ let tmpDir: string;
+
+ beforeEach( () => {
+ runPhpCommand.mockClear();
+ tmpDir = fs.mkdtempSync( path.join( os.tmpdir(), 'site-setup-test-' ) );
+ } );
+
+ afterEach( () => {
+ fs.rmSync( tmpDir, { recursive: true, force: true } );
+ } );
+
+ it( 'refuses to overwrite a non-default DB_NAME when the engine config is missing', async () => {
+ // This is the real corruption case: a converted MySQL site whose engine
+ // flag was dropped upstream. Writing the SQLite default would sever it
+ // from its database, so ensureWpConfig must throw before writing.
+ writeWpConfig( tmpDir, 'studio_abc123' );
+
+ await expect(
+ ensureWpConfig( tmpDir, PHP_VERSION, new AbortController().signal, TRANSFORMER )
+ ).rejects.toThrow( /studio_abc123/ );
+
+ expect( runPhpCommand ).not.toHaveBeenCalled();
+ } );
+
+ it( 'writes normally when the existing DB_NAME is already the default', async () => {
+ // A fresh SQLite site already carries DB_NAME='wordpress'; re-running the
+ // default write is a no-op and must not trip the guard.
+ writeWpConfig( tmpDir, 'wordpress' );
+
+ await expect(
+ ensureWpConfig( tmpDir, PHP_VERSION, new AbortController().signal, TRANSFORMER )
+ ).resolves.toBeUndefined();
+
+ expect( runPhpCommand ).toHaveBeenCalledOnce();
+ } );
+
+ it( 'writes normally when no wp-config.php exists yet', async () => {
+ // First-run sites have no wp-config.php (only the sample); there is
+ // nothing to clobber.
+ fs.writeFileSync( path.join( tmpDir, 'wp-config-sample.php' ), ' {
- console.error( '[Proxy Error]', err.message );
- if ( res && res instanceof http.ServerResponse ) {
- res.writeHead( 502, { 'Content-Type': 'text/plain' } );
- res.end( 'Proxy error: ' + err.message );
- }
-} );
-
function logProxyBindError( err: NodeJS.ErrnoException, port: number ): void {
console.error( `[Proxy] Error starting ${ port === 443 ? 'HTTPS' : 'HTTP' } server:`, err );
@@ -106,17 +94,97 @@ async function handleProxyRequest(
return;
}
- const headers: Record< string, string > = {};
+ forwardToSite( req, res, site.port, isHttps );
+}
- if ( isHttps ) {
- headers[ 'X-Forwarded-Proto' ] = 'https';
- }
+/**
+ * Forwards a request to a site's local HTTP port, decoupling the UPSTREAM
+ * request's lifetime from the client connection.
+ *
+ * We do the forward with a raw `http.request` rather than `http-proxy`'s
+ * `proxy.web()` because `http-proxy` tears down the upstream request when the
+ * client aborts. That breaks fire-and-forget loopback requests, which WordPress
+ * async fanout depends on: WordPress dispatches Action Scheduler's async queue
+ * runner with a ~0.01s timeout and a non-blocking transport, so the client
+ * disconnects almost immediately by design and the PHP worker is meant to keep
+ * running (claim + execute a queued branch). If the client abort propagated to
+ * the upstream, the worker request would be killed before it did any work and
+ * the branch would never run.
+ *
+ * So: forward the request, but on a client abort DO NOT destroy the upstream —
+ * finish sending the request and let the worker run to completion. When the
+ * client is still connected we write the response back as usual; when it has
+ * gone we simply drain the upstream response so the request completes cleanly.
+ */
+function forwardToSite(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ sitePort: number,
+ isHttps: boolean
+): void {
+ const headers = { ...req.headers };
+ // Mirror `xfwd: true` (the x-forwarded-* headers http-proxy set for us) plus
+ // the explicit X-Forwarded-Proto we passed through before.
+ const remoteAddress = req.socket.remoteAddress ?? '';
+ headers[ 'x-forwarded-for' ] = headers[ 'x-forwarded-for' ]
+ ? `${ headers[ 'x-forwarded-for' ] }, ${ remoteAddress }`
+ : remoteAddress;
+ headers[ 'x-forwarded-port' ] = String( isHttps ? 443 : 80 );
+ headers[ 'x-forwarded-proto' ] = isHttps ? 'https' : 'http';
+ headers[ 'x-forwarded-host' ] = headers.host ?? '';
+ // Let the upstream keep-alive/length be recomputed by the target.
+ delete headers.connection;
+ delete headers[ 'proxy-connection' ];
+
+ const upstream = http.request(
+ {
+ hostname: 'localhost',
+ port: sitePort,
+ path: req.url,
+ method: req.method,
+ headers,
+ },
+ ( upstreamRes ) => {
+ if ( ! res.writableEnded && ! res.destroyed ) {
+ res.writeHead( upstreamRes.statusCode ?? 502, upstreamRes.headers );
+ upstreamRes.pipe( res );
+ } else {
+ // Client already gone (fire-and-forget): drain the response so the
+ // upstream request finishes instead of stalling on backpressure.
+ upstreamRes.resume();
+ }
+ }
+ );
- proxy.web( req, res, {
- target: `http://localhost:${ site.port }`,
- xfwd: true, // Pass along x-forwarded headers
- headers,
+ upstream.on( 'error', ( err ) => {
+ console.error( '[Proxy] Upstream error:', err.message );
+ if ( ! res.headersSent && ! res.writableEnded && ! res.destroyed ) {
+ res.writeHead( 502, { 'Content-Type': 'text/plain' } );
+ res.end( 'Proxy error: ' + err.message );
+ }
+ } );
+
+ // Forward the request body manually. A client abort must NOT destroy the
+ // upstream — finish the request to the worker so it runs to completion. This
+ // keeps a fire-and-forget loopback (client disconnects immediately by design)
+ // from killing the worker request it just kicked off.
+ const finishUpstream = () => {
+ if ( ! upstream.writableEnded ) {
+ upstream.end();
+ }
+ };
+ req.on( 'data', ( chunk ) => {
+ if ( ! upstream.writableEnded ) {
+ upstream.write( chunk );
+ }
} );
+ req.on( 'end', finishUpstream );
+ // A fire-and-forget client aborts without a normal `end`. On abort/close/error,
+ // still finish sending the (already-received) request to the worker so the
+ // upstream runs to completion — do NOT let the client's disconnect tear it down.
+ req.on( 'aborted', finishUpstream );
+ req.on( 'close', finishUpstream );
+ req.on( 'error', finishUpstream );
}
/**
diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts
index 92f15a1cfa..16b8e6736d 100644
--- a/apps/cli/lib/run-wp-cli-command.ts
+++ b/apps/cli/lib/run-wp-cli-command.ts
@@ -12,6 +12,7 @@ import {
} from '@php-wasm/universal';
import { createSpawnHandler } from '@php-wasm/util';
import { DEFAULT_PHP_VERSION } from '@studio/common/constants';
+import { isMysqlSite } from '@studio/common/lib/database-engine';
import { IS_JSPI_AVAILABLE } from '@studio/common/lib/jspi';
import {
cleanupLegacyMuPlugins,
@@ -27,12 +28,14 @@ import {
import { __ } from '@wordpress/i18n';
import { setupPlatformLevelMuPlugins } from '@wp-playground/wordpress';
import {
+ getMysqlClientBinaryDir,
getPhpBinaryPath,
getSqliteCommandPath,
getWpCliPharPath,
} from 'cli/lib/dependency-management/paths';
import { validatePhpVersion } from 'cli/lib/utils';
import { ensurePhpBinaryAvailable } from './dependency-management/php-binary';
+import { ensureMysqlServerRunning, type ManagedMysqlServer } from './mysql/mysql-process';
import { getDefaultPhpArgs } from './native-php/config';
import {
DETACH_FOR_GROUP_KILL,
@@ -160,6 +163,25 @@ async function ensureChildSpawned( child: ChildProcess ): Promise< void > {
} );
}
+// Builds the environment for a native WP-CLI child. For MySQL-engine sites the
+// bundled MySQL client's `bin/` dir is prepended to PATH: `wp db *` subcommands
+// (query/export/import/…) shell out to a bare `mysql`/`mysqldump` resolved via
+// PATH, and Studio ships that client without installing it globally, so without
+// this WP-CLI fails with `env: mysql: No such file or directory`. SQLite sites
+// need no MySQL client, so their env is left untouched.
+function buildWpCliChildEnv( site: SiteData ): NodeJS.ProcessEnv {
+ if ( ! isMysqlSite( site ) || ! site.mysql ) {
+ return process.env;
+ }
+
+ const mysqlClientDir = getMysqlClientBinaryDir( site.mysql.serverVersion );
+ const currentPath = process.env.PATH ?? '';
+ return {
+ ...process.env,
+ PATH: currentPath ? `${ mysqlClientDir }${ path.delimiter }${ currentPath }` : mysqlClientDir,
+ };
+}
+
type DisposableWpCliResponse = Disposable & {
response: WpCliResponse;
};
@@ -185,7 +207,17 @@ async function runNativeWpCliCommand(
): Promise< DisposableWpCliResponse | DisposableExitCode > {
const phpVersion = resolveNativePhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION );
await ensurePhpBinaryAvailable( phpVersion );
- await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating );
+ await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating, {
+ siteHost: site.customDomain,
+ sitePort: site.port,
+ } );
+ let mysqlServer: ManagedMysqlServer | null = null;
+ if ( isMysqlSite( site ) ) {
+ if ( ! site.mysql ) {
+ throw new Error( 'MySQL site is missing database configuration.' );
+ }
+ mysqlServer = await ensureMysqlServerRunning( site.mysql );
+ }
// Reprint-pulled sites wire SQLite through runtime.php (loaded as auto_prepend_file),
// so load it here too. No-op for normal sites (helper returns undefined).
@@ -200,12 +232,18 @@ async function runNativeWpCliCommand(
[ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...nativeArgs ],
{
cwd: site.path,
+ env: buildWpCliChildEnv( site ),
stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ],
detached: DETACH_FOR_GROUP_KILL,
}
);
- await ensureChildSpawned( child );
+ try {
+ await ensureChildSpawned( child );
+ } catch ( error ) {
+ await mysqlServer?.stop().catch( () => undefined );
+ throw error;
+ }
const removeReaper = reapPhpTreeOnInterrupt( child );
const exitCode = new Promise< number >( ( resolve, reject ) => {
@@ -219,6 +257,7 @@ async function runNativeWpCliCommand(
if ( child.exitCode === null && child.signalCode === null && ! child.killed ) {
killPhpProcessTree( child, 'SIGKILL' );
}
+ void mysqlServer?.stop();
};
if ( options.stdio === 'inherit' ) {
diff --git a/apps/cli/lib/types/wordpress-server-ipc.ts b/apps/cli/lib/types/wordpress-server-ipc.ts
index 6d1b0692f5..1ccf168007 100644
--- a/apps/cli/lib/types/wordpress-server-ipc.ts
+++ b/apps/cli/lib/types/wordpress-server-ipc.ts
@@ -1,3 +1,4 @@
+import { databaseEngineSchema, mysqlSiteConfigSchema } from '@studio/common/lib/database-engine';
import { siteFileAccessSchema } from '@studio/common/lib/site-file-access';
import { z } from 'zod';
import type { WordPressInstallMode } from '@wp-playground/wordpress';
@@ -29,6 +30,8 @@ export const serverConfigSchema = z.object( {
siteLanguage: z.string().optional(),
isWpAutoUpdating: z.boolean().optional(),
fileAccess: siteFileAccessSchema.optional(),
+ databaseEngine: databaseEngineSchema.optional(),
+ mysql: mysqlSiteConfigSchema.optional(),
enableXdebug: z.boolean().optional(),
enableDebugLog: z.boolean().optional(),
enableDebugDisplay: z.boolean().optional(),
diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts
index 81909a0011..5604563123 100644
--- a/apps/cli/lib/wordpress-server-manager.ts
+++ b/apps/cli/lib/wordpress-server-manager.ts
@@ -184,6 +184,14 @@ function buildServerConfig(
serverConfig.fileAccess = site.fileAccess;
}
+ if ( site.databaseEngine ) {
+ serverConfig.databaseEngine = site.databaseEngine;
+ }
+
+ if ( site.mysql ) {
+ serverConfig.mysql = site.mysql;
+ }
+
if ( site.enableXdebug ) {
serverConfig.enableXdebug = true;
}
diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts
index 008779b257..0be99d1b47 100644
--- a/apps/cli/php-server-child.ts
+++ b/apps/cli/php-server-child.ts
@@ -26,6 +26,8 @@ import {
} from 'cli/lib/types/wordpress-server-ipc';
import { requestSetAdminCredentials, toUrlSearchParams } from './lib/admin-credentials';
import { getPhpMyAdminPath } from './lib/dependency-management/paths';
+import { ensureMysqlServerRunning, type ManagedMysqlServer } from './lib/mysql/mysql-process';
+import { getMysqlConfigFromServerConfig, prepareMysqlSite } from './lib/mysql/mysql-site';
import { runBlueprint } from './lib/native-php/blueprints';
import {
killAllLivePhpProcesses,
@@ -97,9 +99,11 @@ let phpWorkerProcesses: ChildProcess[] = [];
let phpProxyServer: http.Server | null = null;
let phpWorkerPorts: number[] = [];
let phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 );
+let mysqlServer: ManagedMysqlServer | null = null;
let startupAbortController: AbortController | null = null;
let startingPromise: Promise< void > | null = null;
let blueprintQueue: Promise< unknown > = Promise.resolve();
+let wpCronHeartbeatTimer: NodeJS.Timeout | null = null;
// Symlink-aware open_basedir state. PHP's open_basedir cannot be extended at
// runtime, so when a new symlink appears under the site directory we have to
@@ -111,7 +115,30 @@ let runningConfig: ServerConfig | null = null;
const SYMLINK_RESTART_DEBOUNCE_MS = 750;
const STOP_SERVER_TIMEOUT = 5000;
-const NATIVE_PHP_WORKER_POOL_SIZE = 4;
+
+// Interval for the WP-Cron heartbeat loopback. A production WordPress host has a
+// system cron (or real web traffic) firing wp-cron.php on a schedule, which is
+// how Action Scheduler drains its queue. This runtime is a bare `php -S` worker
+// pool with no cron ticker, so without an external heartbeat the AS queue only
+// advances when a user request happens to arrive — asynchronous workloads (agent
+// fanout branches, scheduled jobs) can otherwise strand PENDING indefinitely.
+// 60s matches Action Scheduler's own `every_minute` WP-Cron schedule.
+const WP_CRON_HEARTBEAT_INTERVAL_MS = 60_000;
+// The heartbeat request is fire-and-forget; give up quickly if the worker is
+// busy so the heartbeat never piles up or blocks. A slow tick is a no-op — the
+// next one fires 60s later regardless.
+const WP_CRON_HEARTBEAT_TIMEOUT_MS = 5_000;
+// Number of native-PHP worker processes fronted by the request-balancing proxy.
+// This is the concurrency ceiling for a native-PHP site — more workers serve
+// more simultaneous requests. Defaults to 4; override with
+// STUDIO_PHP_WORKER_POOL_SIZE (e.g. to exercise concurrency on a larger machine
+// or dial it down to save memory). "PHP" not "NATIVE_PHP" in the name: this is
+// Studio's native-PHP runtime, unrelated to any product named Studio Native.
+const NATIVE_PHP_WORKER_POOL_SIZE = ( () => {
+ const raw = process.env.STUDIO_PHP_WORKER_POOL_SIZE;
+ const parsed = raw ? parseInt( raw, 10 ) : NaN;
+ return Number.isFinite( parsed ) && parsed > 0 ? parsed : 4;
+} )();
// "Site directory" file access applies the open_basedir jail and
// disable_functions list; "all files" runs PHP unrestricted.
@@ -120,19 +147,39 @@ function isFileAccessRestricted( config: ServerConfig ): boolean {
}
function logToConsole( ...args: Parameters< typeof console.log > ) {
- console.log( `[PHP Server]`, ...args );
+ const message = `[PHP Server] ${ args.map( String ).join( ' ' ) }`;
+ console.log( message );
+ process.send?.( { topic: 'console-message', message } );
}
function errorToConsole( ...args: Parameters< typeof console.error > ) {
- console.error( `[PHP Server]`, ...args );
+ const message = `[PHP Server] ${ args.map( String ).join( ' ' ) }`;
+ console.error( message );
+ process.send?.( { topic: 'console-message', message } );
}
+// Worker affinity is pinned by request PATH, never by HTTP method. Only routes
+// that carry cross-request state on a single worker's local filesystem need to
+// land on the same worker every time; everything else — GET or POST — must
+// load-balance so the pool can serve requests concurrently.
+//
+// The one pinned route today is phpMyAdmin. It keeps its session in a
+// file-based store scoped to one worker (STUDIO_PHPMYADMIN_SESSION_PATH), so a
+// follow-up POST that landed on a different worker would find no session and
+// log the user out. Pinning `/phpmyadmin` (all methods) keeps that flow on
+// worker 0. WordPress itself is stateless across the pool: it keeps sessions in
+// the shared MySQL database, not on any single worker's disk.
+//
+// This used to also pin EVERY non-GET/HEAD/OPTIONS request to worker 0. That
+// method-based rule was an over-broad stand-in for "stateful admin request",
+// but it swept in all of WordPress's own POST loopbacks — most importantly
+// Action Scheduler's async queue runner (a POST to admin-ajax.php) and wp-cron.
+// Async fanout fires N concurrent loopback POSTs to wake N workers for N
+// branches; with the method pin all N serialized on worker 0, capping fanout
+// concurrency at 1 regardless of pool size. Removing it lets those loopbacks
+// fan out across the pool while phpMyAdmin's genuine affinity is preserved by
+// path.
function shouldUsePrimaryWorker( req: http.IncomingMessage ): boolean {
- const method = req.method?.toUpperCase() ?? 'GET';
- if ( ! [ 'GET', 'HEAD', 'OPTIONS' ].includes( method ) ) {
- return true;
- }
-
const requestUrl = req.url ?? '/';
if ( requestUrl.startsWith( '/phpmyadmin' ) ) {
return true;
@@ -222,6 +269,77 @@ async function getAdminCredentialsErrorMessage( response: Response ): Promise< s
}
}
+// The site's canonical host, used as the `Host` header for the self-loopback so
+// WordPress doesn't issue a canonical redirect (which would bounce an internal
+// HTTP hit to the public HTTPS URL). Mirrors how `absoluteUrl` is derived
+// elsewhere; falls back to the plain localhost host for non-custom-domain sites.
+function getCanonicalHostHeader( config: ServerConfig ): string {
+ if ( config.absoluteUrl ) {
+ try {
+ return new URL( config.absoluteUrl ).host;
+ } catch {
+ // Fall through to the localhost default.
+ }
+ }
+ return `localhost:${ config.port }`;
+}
+
+// Fires wp-cron.php as a self-loopback so Action Scheduler's queue drains on a
+// fixed schedule regardless of incoming traffic. `?doing_wp_cron` is the same
+// query WordPress uses for its own spawned cron. The request goes straight to
+// the internal worker-pool proxy over plain HTTP (like setAdminCredentials) —
+// never the public HTTPS front door — so it avoids the site's self-signed
+// `.local` cert entirely, while the canonical `Host` header keeps WordPress from
+// redirecting it. Best-effort and fire-and-forget: a failed or slow tick is a
+// no-op and the next tick still fires. Uses the raw `http` module rather than
+// `fetch` so the connection can be abandoned without an unhandled rejection.
+function triggerWpCronHeartbeat( config: ServerConfig ): void {
+ const request = http.request(
+ {
+ hostname: 'localhost',
+ port: config.port,
+ path: '/wp-cron.php?doing_wp_cron',
+ method: 'GET',
+ headers: { host: getCanonicalHostHeader( config ) },
+ timeout: WP_CRON_HEARTBEAT_TIMEOUT_MS,
+ },
+ ( res ) => {
+ // Drain and discard: we only need WordPress to have started the cron run.
+ res.resume();
+ }
+ );
+ request.on( 'timeout', () => request.destroy() );
+ request.on( 'error', () => {
+ // Expected for a busy worker or a mid-restart window; the next tick recovers.
+ } );
+ request.end();
+}
+
+// Starts the periodic WP-Cron heartbeat. Idempotent: a running timer is left in
+// place. The interval is unref'd so it never keeps the process alive on its own.
+function startWpCronHeartbeat( config: ServerConfig ): void {
+ if ( wpCronHeartbeatTimer ) {
+ return;
+ }
+ logToConsole(
+ `Starting WP-Cron heartbeat every ${
+ WP_CRON_HEARTBEAT_INTERVAL_MS / 1000
+ }s to drain the Action Scheduler queue`
+ );
+ wpCronHeartbeatTimer = setInterval(
+ () => triggerWpCronHeartbeat( config ),
+ WP_CRON_HEARTBEAT_INTERVAL_MS
+ );
+ wpCronHeartbeatTimer.unref?.();
+}
+
+function stopWpCronHeartbeat(): void {
+ if ( wpCronHeartbeatTimer ) {
+ clearInterval( wpCronHeartbeatTimer );
+ wpCronHeartbeatTimer = null;
+ }
+}
+
// The symlink watcher is used to detect new symlinks in wp-content and its subdirectories. When a
// new symlink is detected, it is added to the open_basedir allow list and the server is restarted.
function startSymlinkWatcher( sitePath: string ): void {
@@ -361,6 +479,33 @@ async function stopCurrentPhpServer(): Promise< void > {
);
}
+/**
+ * The site's own host + local port for the loopback DNS fast-path mu-plugin.
+ *
+ * Only returns a host when the site is served from a custom domain (a `.local`
+ * name carried in `absoluteUrl`). A plain `localhost` site has no domain to
+ * resolve and no mDNS penalty, so it needs no pin. The port is the site's public
+ * HTTP port — the one the loopback ultimately reaches.
+ */
+function getLoopbackResolveTarget( config: ServerConfig ): {
+ siteHost?: string;
+ sitePort?: number;
+} {
+ if ( ! config.absoluteUrl ) {
+ return {};
+ }
+ let host: string;
+ try {
+ host = new URL( config.absoluteUrl ).hostname;
+ } catch {
+ return {};
+ }
+ if ( ! host || host === 'localhost' || host === '127.0.0.1' || host === '::1' ) {
+ return {};
+ }
+ return { siteHost: host, sitePort: config.port };
+}
+
function proxyRequestToPhpWorker(
config: ServerConfig,
req: http.IncomingMessage,
@@ -389,7 +534,14 @@ function proxyRequestToPhpWorker(
released = true;
phpWorkerRequestTracker.set( worker.index, phpWorkerRequestTracker.get( worker.index ) - 1 );
};
- res.once( 'close', release );
+ // Release the worker's busy count when the UPSTREAM request to the PHP worker
+ // finishes — not when the client connection closes. A fire-and-forget loopback
+ // (WordPress dispatches Action Scheduler's async queue runner with
+ // blocking=false and a ~0.01s timeout, so the client disconnects almost
+ // immediately by design) must still count the worker as busy for the full
+ // duration of the work it kicked off, or the pool would treat a worker that is
+ // mid-generation as idle. `proxyReq`'s `close` fires once the upstream exchange
+ // is fully done, which is the real end of the worker's work.
const headers = { ...req.headers };
headers.host = req.headers.host ?? `localhost:${ config.port }`;
@@ -405,20 +557,54 @@ function proxyRequestToPhpWorker(
headers,
},
( proxyRes ) => {
- res.writeHead( proxyRes.statusCode ?? 502, proxyRes.headers );
- proxyRes.pipe( res );
+ // The client may already be gone (fire-and-forget). Only write back if
+ // it is still connected; the upstream still runs to completion either way.
+ if ( ! res.writableEnded && ! res.destroyed ) {
+ res.writeHead( proxyRes.statusCode ?? 502, proxyRes.headers );
+ proxyRes.pipe( res );
+ } else {
+ // Client is gone: drain the worker's response so the upstream request
+ // completes cleanly instead of stalling on backpressure.
+ proxyRes.resume();
+ }
}
);
+ proxyReq.once( 'close', release );
+
proxyReq.on( 'error', ( error ) => {
release();
- if ( ! res.headersSent ) {
+ if ( ! res.headersSent && ! res.writableEnded && ! res.destroyed ) {
res.writeHead( 502 );
+ res.end( `PHP worker proxy error: ${ error.message }` );
}
- res.end( `PHP worker proxy error: ${ error.message }` );
} );
- req.pipe( proxyReq );
+ // Decouple the upstream worker request from the client connection. WordPress's
+ // async fanout depends on fire-and-forget loopback requests: the client
+ // disconnects the instant the request is sent and the PHP worker is meant to
+ // keep running (claim + execute an Action Scheduler branch). A plain
+ // `req.pipe( proxyReq )` would propagate the client's early close/abort to the
+ // upstream and destroy that worker request mid-flight, so the branch never runs.
+ // Forward the body manually so a client abort does NOT tear down the upstream:
+ // the worker request runs to completion on its own.
+ req.on( 'data', ( chunk ) => {
+ if ( ! proxyReq.writableEnded ) {
+ proxyReq.write( chunk );
+ }
+ } );
+ req.on( 'end', () => {
+ if ( ! proxyReq.writableEnded ) {
+ proxyReq.end();
+ }
+ } );
+ req.on( 'error', () => {
+ // Client aborted (expected for fire-and-forget). Finish sending the request
+ // to the worker so the upstream can run to completion; do NOT destroy it.
+ if ( ! proxyReq.writableEnded ) {
+ proxyReq.end();
+ }
+ } );
}
async function startPhpProxyServer(
@@ -450,6 +636,7 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise
}
const phpVersion = resolveNativePhpVersion( config.phpVersion ?? '' );
+ const mysqlConfig = getMysqlConfigFromServerConfig( config );
startupAbortController = new AbortController();
const stopSignal = AbortSignal.any( [ signal, startupAbortController.signal ] );
@@ -464,6 +651,13 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise
try {
stopSignal.throwIfAborted();
+ if ( mysqlConfig ) {
+ mysqlServer = await ensureMysqlServerRunning( mysqlConfig, logToConsole, stopSignal );
+ stopSignal.throwIfAborted();
+ await prepareMysqlSite( mysqlConfig, config.sitePath );
+ stopSignal.throwIfAborted();
+ }
+
if ( ! isImportedSite ) {
await ensureWpConfig(
config.sitePath,
@@ -477,7 +671,8 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise
const muPluginsPath = await writeStudioMuPluginsForNativePhpRuntime(
config.sitePath,
- config.isWpAutoUpdating
+ config.isWpAutoUpdating,
+ getLoopbackResolveTarget( config )
);
stopSignal.throwIfAborted();
@@ -526,9 +721,18 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise
stopSignal.throwIfAborted();
await setAdminCredentials( config, stopSignal );
stopSignal.throwIfAborted();
+
+ // Drive Action Scheduler's queue on a schedule. This runtime has no system
+ // cron, so async workloads would otherwise only advance on incoming traffic.
+ startWpCronHeartbeat( config );
} catch ( error ) {
+ stopWpCronHeartbeat();
killPhpProcess();
phpProcess = null;
+ if ( mysqlServer ) {
+ await mysqlServer.stop().catch( () => undefined );
+ mysqlServer = null;
+ }
await stopSymlinkWatcher();
runningConfig = null;
currentOpenBasedirAllowlist.clear();
@@ -671,12 +875,13 @@ async function stopServer(): Promise< StopServerResult > {
return StopServerResult.ABORTED_STARTUP;
}
+ stopWpCronHeartbeat();
await stopSymlinkWatcher();
runningConfig = null;
currentOpenBasedirAllowlist.clear();
const children = getCurrentPhpProcesses();
- if ( children.length === 0 && ! phpProxyServer ) {
+ if ( children.length === 0 && ! phpProxyServer && ! mysqlServer ) {
logToConsole( 'No server running, nothing to stop' );
return StopServerResult.OK;
}
@@ -684,13 +889,18 @@ async function stopServer(): Promise< StopServerResult > {
if (
children.length > 0 &&
children.every( ( child ) => child.exitCode !== null || child.signalCode !== null ) &&
- ! phpProxyServer
+ ! phpProxyServer &&
+ ! mysqlServer
) {
logToConsole( 'Server already stopped' );
return StopServerResult.OK;
}
await stopCurrentPhpServer();
+ if ( mysqlServer ) {
+ await mysqlServer.stop();
+ mysqlServer = null;
+ }
logToConsole( 'Server stopped gracefully' );
return StopServerResult.OK;
@@ -758,37 +968,59 @@ async function ipcMessageHandler( packet: unknown ) {
case 'run-blueprint': {
const blueprintConfig = validMessage.data.config;
const blueprintPhpVersion = resolveNativePhpVersion( blueprintConfig.phpVersion ?? '' );
- await ensureWpConfig(
- blueprintConfig.sitePath,
- blueprintPhpVersion,
- abortController.signal,
- WP_CONFIG_TRANSFORMER_PATH,
- blueprintConfig
- );
- await writeStudioMuPluginsForNativePhpRuntime(
- blueprintConfig.sitePath,
- blueprintConfig.isWpAutoUpdating
- );
- await installWordPress(
- blueprintConfig,
- blueprintPhpVersion,
- abortController.signal,
- SET_DEFAULT_PERMALINKS_PATH,
- logToConsole
- );
- if ( ! blueprintConfig.blueprint ) {
- throw new Error( 'Blueprint is required' );
+ const blueprintMysqlConfig = getMysqlConfigFromServerConfig( blueprintConfig );
+ if ( blueprintMysqlConfig ) {
+ mysqlServer = await ensureMysqlServerRunning(
+ blueprintMysqlConfig,
+ logToConsole,
+ abortController.signal
+ );
+ await prepareMysqlSite( blueprintMysqlConfig, blueprintConfig.sitePath );
}
- const blueprint = blueprintConfig.blueprint;
- // Sequential queue: each message waits for the previous to settle before
- // running its own blueprint. Distinct configs are not coalesced.
- const next = blueprintQueue
- .catch( () => {} )
- .then( () =>
- runBlueprint( blueprintConfig, blueprint, blueprintPhpVersion, abortController.signal )
+ try {
+ await ensureWpConfig(
+ blueprintConfig.sitePath,
+ blueprintPhpVersion,
+ abortController.signal,
+ WP_CONFIG_TRANSFORMER_PATH,
+ blueprintConfig
);
- blueprintQueue = next;
- result = await next;
+ await writeStudioMuPluginsForNativePhpRuntime(
+ blueprintConfig.sitePath,
+ blueprintConfig.isWpAutoUpdating,
+ getLoopbackResolveTarget( blueprintConfig )
+ );
+ await installWordPress(
+ blueprintConfig,
+ blueprintPhpVersion,
+ abortController.signal,
+ SET_DEFAULT_PERMALINKS_PATH,
+ logToConsole
+ );
+ if ( ! blueprintConfig.blueprint ) {
+ throw new Error( 'Blueprint is required' );
+ }
+ const blueprint = blueprintConfig.blueprint;
+ // Sequential queue: each message waits for the previous to settle before
+ // running its own blueprint. Distinct configs are not coalesced.
+ const next = blueprintQueue
+ .catch( () => {} )
+ .then( () =>
+ runBlueprint(
+ blueprintConfig,
+ blueprint,
+ blueprintPhpVersion,
+ abortController.signal
+ )
+ );
+ blueprintQueue = next;
+ result = await next;
+ } finally {
+ if ( blueprintMysqlConfig && mysqlServer ) {
+ await mysqlServer.stop().catch( () => undefined );
+ mysqlServer = null;
+ }
+ }
break;
}
case 'wp-cli-command':
@@ -815,6 +1047,7 @@ async function ipcMessageHandler( packet: unknown ) {
errorToConsole( `Error handling message ${ validMessage.topic }:`, error );
await sendErrorMessage( validMessage.messageId, error );
errorToConsole( 'Killing process because of', error );
+ await stopMysqlServerBestEffort();
process.exit( 1 );
} finally {
delete abortControllers[ validMessage.messageId ];
@@ -822,6 +1055,7 @@ async function ipcMessageHandler( packet: unknown ) {
}
function killPhpProcess(): void {
+ stopWpCronHeartbeat();
try {
phpProxyServer?.close();
} catch {
@@ -840,12 +1074,21 @@ function killPhpProcess(): void {
phpWorkerRequestTracker = new PhpWorkerRequestTracker( 0 );
}
+async function stopMysqlServerBestEffort(): Promise< void > {
+ if ( ! mysqlServer ) {
+ return;
+ }
+ const server = mysqlServer;
+ mysqlServer = null;
+ await server.stop().catch( () => undefined );
+}
+
function shutdownOnSignal( signal: NodeJS.Signals ): void {
logToConsole( `Received ${ signal }, shutting down` );
killPhpProcess();
// Follow the Unix convention of `128 + signum` so the exit code reflects the signal.
const signum = os.constants.signals[ signal ] ?? 0;
- process.exit( 128 + signum );
+ void stopMysqlServerBestEffort().finally( () => process.exit( 128 + signum ) );
}
// If this node process is going down (normal exit or IPC disconnect), make sure PHP goes with it.
@@ -855,7 +1098,7 @@ process.on( 'disconnect', () => {
killPhpProcess();
// Without an explicit exit, the wrapper would linger until the event loop drains,
// which delays the daemon's stop sequence and risks the force-kill timer firing.
- process.exit( 0 );
+ void stopMysqlServerBestEffort().finally( () => process.exit( 0 ) );
} );
// Without explicit signal handlers, the process is terminated abruptly and the 'exit' event
diff --git a/apps/studio/src/components/content-tab-overview.tsx b/apps/studio/src/components/content-tab-overview.tsx
index 7984a7bf2d..6b05b51cff 100644
--- a/apps/studio/src/components/content-tab-overview.tsx
+++ b/apps/studio/src/components/content-tab-overview.tsx
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/electron/renderer';
+import { isMysqlSite } from '@studio/common/lib/database-engine';
import { __ } from '@wordpress/i18n';
import {
archive,
@@ -176,21 +177,23 @@ function ShortcutsSection( { selectedSite }: Pick< ContentTabOverviewProps, 'sel
},
} );
- buttonsArray.push( {
- label: __( 'phpMyAdmin' ),
- className: 'text-nowrap',
- icon: grid,
- disabled: isServerLoading,
- onClick: async () => {
- if ( ! selectedSite.running ) {
- await startServer( selectedSite );
- }
- getIpcApi().openSiteURL(
- selectedSite.id,
- '/phpmyadmin/index.php?route=/database/structure&db=wordpress'
- );
- },
- } );
+ if ( ! isMysqlSite( selectedSite ) ) {
+ buttonsArray.push( {
+ label: __( 'phpMyAdmin' ),
+ className: 'text-nowrap',
+ icon: grid,
+ disabled: isServerLoading,
+ onClick: async () => {
+ if ( ! selectedSite.running ) {
+ await startServer( selectedSite );
+ }
+ getIpcApi().openSiteURL(
+ selectedSite.id,
+ '/phpmyadmin/index.php?route=/database/structure&db=wordpress'
+ );
+ },
+ } );
+ }
return