From 9199c54378132c405f392cfb0c0b3ec020bf9d5e Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Mon, 29 Jun 2026 19:47:52 -0400 Subject: [PATCH 1/8] Add MySQL proof of concept --- apps/cli/commands/site/create.ts | 51 +- apps/cli/commands/site/start.ts | 15 +- apps/cli/commands/site/tests/create.test.ts | 45 ++ .../lib/dependency-management/mysql-binary.ts | 197 +++++++ apps/cli/lib/dependency-management/paths.ts | 27 + apps/cli/lib/mysql/mysql-process.ts | 320 ++++++++++ apps/cli/lib/mysql/mysql-site.ts | 217 +++++++ apps/cli/lib/native-php/blueprints.ts | 18 +- apps/cli/lib/native-php/php-process.ts | 13 +- apps/cli/lib/native-php/site-setup.ts | 12 +- apps/cli/lib/run-wp-cli-command.ts | 17 +- apps/cli/lib/types/wordpress-server-ipc.ts | 3 + apps/cli/lib/wordpress-server-manager.ts | 8 + apps/cli/php-server-child.ts | 125 ++-- .../src/components/content-tab-overview.tsx | 33 +- ...nt-tab-overview-shortcuts-section.test.tsx | 26 + .../src/hooks/tests/use-add-site.test.tsx | 4 +- apps/studio/src/hooks/use-add-site.ts | 5 +- apps/studio/src/hooks/use-site-details.tsx | 9 +- apps/studio/src/ipc-handlers.ts | 5 + apps/studio/src/ipc-types.d.ts | 13 + .../add-site/components/create-site-form.tsx | 29 + .../modules/add-site/tests/add-site.test.tsx | 18 +- .../src/modules/cli/lib/cli-site-creator.ts | 6 + .../cli/lib/tests/cli-site-creator.test.ts | 27 + apps/studio/src/modules/sync/index.tsx | 14 + .../src/modules/sync/tests/index.test.tsx | 11 + apps/studio/src/site-server.ts | 2 + docs/design-docs/mysql-binaries.md | 247 ++++++++ docs/design-docs/mysql-poc-agent-plan.md | 547 ++++++++++++++++++ docs/design-docs/mysql-support.md | 276 +++++++++ tools/common/lib/cli-events.ts | 3 + tools/common/lib/database-engine.ts | 27 + .../common/lib/mysql-binary-cdn-metadata.json | 15 + tools/common/lib/mysql-binary-metadata.ts | 69 +++ 35 files changed, 2368 insertions(+), 86 deletions(-) create mode 100644 apps/cli/lib/dependency-management/mysql-binary.ts create mode 100644 apps/cli/lib/mysql/mysql-process.ts create mode 100644 apps/cli/lib/mysql/mysql-site.ts create mode 100644 docs/design-docs/mysql-binaries.md create mode 100644 docs/design-docs/mysql-poc-agent-plan.md create mode 100644 docs/design-docs/mysql-support.md create mode 100644 tools/common/lib/database-engine.ts create mode 100644 tools/common/lib/mysql-binary-cdn-metadata.json create mode 100644 tools/common/lib/mysql-binary-metadata.ts diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index a0f4555b8f..fdfaad170b 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,11 +271,13 @@ export async function runCommand( logger.reportSuccess( __( 'WordPress files copied' ) ); } - logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration…' ) ); - const isSqliteUpdated = await keepSqliteIntegrationUpdated( sitePath ); - logger.reportSuccess( - isSqliteUpdated ? __( 'SQLite integration configured' ) : __( 'SQLite integration skipped' ) - ); + if ( databaseEngine === DATABASE_ENGINE_SQLITE ) { + logger.reportStart( LoggerAction.INSTALL_SQLITE, __( 'Setting up SQLite integration…' ) ); + const isSqliteUpdated = await keepSqliteIntegrationUpdated( sitePath ); + logger.reportSuccess( + isSqliteUpdated ? __( 'SQLite integration configured' ) : __( 'SQLite integration skipped' ) + ); + } try { const sharedConfig = await readSharedConfig(); @@ -284,6 +297,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 @@ -326,6 +343,8 @@ export async function runCommand( phpVersion, runtime: siteRuntime, fileAccess: options.fileAccess, + databaseEngine: mysqlConfig ? DATABASE_ENGINE_MYSQL : undefined, + mysql: mysqlConfig, running: false, isWpAutoUpdating: options.wpVersion === DEFAULT_WORDPRESS_VERSION, customDomain: options.customDomain, @@ -368,7 +387,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 ); @@ -387,6 +408,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 } ); } @@ -411,9 +435,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 } ); } @@ -554,6 +583,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")' ), @@ -613,6 +648,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( @@ -790,6 +826,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 124f50ac41..fca6c584ee 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 { __ } from '@wordpress/i18n'; import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; @@ -43,12 +44,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 01d47ee07c..7a74823f5c 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 () => { @@ -334,6 +336,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, { @@ -345,6 +379,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/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 9570207dcc..4d86432f2a 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,30 @@ export function getPhpBinaryPath( version: NativePhpSupportedVersion | string ): return getExactPhpBinaryPath( configuredVersion ?? 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 ); +} + +export function getMysqlDataRoot(): string { + return path.join( getConfigDirectory(), 'mysql-data' ); +} + const WP_CLI_PHAR_FILENAME = 'wp-cli.phar'; const SQLITE_COMMAND_DIRNAME = 'sqlite-command'; diff --git a/apps/cli/lib/mysql/mysql-process.ts b/apps/cli/lib/mysql/mysql-process.ts new file mode 100644 index 0000000000..298461d46a --- /dev/null +++ b/apps/cli/lib/mysql/mysql-process.ts @@ -0,0 +1,320 @@ +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; +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', + ], + { + 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; +} + +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; + } = {} +): Promise< { stdout: string; stderr: string; code: number } > { + const { rejectOnExitCode = true, signal, timeoutMs = MYSQL_COMMAND_TIMEOUT_MS } = options; + + return await new Promise< { stdout: string; stderr: string; code: number } >( + ( resolve, reject ) => { + const child = spawn( command, args, { + stdio: [ '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 ); + + 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..a44df97a9b --- /dev/null +++ b/apps/cli/lib/mysql/mysql-site.ts @@ -0,0 +1,217 @@ +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 ); +} + +export async function provisionMysqlDatabase( config: MysqlSiteConfig ): Promise< void > { + 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 utf8mb4_unicode_ci`, + ...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 utf8mb4_unicode_ci;` + ); + } + + 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 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..1aa37a7857 100644 --- a/apps/cli/lib/native-php/blueprints.ts +++ b/apps/cli/lib/native-php/blueprints.ts @@ -5,6 +5,10 @@ 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 { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata'; import type { ServerConfig } from 'cli/lib/types/wordpress-server-ipc'; @@ -29,9 +33,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 +91,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 +109,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', + `--db-engine=${ mysqlConfig ? 'mysql' : 'sqlite' }`, ], { phpVersion, diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index 8cd7f5da01..7cccec5eee 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -172,17 +172,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 ) => { @@ -194,7 +190,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..631ac52318 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,9 @@ $transformer->to_file( $wp_config_path ); const enableDebugLog = config?.enableDebugLog ?? false; const enableDebugDisplay = config?.enableDebugDisplay ?? false; + const mysqlConfig = config ? getMysqlConfigFromServerConfig( config as ServerConfig ) : undefined; 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, diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index bcf78975bc..2a4188bdfa 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, @@ -33,6 +34,7 @@ import { } 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, @@ -185,6 +187,13 @@ async function runNativeWpCliCommand( const phpVersion = resolveNativePhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); await ensurePhpBinaryAvailable( phpVersion ); await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating ); + 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 ); + } // Don't apply open_basedir or disable_functions to the WP-CLI process const defaultArgs = getDefaultPhpArgs( phpVersion ); @@ -199,7 +208,12 @@ async function runNativeWpCliCommand( } ); - 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 ) => { @@ -213,6 +227,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..1c1b1ca7ad 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,6 +99,7 @@ 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(); @@ -120,11 +123,15 @@ 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 } ); } function shouldUsePrimaryWorker( req: http.IncomingMessage ): boolean { @@ -450,6 +457,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 +472,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, @@ -529,6 +544,10 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise } catch ( error ) { killPhpProcess(); phpProcess = null; + if ( mysqlServer ) { + await mysqlServer.stop().catch( () => undefined ); + mysqlServer = null; + } await stopSymlinkWatcher(); runningConfig = null; currentOpenBasedirAllowlist.clear(); @@ -676,7 +695,7 @@ async function stopServer(): Promise< StopServerResult > { 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 +703,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 +782,58 @@ 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 + ); + await writeStudioMuPluginsForNativePhpRuntime( + blueprintConfig.sitePath, + blueprintConfig.isWpAutoUpdating ); - blueprintQueue = next; - result = await next; + 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 +860,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 ]; @@ -840,12 +886,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 +910,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 48569b17c5..cebaf20690 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: ( @@ -258,7 +260,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 ) => { @@ -317,6 +320,7 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { running: false, isAddingSite: true, phpVersion: '', + databaseEngine, }, ] ) ); @@ -333,6 +337,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 5e6bb463bc..edd952aea5 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -43,6 +43,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 { extractZip } from '@studio/common/lib/extract-zip'; import { @@ -802,6 +803,7 @@ export async function createSite( adminPassword?: string; adminEmail?: string; noStart?: boolean; + databaseEngine?: DatabaseEngine; } = {} ): Promise< SiteDetails > { const { @@ -818,6 +820,7 @@ export async function createSite( adminPassword, adminEmail, noStart = false, + databaseEngine, } = config; const siteId = providedSiteId || crypto.randomUUID(); @@ -853,6 +856,7 @@ export async function createSite( adminPassword, adminEmail, noStart, + databaseEngine, }, { wpVersion, blueprint: blueprint?.blueprint } ); @@ -877,6 +881,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 c95b398d6a..8dad178a07 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, @@ -547,6 +558,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 + /> +
+