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 ; } diff --git a/apps/studio/src/components/tests/content-tab-overview-shortcuts-section.test.tsx b/apps/studio/src/components/tests/content-tab-overview-shortcuts-section.test.tsx index f6164c8130..5370956397 100644 --- a/apps/studio/src/components/tests/content-tab-overview-shortcuts-section.test.tsx +++ b/apps/studio/src/components/tests/content-tab-overview-shortcuts-section.test.tsx @@ -195,4 +195,30 @@ describe( 'ShortcutsSection', () => { expect( queryByLabelText( 'Visual Studio Code' ) ).not.toBeInTheDocument(); expect( queryByLabelText( 'PhpStorm' ) ).not.toBeInTheDocument(); } ); + + it( 'hides phpMyAdmin for MySQL sites', async () => { + vi.mocked( getIpcApi, { partial: true } ).mockReturnValue( { + getUserEditor: vi.fn().mockResolvedValue( null ), + getUserTerminal: vi.fn().mockResolvedValue( 'terminal' ), + getInstalledAppsAndTerminals: vi.fn().mockResolvedValue( { + vscode: false, + phpstorm: false, + webstorm: false, + windsurf: false, + cursor: false, + terminal: true, + iterm: false, + ghostty: false, + warp: false, + } ), + } ); + + const { findByLabelText, queryByLabelText } = renderWithProvider( + + ); + + await findByLabelText( 'Terminal' ); + + expect( queryByLabelText( 'phpMyAdmin' ) ).not.toBeInTheDocument(); + } ); } ); diff --git a/apps/studio/src/hooks/tests/use-add-site.test.tsx b/apps/studio/src/hooks/tests/use-add-site.test.tsx index fde9bb51ff..6618815b0a 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -152,6 +152,7 @@ describe( 'useAddSite', () => { sitePath: '/test/path', phpVersion: '8.2', wpVersion: '6.1.7', + databaseEngine: 'mysql', useCustomDomain: false, customDomain: null, enableHttps: false, @@ -175,7 +176,8 @@ describe( 'useAddSite', () => { undefined, // adminPassword undefined, // adminEmail undefined, // runtime - undefined // fileAccess + undefined, // fileAccess + 'mysql' ); } ); diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 2439eb6d1b..a740c32b3d 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -1,6 +1,7 @@ import * as Sentry from '@sentry/electron/renderer'; import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { updateBlueprintWithFormValues } from '@studio/common/lib/blueprint-settings'; +import { type DatabaseEngine } from '@studio/common/lib/database-engine'; import { generateCustomDomainFromSiteName } from '@studio/common/lib/domains'; import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; import { type SiteRuntime } from '@studio/common/lib/site-runtime'; @@ -30,6 +31,7 @@ export interface CreateSiteFormValues { wpVersion: string; runtime?: SiteRuntime; fileAccess?: SiteFileAccess; + databaseEngine?: DatabaseEngine; useCustomDomain: boolean; customDomain: string | null; enableHttps: boolean; @@ -300,7 +302,8 @@ export function useAddSite() { formValues.adminPassword, formValues.adminEmail, formValues.runtime, - formValues.fileAccess + formValues.fileAccess, + formValues.databaseEngine ); } catch ( e ) { Sentry.captureException( e ); diff --git a/apps/studio/src/hooks/use-site-details.tsx b/apps/studio/src/hooks/use-site-details.tsx index 628ef669cf..92af903109 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -1,4 +1,5 @@ import { SITE_EVENTS, SiteEvent } from '@studio/common/lib/cli-events'; +import { type DatabaseEngine } from '@studio/common/lib/database-engine'; import { sortSites } from '@studio/common/lib/sort-sites'; import { __, sprintf } from '@wordpress/i18n'; import { @@ -37,7 +38,8 @@ interface SiteDetailsContext { adminPassword?: string, adminEmail?: string, runtime?: SiteRuntime, - fileAccess?: SiteFileAccess + fileAccess?: SiteFileAccess, + databaseEngine?: DatabaseEngine ) => Promise< SiteDetails | void >; copySite: ( sourceSiteId: string ) => Promise< SiteDetails | void >; startServer: ( @@ -277,7 +279,8 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { adminPassword?: string, adminEmail?: string, runtime?: SiteRuntime, - fileAccess?: SiteFileAccess + fileAccess?: SiteFileAccess, + databaseEngine?: DatabaseEngine ) => { // Function to handle error messages and cleanup const showError = ( error?: unknown, hasBlueprint?: boolean ) => { @@ -336,6 +339,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { running: false, isAddingSite: true, phpVersion: '', + databaseEngine, }, ] ) ); @@ -352,6 +356,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { phpVersion, runtime, fileAccess, + databaseEngine, blueprint, adminUsername, adminPassword, diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index d1e679aad1..ccef1ae426 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -51,6 +51,7 @@ import { import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; import { parseCliError, errorMessageContains } from '@studio/common/lib/cli-error'; import { getConnectedWpcomSitesForLocalSite } from '@studio/common/lib/connected-sites'; +import { type DatabaseEngine } from '@studio/common/lib/database-engine'; import { createDeployIgnoreFilter } from '@studio/common/lib/deploy-ignore'; import { calculateDirectorySizeForArchive, @@ -726,6 +727,7 @@ export async function createSite( adminPassword?: string; adminEmail?: string; noStart?: boolean; + databaseEngine?: DatabaseEngine; } = {} ): Promise< SiteDetails > { const { @@ -742,6 +744,7 @@ export async function createSite( adminPassword, adminEmail, noStart = false, + databaseEngine, } = config; const siteId = providedSiteId || crypto.randomUUID(); @@ -777,6 +780,7 @@ export async function createSite( adminPassword, adminEmail, noStart, + databaseEngine, }, { wpVersion, blueprint: blueprint?.blueprint } ); @@ -801,6 +805,7 @@ export async function createSite( phpVersion, hasCustomDomain: !! customDomain, httpsEnabled: !! enableHttps, + databaseEngine, }, }; diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index 541f826b22..e3991bdcaa 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -7,6 +7,17 @@ interface ShowNotificationOptions extends Electron.NotificationConstructorOption type SiteRuntime = 'playground' | 'native-php'; type SiteFileAccess = 'site-directory' | 'all-files'; +type DatabaseEngine = 'sqlite' | 'mysql'; + +interface MysqlSiteConfig { + host: string; + port: number; + databaseName: string; + username: string; + password: string; + serverVersion: string; + dataDir: string; +} interface StoppedSiteDetails { running: false; @@ -49,6 +60,8 @@ interface StoppedSiteDetails { landingPage?: string; runtime?: SiteRuntime; fileAccess?: SiteFileAccess; + databaseEngine?: DatabaseEngine; + mysql?: MysqlSiteConfig; } interface StartedSiteDetails extends StoppedSiteDetails { diff --git a/apps/studio/src/modules/add-site/components/create-site-form.tsx b/apps/studio/src/modules/add-site/components/create-site-form.tsx index d3d407baf2..3eee307c88 100644 --- a/apps/studio/src/modules/add-site/components/create-site-form.tsx +++ b/apps/studio/src/modules/add-site/components/create-site-form.tsx @@ -1,4 +1,9 @@ import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; +import { + DATABASE_ENGINE_MYSQL, + DATABASE_ENGINE_SQLITE, + type DatabaseEngine, +} from '@studio/common/lib/database-engine'; import { generateCustomDomainFromSiteName, getDomainNameValidationError, @@ -103,6 +108,8 @@ export const CreateSiteForm = ( { // New sites default to the native PHP runtime. const [ selectedRuntime, setSelectedRuntime ] = useState< SiteRuntime >( SITE_RUNTIME_NATIVE_PHP ); + const [ selectedDatabaseEngine, setSelectedDatabaseEngine ] = + useState< DatabaseEngine >( DATABASE_ENGINE_SQLITE ); const [ selectedFileAccess, setSelectedFileAccess ] = useState< SiteFileAccess >( SITE_FILE_ACCESS_SITE_DIRECTORY ); @@ -112,6 +119,8 @@ export const CreateSiteForm = ( { selectedRuntime === SITE_RUNTIME_PLAYGROUND ? SITE_FILE_ACCESS_SITE_DIRECTORY : selectedFileAccess; + const usedDatabaseEngine = + selectedRuntime === SITE_RUNTIME_PLAYGROUND ? DATABASE_ENGINE_SQLITE : selectedDatabaseEngine; const [ useCustomDomain, setUseCustomDomain ] = useState( false ); const [ customDomain, setCustomDomain ] = useState< string | null >( null ); const [ enableHttps, setEnableHttps ] = useState( false ); @@ -344,6 +353,7 @@ export const CreateSiteForm = ( { wpVersion, runtime: selectedRuntime, fileAccess: usedFileAccess, + databaseEngine: usedDatabaseEngine, useCustomDomain, customDomain, enableHttps, @@ -358,6 +368,7 @@ export const CreateSiteForm = ( { wpVersion, selectedRuntime, usedFileAccess, + usedDatabaseEngine, useCustomDomain, customDomain, enableHttps, @@ -549,6 +560,24 @@ export const CreateSiteForm = ( { +
+ + + id="database-engine-select" + disabled={ selectedRuntime === SITE_RUNTIME_PLAYGROUND } + value={ usedDatabaseEngine } + options={ [ + { label: __( 'SQLite' ), value: DATABASE_ENGINE_SQLITE }, + { label: __( 'MySQL' ), value: DATABASE_ENGINE_MYSQL }, + ] } + onChange={ ( value ) => setSelectedDatabaseEngine( value ) } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> +
+