diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 12690059eb..10a0ed419d 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'; import os from 'os'; import path from 'path'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { getSharedBrowser } from 'cli/ai/browser-utils'; import { emitEvent } from 'cli/ai/json-events'; @@ -636,6 +637,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); await expect( @@ -655,6 +657,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '123', @@ -685,6 +688,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '123', @@ -712,6 +716,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '123', @@ -741,6 +746,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '123', @@ -769,6 +775,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '123', @@ -801,6 +808,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '123', @@ -833,6 +841,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); await expect( @@ -991,6 +1000,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: "Success: Switched to 'Acme Studio' theme.", @@ -1020,6 +1030,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); const result = await getTool( 'scaffold_theme' ).rawHandler( { @@ -1056,6 +1067,7 @@ describe( 'Studio AI MCP tools', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( sendWpCliCommand ).mockResolvedValue( { stdout: '', diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index f7ed072434..5eb1f33e17 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -2,12 +2,7 @@ import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { confirm, input, password, select } from '@inquirer/prompts'; -import { SupportedPHPVersions } from '@php-wasm/universal'; -import { - DEFAULT_PHP_VERSION, - DEFAULT_WORDPRESS_VERSION, - MINIMUM_WORDPRESS_VERSION, -} from '@studio/common/constants'; +import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; import { installAiInstructionsToSite } from '@studio/common/lib/agent-skills'; import { extractFormValuesFromBlueprint } from '@studio/common/lib/blueprint-settings'; import { validateBlueprintData } from '@studio/common/lib/blueprint-validation'; @@ -43,6 +38,7 @@ import { } from '@studio/common/lib/wordpress-version-utils'; import { fetchWordPressVersions } from '@studio/common/lib/wordpress-versions'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; +import { type SupportedPHPVersion } from '@studio/common/types/php-versions'; import { __, sprintf } from '@wordpress/i18n'; import { isStepDefinition, type BlueprintV1Declaration } from '@wp-playground/blueprints'; import { bumpStat, getPlatformMetric } from 'cli/lib/bump-stat'; @@ -51,8 +47,6 @@ import { readCliConfig, saveCliConfig, SiteData, - SiteRuntime, - siteRuntimeSchema, unlockCliConfig, } from 'cli/lib/cli-config/core'; import { @@ -64,6 +58,11 @@ import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/dae import { getAiInstructionsPath } from 'cli/lib/dependency-management/paths'; import { updateServerFiles } from 'cli/lib/dependency-management/setup'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; +import { + getRecommendedPhpVersionForSiteRuntime, + getSupportedPhpVersionsForSiteRuntime, + validatePhpVersionForSiteRuntime, +} from 'cli/lib/php-versions'; import { getPreferredSiteLanguage } from 'cli/lib/site-language'; import { generateSiteName } from 'cli/lib/site-name'; import { getDefaultSitePath } from 'cli/lib/site-paths'; @@ -76,15 +75,13 @@ import { runBlueprint, startWordPressServer } from 'cli/lib/wordpress-server-man import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; - const logger = new Logger< LoggerAction >(); export type CreateCommandOptions = { name?: string; siteId?: string; wpVersion: string; - phpVersion: ( typeof ALLOWED_PHP_VERSIONS )[ number ]; + phpVersion: SupportedPHPVersion; customDomain?: string; enableHttps: boolean; blueprint?: { @@ -99,14 +96,11 @@ export type CreateCommandOptions = { skipLogDetails: boolean; }; -function resolveRuntimeFromEnv(): SiteRuntime { - return siteRuntimeSchema.catch( 'playground' ).parse( process.env.STUDIO_RUNTIME ); -} - export async function runCommand( sitePath: string, options: CreateCommandOptions ): Promise< void > { + const phpVersion = validatePhpVersionForSiteRuntime( options.phpVersion ); const isOnlineStatus = await isOnline(); try { @@ -292,13 +286,12 @@ export async function runCommand( adminPassword, adminEmail, port, - phpVersion: options.phpVersion, + phpVersion, running: false, isWpAutoUpdating: options.wpVersion === DEFAULT_WORDPRESS_VERSION, customDomain: options.customDomain, enableHttps: options.enableHttps, landingPage: normalizeLandingPage( blueprint?.landingPage ), - runtime: resolveRuntimeFromEnv(), }; logger.reportStart( LoggerAction.SAVE_SITE, __( 'Saving site…' ) ); @@ -484,6 +477,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { command: 'create', describe: __( 'Create a new site' ), builder: ( yargs ) => { + const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); + const recommendedPhpVersion = getRecommendedPhpVersionForSiteRuntime(); return yargs .option( 'id', { type: 'string', @@ -504,8 +499,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'php', { type: 'string', describe: __( 'PHP version' ), - choices: ALLOWED_PHP_VERSIONS, - defaultDescription: DEFAULT_PHP_VERSION, + choices: supportedPhpVersions, + defaultDescription: recommendedPhpVersion, } ) .option( 'domain', { type: 'string', @@ -564,6 +559,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { let adminUsername = argv.adminUsername; let adminPassword = argv.adminPassword; let adminEmail = argv.adminEmail; + const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); + const recommendedPhpVersion = getRecommendedPhpVersionForSiteRuntime(); // Validate and resolve the WordPress version against available versions before prompting if ( wpVersion && wpVersion !== 'latest' && wpVersion !== 'nightly' ) { @@ -662,11 +659,11 @@ export const registerCommand = ( yargs: StudioArgv ) => { if ( ! phpVersion ) { phpVersion = await select( { message: __( 'PHP version:' ), - choices: ALLOWED_PHP_VERSIONS.map( ( v ) => ( { - name: v === DEFAULT_PHP_VERSION ? sprintf( __( '%s (recommended)' ), v ) : v, + choices: supportedPhpVersions.map( ( v ) => ( { + name: v === recommendedPhpVersion ? sprintf( __( '%s (recommended)' ), v ) : v, value: v, } ) ), - default: DEFAULT_PHP_VERSION, + default: recommendedPhpVersion, } ); } @@ -723,13 +720,15 @@ export const registerCommand = ( yargs: StudioArgv ) => { // Apply defaults for non-interactive mode when flags weren't provided wpVersion = wpVersion ?? DEFAULT_WORDPRESS_VERSION; - phpVersion = phpVersion ?? DEFAULT_PHP_VERSION; + const resolvedPhpVersion = validatePhpVersionForSiteRuntime( + phpVersion ?? recommendedPhpVersion + ); const config: CreateCommandOptions = { name: siteName, siteId: argv.id, wpVersion, - phpVersion, + phpVersion: resolvedPhpVersion, customDomain, enableHttps, adminUsername, diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index d5a0c258e8..9a212d3cf9 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -1,4 +1,3 @@ -import { SupportedPHPVersions } from '@php-wasm/universal'; import { DEFAULT_WORDPRESS_VERSION, MINIMUM_WORDPRESS_VERSION } from '@studio/common/constants'; import { SITE_EVENTS } from '@studio/common/lib/cli-events'; import { getDomainNameValidationError } from '@studio/common/lib/domains'; @@ -26,6 +25,10 @@ import { import { getSiteByFolder, updateSiteLatestCliPid } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon, emitCliEvent } from 'cli/lib/daemon-client'; import { updateDomainInHosts } from 'cli/lib/hosts-file'; +import { + getSupportedPhpVersionsForSiteRuntime, + validatePhpVersionForSiteRuntime, +} from 'cli/lib/php-versions'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { setupCustomDomain } from 'cli/lib/site-utils'; import { ValidationError } from 'cli/lib/validation-error'; @@ -37,8 +40,6 @@ import { import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; - const logger = new Logger< LoggerAction >(); export interface SetCommandOptions { @@ -69,6 +70,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) debugDisplay, } = options; let { adminEmail } = options; + const validatedPhp = php === undefined ? undefined : validatePhpVersionForSiteRuntime( php ); if ( name === undefined && @@ -159,7 +161,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const nameChanged = name !== undefined && name !== site.name; const domainChanged = domain !== undefined && domain !== site.customDomain; const httpsChanged = https !== undefined && https !== site.enableHttps; - const phpChanged = php !== undefined && php !== site.phpVersion; + const phpChanged = validatedPhp !== undefined && validatedPhp !== site.phpVersion; const wpChanged = wp !== undefined; const xdebugChanged = xdebug !== undefined && xdebug !== site.enableXdebug; const adminUsernameChanged = @@ -217,7 +219,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) foundSite.enableHttps = https; } if ( phpChanged ) { - foundSite.phpVersion = php!; + foundSite.phpVersion = validatedPhp!; } if ( xdebugChanged ) { foundSite.enableXdebug = xdebug; @@ -327,6 +329,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { command: 'set', describe: __( 'Configure site settings' ), builder: ( yargs ) => { + const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); return yargs .option( 'name', { type: 'string', @@ -343,7 +346,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'php', { type: 'string', description: __( 'PHP version' ), - choices: ALLOWED_PHP_VERSIONS, + choices: supportedPhpVersions, } ) .option( 'wp', { type: 'string', diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index a7acab12b0..3cba86f584 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -10,6 +10,7 @@ import { import { isOnline } from '@studio/common/lib/network-utils'; import { portFinder } from '@studio/common/lib/port-finder'; import { normalizeLineEndings } from '@studio/common/lib/remove-default-db-constants'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { getServerFilesPath } from '@studio/common/lib/well-known-paths'; import { Blueprint, BlueprintV1Declaration } from '@wp-playground/blueprints'; import { vi, type MockInstance } from 'vitest'; @@ -114,6 +115,7 @@ describe( 'CLI: studio site create', () => { pmId: 0, status: 'online', pid: 12345, + runtime: SITE_RUNTIME_PLAYGROUND, }; let consoleLogSpy: MockInstance; @@ -171,6 +173,7 @@ describe( 'CLI: studio site create', () => { } ); afterEach( () => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); } ); @@ -228,6 +231,21 @@ describe( 'CLI: studio site create', () => { expect( disconnectFromDaemon ).toHaveBeenCalled(); } ); + it( 'should error if PHP version is not supported by the native PHP runtime', async () => { + vi.stubEnv( 'STUDIO_RUNTIME', SITE_RUNTIME_NATIVE_PHP ); + + await expect( + runCommand( mockSitePath, { + ...defaultTestOptions, + phpVersion: '8.1', + } ) + ).rejects.toThrow( + 'PHP 8.1 is not supported by the native PHP runtime. Supported versions: 8.5, 8.4, 8.3, 8.2.' + ); + + expect( saveCliConfig ).not.toHaveBeenCalled(); + } ); + it( 'should error if Blueprint validation fails', async () => { vi.mocked( validateBlueprintData ).mockResolvedValue( { valid: false, @@ -519,48 +537,6 @@ describe( 'CLI: studio site create', () => { } ); } ); - describe( 'Runtime (STUDIO_RUNTIME env var)', () => { - afterEach( () => { - vi.unstubAllEnvs(); - } ); - - it( 'persists runtime=native-php when STUDIO_RUNTIME=native-php', async () => { - vi.stubEnv( 'STUDIO_RUNTIME', 'native-php' ); - - await runCommand( mockSitePath, { ...defaultTestOptions } ); - - expect( saveCliConfig ).toHaveBeenCalledWith( - expect.objectContaining( { - sites: expect.arrayContaining( [ expect.objectContaining( { runtime: 'native-php' } ) ] ), - } ) - ); - } ); - - it( 'defaults to playground when STUDIO_RUNTIME is unset', async () => { - vi.stubEnv( 'STUDIO_RUNTIME', undefined ); - - await runCommand( mockSitePath, { ...defaultTestOptions } ); - - expect( saveCliConfig ).toHaveBeenCalledWith( - expect.objectContaining( { - sites: expect.arrayContaining( [ expect.objectContaining( { runtime: 'playground' } ) ] ), - } ) - ); - } ); - - it( 'falls back to playground when STUDIO_RUNTIME has an unknown value', async () => { - vi.stubEnv( 'STUDIO_RUNTIME', 'nonsense' ); - - await runCommand( mockSitePath, { ...defaultTestOptions } ); - - expect( saveCliConfig ).toHaveBeenCalledWith( - expect.objectContaining( { - sites: expect.arrayContaining( [ expect.objectContaining( { runtime: 'playground' } ) ] ), - } ) - ); - } ); - } ); - describe( 'Multisite Validation', () => { it( 'should error when enableMultisite step is present without custom domain', async () => { const multisiteBlueprint: Blueprint = { diff --git a/apps/cli/commands/site/tests/delete.test.ts b/apps/cli/commands/site/tests/delete.test.ts index e1bab60928..45d82f66e2 100644 --- a/apps/cli/commands/site/tests/delete.test.ts +++ b/apps/cli/commands/site/tests/delete.test.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { readAuthToken } from '@studio/common/lib/shared-config'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import trash from 'trash'; import { vi } from 'vitest'; import { deleteSnapshot } from 'cli/lib/api'; @@ -70,6 +71,7 @@ describe( 'CLI: studio site delete', () => { pmId: 0, status: 'online', pid: 12345, + runtime: SITE_RUNTIME_PLAYGROUND, }; const testAuthToken = { diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index 7b9f86c625..a5f2f83f6d 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -1,6 +1,7 @@ import { getDomainNameValidationError } from '@studio/common/lib/domains'; import { arePathsEqual } from '@studio/common/lib/fs-utils'; import { encodePassword } from '@studio/common/lib/passwords'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { readCliConfig, saveCliConfig, unlockCliConfig, SiteData } from 'cli/lib/cli-config/core'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; @@ -73,6 +74,7 @@ describe( 'CLI: studio site set', () => { pmId: 0, status: 'online', pid: 12345, + runtime: SITE_RUNTIME_PLAYGROUND, }; beforeEach( () => { @@ -95,6 +97,7 @@ describe( 'CLI: studio site set', () => { } ); afterEach( () => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); } ); @@ -132,6 +135,16 @@ describe( 'CLI: studio site set', () => { ); } ); + it( 'should throw when PHP version is not supported by the native PHP runtime', async () => { + vi.stubEnv( 'STUDIO_RUNTIME', SITE_RUNTIME_NATIVE_PHP ); + + await expect( runCommand( testSitePath, { php: '8.1' } ) ).rejects.toThrow( + 'PHP 8.1 is not supported by the native PHP runtime. Supported versions: 8.5, 8.4, 8.3, 8.2.' + ); + + expect( saveCliConfig ).not.toHaveBeenCalled(); + } ); + it( 'should allow enabling HTTPS when domain is being set', async () => { await runCommand( testSitePath, { domain: 'new.local', https: true } ); diff --git a/apps/cli/commands/site/tests/start.test.ts b/apps/cli/commands/site/tests/start.test.ts index 8b9a96913b..c7a80c21b8 100644 --- a/apps/cli/commands/site/tests/start.test.ts +++ b/apps/cli/commands/site/tests/start.test.ts @@ -1,3 +1,4 @@ +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { SiteData } from 'cli/lib/cli-config/core'; import { @@ -46,6 +47,7 @@ describe( 'CLI: studio site start', () => { pmId: 0, pid: 12345, status: 'online', + runtime: SITE_RUNTIME_PLAYGROUND, }; beforeEach( () => { diff --git a/apps/cli/commands/site/tests/status.test.ts b/apps/cli/commands/site/tests/status.test.ts index 8351a6a8d5..c5a2dd93c2 100644 --- a/apps/cli/commands/site/tests/status.test.ts +++ b/apps/cli/commands/site/tests/status.test.ts @@ -1,4 +1,5 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { getSiteByFolder, getSiteUrl } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; @@ -101,6 +102,7 @@ describe( 'CLI: studio site status', () => { pmId: 0, status: 'online', pid: 12345, + runtime: SITE_RUNTIME_PLAYGROUND, } ); const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); diff --git a/apps/cli/commands/site/tests/stop.test.ts b/apps/cli/commands/site/tests/stop.test.ts index 2df7e4a7ea..7a9b760e57 100644 --- a/apps/cli/commands/site/tests/stop.test.ts +++ b/apps/cli/commands/site/tests/stop.test.ts @@ -1,3 +1,4 @@ +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { SiteData, readCliConfig, saveCliConfig } from 'cli/lib/cli-config/core'; import { @@ -53,6 +54,7 @@ describe( 'CLI: studio site stop', () => { pmId: 0, pid: 12345, status: 'online', + runtime: SITE_RUNTIME_PLAYGROUND, }; beforeEach( () => { @@ -215,6 +217,7 @@ describe( 'CLI: studio site stop --all', () => { pmId: 0, pid: 12345, status: 'online', + runtime: SITE_RUNTIME_PLAYGROUND, }; beforeEach( () => { diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index 738394d467..23d1d7d48a 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,6 +1,7 @@ import { spawn } from 'node:child_process'; import { writeStudioMuPluginsForNativePhpRuntime } from '@studio/common/lib/mu-plugins'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; import yargsParser from 'yargs-parser'; @@ -9,6 +10,7 @@ import { getSiteByFolder } from 'cli/lib/cli-config/sites'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getPhpBinaryPath, getWpCliPharPath } from 'cli/lib/dependency-management/paths'; import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; +import { getSiteRuntime } from 'cli/lib/feature-flags'; import { getDefaultPhpArgs } from 'cli/lib/native-php'; import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; @@ -46,11 +48,6 @@ enum Mode { async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promise< void > { const phpVersion = resolveNativePhpVersion( site.phpVersion ); - if ( phpVersion !== site.phpVersion ) { - logger.reportWarning( - `PHP ${ site.phpVersion } is not available for native PHP. Using PHP ${ phpVersion } instead.` - ); - } await ensurePhpBinaryAvailable( phpVersion ); await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating ); // Don't apply open_basedir or disable_functions to the WP-CLI process @@ -88,9 +85,11 @@ export async function runCommand( args: string[], options: { phpVersion?: string } = {} ): Promise< void > { + const runtime = getSiteRuntime(); + // Handle global WP-CLI commands that don't require a site path (--studio-no-path) if ( mode === Mode.GLOBAL ) { - await using command = await runGlobalWpCliCommand( args ); + await using command = await runGlobalWpCliCommand( args, { runtime } ); await pipePHPResponse( command.response ); process.exitCode = await command.response.exitCode; @@ -100,7 +99,7 @@ export async function runCommand( const site = await getSiteByFolder( siteFolder ); - if ( site.runtime === 'native-php' ) { + if ( runtime === SITE_RUNTIME_NATIVE_PHP ) { await runNativePhpWpCliCommand( site, args ); return; } @@ -118,7 +117,8 @@ export async function runCommand( try { await connectToDaemon(); - if ( await isServerRunning( site.id ) ) { + const runningProcess = await isServerRunning( site.id ); + if ( runningProcess?.runtime === SITE_RUNTIME_PLAYGROUND ) { const result = await sendWpCliCommand( site.id, args ); process.stdout.write( result.stdout ); process.stderr.write( result.stderr ); diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index ffc66d82c9..63b706ec58 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -6,8 +6,6 @@ import { LOCKFILE_WAIT_TIME, } from '@studio/common/constants'; import { siteDetailsSchema } from '@studio/common/lib/cli-events'; -export { siteRuntimeSchema } from '@studio/common/lib/cli-events'; -export type { SiteRuntime } from '@studio/common/lib/cli-events'; import { hideDirectoryOnWindows } from '@studio/common/lib/hide-dir-windows'; import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; import { getCliConfigPath, getConfigDirectory } from '@studio/common/lib/well-known-paths'; diff --git a/apps/cli/lib/daemon-client.ts b/apps/cli/lib/daemon-client.ts index abe7b301f5..5b7b1e5416 100644 --- a/apps/cli/lib/daemon-client.ts +++ b/apps/cli/lib/daemon-client.ts @@ -26,6 +26,7 @@ import { childMessageFromProcessManagerSchema, } from 'cli/lib/types/wordpress-server-ipc'; import type { SocketEvent } from '@studio/common/lib/cli-events'; +import type { SiteRuntime } from '@studio/common/lib/site-runtime'; const PROXY_PROCESS_NAME = 'studio-proxy'; const CONNECTION_TIMEOUT_MS = 10_000; @@ -306,18 +307,24 @@ const daemonStartProcessSuccessResponseSchema = z.object( { process: processDescriptionSchema, } ); +type StartProcessOptions = { + env?: NodeJS.ProcessEnv; + args?: string[]; + runtime?: SiteRuntime; +}; + export async function startProcess( processName: string, scriptPath: string, - env: NodeJS.ProcessEnv = process.env, - args: string[] = [] + options: StartProcessOptions = {} ): Promise< ProcessDescription > { const response = await sendDaemonRequest( { type: 'start-process', processName, scriptPath, - env, - args, + env: options.env ?? process.env, + args: options.args ?? [], + runtime: options.runtime, } ); return daemonStartProcessSuccessResponseSchema.parse( response ).process; } diff --git a/apps/cli/lib/dependency-management/php-binary.ts b/apps/cli/lib/dependency-management/php-binary.ts index 19703f5054..338d94be59 100644 --- a/apps/cli/lib/dependency-management/php-binary.ts +++ b/apps/cli/lib/dependency-management/php-binary.ts @@ -23,11 +23,6 @@ export async function ensurePhpBinaryAvailable( onProgress?: ( downloaded: number, total: number ) => void ): Promise< void > { const nativePhpVersion = resolveNativePhpVersion( version ); - if ( nativePhpVersion !== version ) { - console.warn( - `Warning: PHP ${ version } is not available for native PHP. Using PHP ${ nativePhpVersion } instead.` - ); - } if ( ! fs.existsSync( getPhpBinaryPath( nativePhpVersion ) ) ) { await downloadAndInstall( nativePhpVersion, onProgress ); diff --git a/apps/cli/lib/dependency-management/tests/php-binary.test.ts b/apps/cli/lib/dependency-management/tests/php-binary.test.ts index 2df89eebb3..85f25855b1 100644 --- a/apps/cli/lib/dependency-management/tests/php-binary.test.ts +++ b/apps/cli/lib/dependency-management/tests/php-binary.test.ts @@ -1,5 +1,4 @@ import { - MinimumNativePhpSupportedVersion, resolveNativePhpVersion, getConfiguredPhpBinaryVersion, getPhpBinaryDownloadInfo, @@ -36,8 +35,8 @@ describe( 'getPhpBinaryDownloadInfo', () => { expect( getPhpBinaryDownloadInfo( '8.4', 'aix', 'x64' ) ).toBeUndefined(); } ); - it( 'coerces older supported PHP versions to the minimum native version', () => { - expect( resolveNativePhpVersion( '8.0' ) ).toBe( MinimumNativePhpSupportedVersion ); + it( 'resolves older supported PHP versions to the closest native PHP version', () => { + expect( resolveNativePhpVersion( '8.0' ) ).toBe( '8.2' ); } ); } ); diff --git a/apps/cli/lib/feature-flags.ts b/apps/cli/lib/feature-flags.ts new file mode 100644 index 0000000000..72ed0e194d --- /dev/null +++ b/apps/cli/lib/feature-flags.ts @@ -0,0 +1,9 @@ +import { + SITE_RUNTIME_PLAYGROUND, + siteRuntimeSchema, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; + +export function getSiteRuntime(): SiteRuntime { + return siteRuntimeSchema.catch( SITE_RUNTIME_PLAYGROUND ).parse( process.env.STUDIO_RUNTIME ); +} diff --git a/apps/cli/lib/import-export/import/importers/importer.ts b/apps/cli/lib/import-export/import/importers/importer.ts index 5a5f544f74..be9dc6e86d 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -5,12 +5,16 @@ import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { generateBackupFilename } from '@studio/common/lib/generate-backup-filename'; import { ImportEvents } from '@studio/common/lib/import-export-events'; import { serializePlugins } from '@studio/common/lib/serialize-plugins'; -import { SupportedPHPVersionsList } from '@studio/common/types/php-versions'; +import { type SupportedPHPVersion } from '@studio/common/types/php-versions'; import { __, sprintf } from '@wordpress/i18n'; import { move } from 'fs-extra'; import semver from 'semver'; import trash from 'trash'; import { SiteData } from 'cli/lib/cli-config/core'; +import { + getRecommendedPhpVersionForSiteRuntime, + getSupportedPhpVersionsForSiteRuntime, +} from 'cli/lib/php-versions'; import { runWpCliCommand } from 'cli/lib/run-wp-cli-command'; import { ImportExportEventEmitter } from '../../events'; import { BackupContents, MetaFileData } from '../types'; @@ -275,17 +279,21 @@ abstract class BaseBackupImporter extends BaseImporter { } protected parsePhpVersion( version: string | undefined ): string { + const defaultPhpVersion = getRecommendedPhpVersionForSiteRuntime(); if ( ! version ) { - return DEFAULT_PHP_VERSION; + return defaultPhpVersion; } const phpVersion = semver.coerce( version ); if ( ! phpVersion ) { - return DEFAULT_PHP_VERSION; + return defaultPhpVersion; } const parsedVersion = `${ phpVersion.major }.${ phpVersion.minor }`; + const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); - return SupportedPHPVersionsList.includes( parsedVersion ) ? parsedVersion : DEFAULT_PHP_VERSION; + return supportedPhpVersions.includes( parsedVersion as SupportedPHPVersion ) + ? parsedVersion + : defaultPhpVersion; } } diff --git a/apps/cli/lib/php-versions.ts b/apps/cli/lib/php-versions.ts new file mode 100644 index 0000000000..5f3490370a --- /dev/null +++ b/apps/cli/lib/php-versions.ts @@ -0,0 +1,42 @@ +import { SITE_RUNTIME_NATIVE_PHP, type SiteRuntime } from '@studio/common/lib/site-runtime'; +import { + getRecommendedPHPVersionForRuntime, + getSupportedPHPVersionsForRuntime, + type SupportedPHPVersion, +} from '@studio/common/types/php-versions'; +import { __, sprintf } from '@wordpress/i18n'; +import { getSiteRuntime } from 'cli/lib/feature-flags'; +import { LoggerError } from 'cli/logger'; + +export function getSupportedPhpVersionsForSiteRuntime( + runtime: SiteRuntime = getSiteRuntime() +): readonly SupportedPHPVersion[] { + return getSupportedPHPVersionsForRuntime( runtime ); +} + +export function getRecommendedPhpVersionForSiteRuntime( + runtime: SiteRuntime = getSiteRuntime() +): SupportedPHPVersion { + return getRecommendedPHPVersionForRuntime( runtime ); +} + +export function validatePhpVersionForSiteRuntime( + version: string, + runtime: SiteRuntime = getSiteRuntime() +): SupportedPHPVersion { + const supportedVersions = getSupportedPhpVersionsForSiteRuntime( runtime ); + if ( supportedVersions.includes( version as SupportedPHPVersion ) ) { + return version as SupportedPHPVersion; + } + + const runtimeLabel = + runtime === SITE_RUNTIME_NATIVE_PHP ? __( 'native PHP' ) : __( 'Playground' ); + throw new LoggerError( + sprintf( + __( 'PHP %1$s is not supported by the %2$s runtime. Supported versions: %3$s.' ), + version, + runtimeLabel, + supportedVersions.join( ', ' ) + ) + ); +} diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index f8ccdf438e..459d039873 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -19,6 +19,7 @@ import { writeStudioMuPluginsForNativePhpRuntime, } from '@studio/common/lib/mu-plugins'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; +import { SITE_RUNTIME_NATIVE_PHP, type SiteRuntime } from '@studio/common/lib/site-runtime'; import { LatestSupportedPHPVersion } from '@studio/common/types/php-versions'; import { __ } from '@wordpress/i18n'; import { setupPlatformLevelMuPlugins } from '@wp-playground/wordpress'; @@ -27,6 +28,7 @@ import { getSqliteCommandPath, getWpCliPharPath, } from 'cli/lib/dependency-management/paths'; +import { getSiteRuntime } from 'cli/lib/feature-flags'; import { validatePhpVersion } from 'cli/lib/utils'; import { getDefaultPhpArgs } from './native-php'; import type { SiteData } from 'cli/lib/cli-config/core'; @@ -181,7 +183,7 @@ export async function runWpCliCommand( ): Promise< DisposableWpCliResponse > { const siteFolder = site.path; - if ( site.runtime === 'native-php' ) { + if ( getSiteRuntime() === SITE_RUNTIME_NATIVE_PHP ) { return runNativeWpCliCommand( site, args, options ); } @@ -288,7 +290,7 @@ async function runNativeGlobalWpCliCommand( args: string[] ): Promise< Disposabl } type RunGlobalWpCliCommandOptions = { - runtime?: 'wasm' | 'native-php'; + runtime?: SiteRuntime; }; /** @@ -299,7 +301,7 @@ export async function runGlobalWpCliCommand( args: string[], options: RunGlobalWpCliCommandOptions = {} ): Promise< DisposableWpCliResponse > { - if ( options.runtime === 'native-php' ) { + if ( options.runtime === SITE_RUNTIME_NATIVE_PHP ) { return runNativeGlobalWpCliCommand( args ); } diff --git a/apps/cli/lib/site-utils.ts b/apps/cli/lib/site-utils.ts index ffbd768c9e..739ca96e02 100644 --- a/apps/cli/lib/site-utils.ts +++ b/apps/cli/lib/site-utils.ts @@ -1,4 +1,5 @@ import { decodePassword } from '@studio/common/lib/passwords'; +import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { openBrowser } from 'cli/lib/browser'; @@ -11,6 +12,7 @@ import { startProxyProcess, stopProxyProcess, } from 'cli/lib/daemon-client'; +import { getSiteRuntime } from 'cli/lib/feature-flags'; import { addDomainToHosts } from 'cli/lib/hosts-file'; import { isServerRunning, SITE_PROCESS_PREFIX } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; @@ -36,6 +38,10 @@ export async function startProxyIfNeeded( logger: Logger< LoggerAction > ): Prom * the studio-auto-login mu-plugin expects. */ export function buildAutoLoginUrl( siteUrl: string, redirectTo?: string ): string { + if ( getSiteRuntime() === SITE_RUNTIME_NATIVE_PHP ) { + return `${ siteUrl }/`; + } + const base = `${ siteUrl }/studio-auto-login`; if ( ! redirectTo ) { return base; @@ -55,9 +61,7 @@ export async function openSiteInBrowser( site: SiteData ): Promise< void > { try { const targetPath = site.landingPage || '/wp-admin/'; const target = new URL( targetPath, siteUrl ).toString(); - const autoLoginUrl = - site.runtime === 'playground' ? buildAutoLoginUrl( siteUrl, target ) : `${ siteUrl }/`; - await openBrowser( autoLoginUrl ); + await openBrowser( buildAutoLoginUrl( siteUrl, target ) ); } catch ( error ) { // Silently fail if browser can't be opened } diff --git a/apps/cli/lib/tests/daemon-client.test.ts b/apps/cli/lib/tests/daemon-client.test.ts index 9a8d4d7d7b..b801b4bb2d 100644 --- a/apps/cli/lib/tests/daemon-client.test.ts +++ b/apps/cli/lib/tests/daemon-client.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'events'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const createConnectionMock = vi.fn(); @@ -189,6 +190,7 @@ describe( 'process manager daemon client', () => { pmId: 2, status: 'online', pid: 2000, + runtime: SITE_RUNTIME_PLAYGROUND, } ); } ); diff --git a/apps/cli/lib/tests/feature-flags.test.ts b/apps/cli/lib/tests/feature-flags.test.ts new file mode 100644 index 0000000000..bf60a257bc --- /dev/null +++ b/apps/cli/lib/tests/feature-flags.test.ts @@ -0,0 +1,33 @@ +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getSiteRuntime } from 'cli/lib/feature-flags'; + +describe( 'getSiteRuntime', () => { + const originalValue = process.env.STUDIO_RUNTIME; + + beforeEach( () => { + delete process.env.STUDIO_RUNTIME; + } ); + + afterEach( () => { + if ( originalValue === undefined ) { + delete process.env.STUDIO_RUNTIME; + } else { + process.env.STUDIO_RUNTIME = originalValue; + } + } ); + + it( 'defaults to playground when the env var is unset', () => { + expect( getSiteRuntime() ).toBe( SITE_RUNTIME_PLAYGROUND ); + } ); + + it( 'returns native-php when STUDIO_RUNTIME=native-php', () => { + process.env.STUDIO_RUNTIME = SITE_RUNTIME_NATIVE_PHP; + expect( getSiteRuntime() ).toBe( SITE_RUNTIME_NATIVE_PHP ); + } ); + + it( 'falls back to playground for unknown values', () => { + process.env.STUDIO_RUNTIME = 'nonsense'; + expect( getSiteRuntime() ).toBe( SITE_RUNTIME_PLAYGROUND ); + } ); +} ); diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index 8206a88f01..9bf423f3be 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -1,10 +1,13 @@ import { EventEmitter } from 'events'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { vi } from 'vitest'; import { SiteData } from 'cli/lib/cli-config/core'; import * as daemonClient from 'cli/lib/daemon-client'; import { DaemonBus } from 'cli/lib/daemon-client'; +import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; import { isServerRunning, + sendWpCliCommand, startWordPressServer, stopWordPressServer, } from 'cli/lib/wordpress-server-manager'; @@ -27,7 +30,7 @@ describe( 'WordPress Server Manager', () => { name: 'Test Site', path: '/test/site/path', port: 8881, - phpVersion: '8.0', + phpVersion: '8.4', adminUsername: 'admin', adminPassword: 'password123', running: false, @@ -38,6 +41,7 @@ describe( 'WordPress Server Manager', () => { pmId: 5, status: 'online', pid: 12345, + runtime: SITE_RUNTIME_PLAYGROUND, } as const; let mockBus: EventEmitter; @@ -55,6 +59,7 @@ describe( 'WordPress Server Manager', () => { } ); afterEach( () => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); } ); @@ -91,6 +96,7 @@ describe( 'WordPress Server Manager', () => { pmId: 5, status: 'online', pid: 12345, + runtime: SITE_RUNTIME_PLAYGROUND, } as const; vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( mockProcess ); @@ -103,6 +109,22 @@ describe( 'WordPress Server Manager', () => { expect( result ).toEqual( mockProcess ); } ); + it( 'should preserve runtime from a running site process', async () => { + const mockProcess = { + name: 'studio-site-test-site-id', + pmId: 5, + status: 'online', + pid: 12345, + runtime: SITE_RUNTIME_NATIVE_PHP, + } as const; + + vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( mockProcess ); + + const result = await isServerRunning( 'test-site-id' ); + + expect( result ).toEqual( mockProcess ); + } ); + it( 'should return undefined when process is not running', async () => { vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( undefined ); @@ -120,31 +142,56 @@ describe( 'WordPress Server Manager', () => { expect( vi.mocked( daemonClient.startProcess ) ).toHaveBeenCalledWith( 'studio-site-test-site-id', - expect.stringMatching( /playground-server-child\.mjs$/ ) + expect.stringMatching( /playground-server-child\.mjs$/ ), + { runtime: SITE_RUNTIME_PLAYGROUND } ); expect( result ).toEqual( mockProcessDescription ); } ); - it( 'should use the native-php child script when site.runtime is native-php', async () => { + it( 'should use the native-php child script when STUDIO_RUNTIME is native-php', async () => { + vi.stubEnv( 'STUDIO_RUNTIME', SITE_RUNTIME_NATIVE_PHP ); setupIpcMocks(); - await startWordPressServer( { ...mockSiteData, runtime: 'native-php' }, mockLogger ); + await startWordPressServer( mockSiteData, mockLogger ); expect( vi.mocked( daemonClient.startProcess ) ).toHaveBeenCalledWith( 'studio-site-test-site-id', - expect.stringMatching( /php-server-child\.mjs$/ ) + expect.stringMatching( /php-server-child\.mjs$/ ), + { runtime: SITE_RUNTIME_NATIVE_PHP } ); } ); - it( 'should use the playground child script when site.runtime is undefined', async () => { + it( 'should resolve older stored PHP versions to the closest native PHP version when starting native PHP', async () => { + vi.stubEnv( 'STUDIO_RUNTIME', SITE_RUNTIME_NATIVE_PHP ); setupIpcMocks(); - await startWordPressServer( { ...mockSiteData, runtime: undefined }, mockLogger ); + await startWordPressServer( { ...mockSiteData, phpVersion: '7.4' }, mockLogger ); + + expect( vi.mocked( ensurePhpBinaryAvailable ) ).toHaveBeenCalledWith( + '8.2', + expect.any( Function ) + ); + expect( vi.mocked( daemonClient.sendMessageToProcess ) ).toHaveBeenCalledWith( + mockProcessDescription.pmId, + expect.objectContaining( { + topic: 'start-server', + data: expect.objectContaining( { + config: expect.objectContaining( { phpVersion: '8.2' } ), + } ), + } ) + ); + } ); + + it( 'should use the playground child script when STUDIO_RUNTIME is unset', async () => { + setupIpcMocks(); + + await startWordPressServer( mockSiteData, mockLogger ); expect( vi.mocked( daemonClient.startProcess ) ).toHaveBeenCalledWith( 'studio-site-test-site-id', - expect.stringMatching( /playground-server-child\.mjs$/ ) + expect.stringMatching( /playground-server-child\.mjs$/ ), + { runtime: SITE_RUNTIME_PLAYGROUND } ); } ); @@ -219,6 +266,53 @@ describe( 'WordPress Server Manager', () => { } ); } ); + describe( 'sendWpCliCommand', () => { + it( 'should send WP-CLI commands to a running playground process', async () => { + vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( { + name: 'studio-site-test-site-id', + pmId: 1, + status: 'online', + pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, + } ); + + vi.mocked( daemonClient.sendMessageToProcess ).mockImplementation( ( processId, message ) => { + setImmediate( () => { + mockBus.emit( 'process-message', { + process: { name: 'studio-site-test-site-id', pm_id: processId }, + raw: { + topic: 'result', + originalMessageId: message.messageId, + result: { stdout: 'ok', stderr: '', exitCode: 0 }, + }, + } ); + } ); + + return Promise.resolve(); + } ); + + await expect( + sendWpCliCommand( 'test-site-id', [ 'option', 'get', 'siteurl' ] ) + ).resolves.toEqual( { stdout: 'ok', stderr: '', exitCode: 0 } ); + } ); + + it( 'should not send WP-CLI commands to a running native PHP process', async () => { + vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( { + name: 'studio-site-test-site-id', + pmId: 1, + status: 'online', + pid: 1234, + runtime: SITE_RUNTIME_NATIVE_PHP, + } ); + + await expect( + sendWpCliCommand( 'test-site-id', [ 'option', 'get', 'siteurl' ] ) + ).rejects.toThrow( 'Running WordPress server does not support WP-CLI commands' ); + + expect( vi.mocked( daemonClient.sendMessageToProcess ) ).not.toHaveBeenCalled(); + } ); + } ); + describe( 'stopWordPressServer', () => { it( 'should stop WordPress server with correct process name', async () => { vi.mocked( daemonClient.isProcessRunning ).mockResolvedValue( { @@ -226,6 +320,7 @@ describe( 'WordPress Server Manager', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( daemonClient.sendMessageToProcess ).mockImplementation( ( processId, message ) => { @@ -262,6 +357,7 @@ describe( 'WordPress Server Manager', () => { pmId: 1, status: 'online', pid: 1234, + runtime: SITE_RUNTIME_PLAYGROUND, } ); vi.mocked( daemonClient.sendMessageToProcess ).mockRejectedValue( new Error( 'Failed to send stop message' ) diff --git a/apps/cli/lib/types/process-manager-ipc.ts b/apps/cli/lib/types/process-manager-ipc.ts index 997feca051..597cb8396e 100644 --- a/apps/cli/lib/types/process-manager-ipc.ts +++ b/apps/cli/lib/types/process-manager-ipc.ts @@ -1,3 +1,4 @@ +import { SITE_RUNTIME_PLAYGROUND, siteRuntimeSchema } from '@studio/common/lib/site-runtime'; import { z } from 'zod'; import { childMessageFromProcessManagerSchema, @@ -8,6 +9,7 @@ import { const processDescriptionSchemaBase = z.object( { name: z.string(), pmId: z.number(), + runtime: siteRuntimeSchema.default( SITE_RUNTIME_PLAYGROUND ), } ); const processDescriptionSchemaRunning = processDescriptionSchemaBase.extend( { status: z.literal( 'online' ), @@ -33,6 +35,7 @@ const daemonRequestStartProcessSchema = z.object( { scriptPath: z.string(), env: z.record( z.string(), z.union( [ z.string(), z.undefined() ] ) ).optional(), args: z.array( z.string() ).optional(), + runtime: siteRuntimeSchema.optional(), } ); const daemonRequestStopProcessSchema = z.object( { diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 456aa7b84e..a50677c2a4 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -12,10 +12,15 @@ import { PLAYGROUND_CLI_MAX_TIMEOUT, } from '@studio/common/constants'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; +import { + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; import { __ } from '@wordpress/i18n'; import { z } from 'zod'; -import { SiteData, SiteRuntime } from 'cli/lib/cli-config/core'; +import { SiteData } from 'cli/lib/cli-config/core'; import { isProcessRunning, startProcess, @@ -25,10 +30,10 @@ import { sendMessageToProcess, } from 'cli/lib/daemon-client'; import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; +import { getSiteRuntime } from 'cli/lib/feature-flags'; import { ProcessDescription } from 'cli/lib/types/process-manager-ipc'; import { ServerConfig, ManagerMessagePayload } from 'cli/lib/types/wordpress-server-ipc'; import { Logger } from 'cli/logger'; -import { validatePhpVersion } from './utils'; import type { WordPressInstallMode } from '@wp-playground/wordpress'; export const SITE_PROCESS_PREFIX = 'studio-site-'; @@ -43,19 +48,31 @@ export function getProcessName( siteId: string ): string { return `${ SITE_PROCESS_PREFIX }${ siteId }`; } -function getChildScriptPath( runtime: SiteRuntime | undefined ): string { - switch ( runtime ?? 'playground' ) { - case 'native-php': +function getChildScriptPath( runtime: SiteRuntime ): string { + switch ( runtime ) { + case SITE_RUNTIME_NATIVE_PHP: return path.resolve( import.meta.dirname, 'php-server-child.mjs' ); - case 'playground': + case SITE_RUNTIME_PLAYGROUND: default: return path.resolve( import.meta.dirname, 'playground-server-child.mjs' ); } } +function withSiteRuntime( processDescription: ProcessDescription ): ProcessDescription { + return { + ...processDescription, + runtime: processDescription.runtime ?? SITE_RUNTIME_PLAYGROUND, + }; +} + export async function isServerRunning( siteId: string ): Promise< ProcessDescription | undefined > { const processName = getProcessName( siteId ); - return isProcessRunning( processName ); + const runningProcess = await isProcessRunning( processName ); + return runningProcess ? withSiteRuntime( runningProcess ) : undefined; +} + +function canReuseProcessForWpCli( processDescription: ProcessDescription ): boolean { + return processDescription.runtime === SITE_RUNTIME_PLAYGROUND; } /** @@ -79,6 +96,7 @@ export interface StartServerOptions { function buildServerConfig( site: SiteData, + runtime: SiteRuntime, options?: Partial< StartServerOptions & RunBlueprintOptions > ): ServerConfig { const serverConfig: ServerConfig = { @@ -86,8 +104,8 @@ function buildServerConfig( sitePath: site.path, port: site.port, phpVersion: - site.runtime === 'native-php' - ? resolveNativePhpVersion( validatePhpVersion( site.phpVersion ) ) + runtime === SITE_RUNTIME_NATIVE_PHP + ? resolveNativePhpVersion( site.phpVersion ) : site.phpVersion, siteTitle: site.name, }; @@ -165,24 +183,19 @@ function buildServerConfig( async function ensurePhpBinaryAvailableIfNeeded( site: SiteData, - logger: Logger< string > + logger: Logger< string >, + runtime: SiteRuntime ): Promise< void > { - if ( site.runtime === 'native-php' && site.phpVersion ) { + if ( runtime === SITE_RUNTIME_NATIVE_PHP ) { + const phpVersion = resolveNativePhpVersion( site.phpVersion ); logger.reportStart( SiteCommandLoggerAction.ENSURE_PHP_BINARY, - `Checking PHP ${ site.phpVersion } binary…` + `Checking PHP ${ phpVersion } binary…` ); - const phpVersion = validatePhpVersion( site.phpVersion ); - const nativePhpVersion = resolveNativePhpVersion( phpVersion ); - if ( nativePhpVersion !== phpVersion ) { - logger.reportWarning( - `PHP ${ phpVersion } is not available for native PHP. Using PHP ${ nativePhpVersion } instead.` - ); - } - await ensurePhpBinaryAvailable( nativePhpVersion, ( downloaded, total ) => { + await ensurePhpBinaryAvailable( phpVersion, ( downloaded, total ) => { const dl = ( downloaded / 1024 / 1024 ).toFixed( 1 ); const tot = total ? ` / ${ ( total / 1024 / 1024 ).toFixed( 1 ) } MB` : ''; - logger.reportProgress( `Downloading PHP ${ nativePhpVersion } (${ dl } MB${ tot })` ); + logger.reportProgress( `Downloading PHP ${ phpVersion } (${ dl } MB${ tot })` ); } ); } } @@ -206,20 +219,21 @@ export async function startWordPressServer( } } - await ensurePhpBinaryAvailableIfNeeded( site, logger ); + const runtime = getSiteRuntime(); + await ensurePhpBinaryAvailableIfNeeded( site, logger, runtime ); const startMessage = options?.blueprint ? __( 'Starting WordPress server and applying Blueprint…' ) : __( 'Starting WordPress server…' ); logger.reportStart( SiteCommandLoggerAction.START_SITE, startMessage ); - const wordPressServerChildPath = getChildScriptPath( site.runtime ); + const wordPressServerChildPath = getChildScriptPath( runtime ); const processName = getProcessName( site.id ); - const serverConfig = buildServerConfig( site, options ); + const serverConfig = buildServerConfig( site, runtime, options ); const readyOrExit = await subscribeForReadyOrExit( processName ); try { - const processDesc = await startProcess( processName, wordPressServerChildPath ); + const processDesc = await startProcess( processName, wordPressServerChildPath, { runtime } ); await readyOrExit.waitFor( processDesc.pmId ); await sendMessage( processDesc.pmId, @@ -231,7 +245,7 @@ export async function startWordPressServer( { logger } ); - return processDesc; + return withSiteRuntime( processDesc ); } finally { readyOrExit.dispose(); } @@ -529,16 +543,17 @@ export async function runBlueprint( logger: Logger< string >, options: RunBlueprintOptions ): Promise< void > { - await ensurePhpBinaryAvailableIfNeeded( site, logger ); + const runtime = getSiteRuntime(); + await ensurePhpBinaryAvailableIfNeeded( site, logger, runtime ); logger.reportStart( SiteCommandLoggerAction.APPLY_BLUEPRINT, __( 'Applying Blueprint…' ) ); - const wordPressServerChildPath = getChildScriptPath( site.runtime ); + const wordPressServerChildPath = getChildScriptPath( runtime ); const processName = getProcessName( site.id ); - const serverConfig = buildServerConfig( site, options ); + const serverConfig = buildServerConfig( site, runtime, options ); const readyOrExit = await subscribeForReadyOrExit( processName ); try { - const processDesc = await startProcess( processName, wordPressServerChildPath ); + const processDesc = await startProcess( processName, wordPressServerChildPath, { runtime } ); try { await readyOrExit.waitFor( processDesc.pmId ); await sendMessage( @@ -570,12 +585,16 @@ export async function sendWpCliCommand( args: string[] ): Promise< z.infer< typeof wpCliResultSchema > > { const processName = getProcessName( siteId ); - const runningProcess = await isProcessRunning( processName ); + const runningProcess = await isServerRunning( siteId ); if ( ! runningProcess ) { throw new Error( `WordPress server is not running` ); } + if ( ! canReuseProcessForWpCli( runningProcess ) ) { + throw new Error( `Running WordPress server does not support WP-CLI commands` ); + } + const result = await sendMessage( runningProcess.pmId, processName, { topic: 'wp-cli-command', data: { args }, diff --git a/apps/cli/process-manager-daemon.ts b/apps/cli/process-manager-daemon.ts index 40a8d8e002..5ca792fbd1 100644 --- a/apps/cli/process-manager-daemon.ts +++ b/apps/cli/process-manager-daemon.ts @@ -3,6 +3,7 @@ import fs, { createWriteStream, WriteStream } from 'fs'; import net from 'net'; import path from 'path'; import readline from 'readline'; +import { SITE_RUNTIME_PLAYGROUND, type SiteRuntime } from '@studio/common/lib/site-runtime'; import semver from 'semver'; import { PROCESS_MANAGER_LOGS_DIR, @@ -34,6 +35,8 @@ type ManagedProcessBase = { scriptPath: string; args: string[]; env: NodeJS.ProcessEnv; + // Used by clients to decide whether WP-CLI commands can run through this process. + runtime: SiteRuntime; child: ChildProcess; stdoutLogPath: string; stderrLogPath: string; @@ -150,7 +153,8 @@ export class ProcessManagerDaemon { request.processName, request.scriptPath, request.env ?? {}, - request.args ?? [] + request.args ?? [], + request.runtime ); return { type: 'result', @@ -200,7 +204,8 @@ export class ProcessManagerDaemon { processName: string, scriptPath: string, env: NodeJS.ProcessEnv, - args: string[] + args: string[], + runtime: SiteRuntime = SITE_RUNTIME_PLAYGROUND ): Promise< ProcessDescription > { const existing = this.getManagedProcessByName( processName ); if ( existing && existing.status === 'online' ) { @@ -227,6 +232,7 @@ export class ProcessManagerDaemon { scriptPath, args, env, + runtime, child, // `child.pid` is only undefined if there's an error, in which case our error handler // immediately changes the status and deletes the process from the map @@ -404,6 +410,7 @@ export class ProcessManagerDaemon { name: managedProcess.name, pmId: managedProcess.pmId, status: managedProcess.status, + runtime: managedProcess.runtime, }; } @@ -412,6 +419,7 @@ export class ProcessManagerDaemon { pmId: managedProcess.pmId, status: managedProcess.status, pid: managedProcess.pid, + runtime: managedProcess.runtime, }; } diff --git a/apps/cli/tests/daemon.test.ts b/apps/cli/tests/daemon.test.ts index 85baef04b2..b8e9cb6d9e 100644 --- a/apps/cli/tests/daemon.test.ts +++ b/apps/cli/tests/daemon.test.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { PassThrough } from 'stream'; +import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const testProcessName = 'studio-site-process-manager-test'; @@ -77,6 +78,7 @@ describe( 'ProcessManagerDaemon', () => { scriptPath: '/tmp/test-child.js', env: {}, args: [], + runtime: SITE_RUNTIME_NATIVE_PHP, } ); expect( response ).toEqual( @@ -87,6 +89,7 @@ describe( 'ProcessManagerDaemon', () => { name: testProcessName, status: 'online', pid: 4321, + runtime: SITE_RUNTIME_NATIVE_PHP, } ), } ), } ) diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index 18f8190f65..997c537048 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -49,6 +49,7 @@ export class E2ESession { snapshots: [], betaFeatures: { studioSitesCli: true, + nativePhpRuntime: process.env.STUDIO_RUNTIME === 'native-php', }, }; diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index 013e366bfc..cca39b6e89 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -1,23 +1,27 @@ import { decodePassword } from '@studio/common/lib/passwords'; +import { getClosestNativePhpVersion } from '@studio/common/types/php-versions'; import { DropdownMenu, MenuGroup, Button, + Icon, __experimentalHeading as Heading, } from '@wordpress/components'; -import { moreVertical } from '@wordpress/icons'; +import { sprintf } from '@wordpress/i18n'; +import { cautionFilled, moreVertical } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import StudioButton from 'src/components/button'; import { CopyTextButton } from 'src/components/copy-text-button'; import { LearnHowLink } from 'src/components/learn-more'; import { SettingsMenuItem } from 'src/components/settings-site-menu'; +import { Tooltip } from 'src/components/tooltip'; import { useDeleteSite } from 'src/hooks/use-delete-site'; import { useGetWpVersion } from 'src/hooks/use-get-wp-version'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { getIpcApi } from 'src/lib/get-ipc-api'; import EditSiteDetails from 'src/modules/site-settings/edit-site-details'; -import { useAppDispatch } from 'src/stores'; +import { useAppDispatch, useRootSelector } from 'src/stores'; import { certificateTrustApi, useCheckCertificateTrustQuery, @@ -42,6 +46,9 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) const dispatch = useAppDispatch(); const { __ } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); + const isNativePhpRuntime = useRootSelector( + ( state ) => state.betaFeatures.features.nativePhpRuntime + ); const username = selectedSite.adminUsername || 'admin'; // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); @@ -52,6 +59,21 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) ? `${ selectedSite.customDomain }` : `localhost:${ selectedSite.port }`; const protocol = selectedSite.customDomain && selectedSite.enableHttps ? 'https' : 'http'; + const resolvedNativePhpVersion = isNativePhpRuntime + ? getClosestNativePhpVersion( selectedSite.phpVersion ) + : undefined; + const showNativePhpVersionWarning = + isNativePhpRuntime && + resolvedNativePhpVersion !== undefined && + resolvedNativePhpVersion !== selectedSite.phpVersion; + const nativePhpVersionWarning = + showNativePhpVersionWarning && resolvedNativePhpVersion + ? sprintf( + __( 'Native PHP does not support PHP %1$s. This site will run with PHP %2$s instead.' ), + selectedSite.phpVersion, + resolvedNativePhpVersion + ) + : undefined; const handleTrustCertificate = async () => { await getIpcApi().trustCertificate(); @@ -163,8 +185,20 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) { wpVersion } -
+
{ selectedSite.phpVersion } + { nativePhpVersionWarning && ( + + + + + + ) }
diff --git a/apps/studio/src/components/tests/content-tab-settings.test.tsx b/apps/studio/src/components/tests/content-tab-settings.test.tsx index 058a4fba1e..9f2b31a44a 100644 --- a/apps/studio/src/components/tests/content-tab-settings.test.tsx +++ b/apps/studio/src/components/tests/content-tab-settings.test.tsx @@ -45,11 +45,11 @@ let testStore = createTestStore( { } ); // We need to create a new store each time to avoid reducer conflicts -function createCustomTestStore() { +function createCustomTestStore( nativePhpRuntime = false ) { const store = createTestStore( { preloadedState: { betaFeatures: { - features: {}, + features: { nativePhpRuntime }, loading: false, }, }, @@ -291,6 +291,26 @@ describe( 'ContentTabSettings', () => { } ); describe( 'PHP version', () => { + it( 'shows a native PHP fallback warning for unsupported stored PHP versions', async () => { + const user = userEvent.setup(); + testStore = createCustomTestStore( true ); + + renderWithProvider( + + ); + + await waitFor( () => { + expect( getAllCustomDomains ).toHaveBeenCalled(); + } ); + await user.hover( screen.getByRole( 'img', { name: 'PHP version warning' } ) ); + + expect( + await screen.findByText( + 'Native PHP does not support PHP 7.4. This site will run with PHP 8.2 instead.' + ) + ).toBeVisible(); + } ); + it( 'changes PHP version when site is not running', async () => { const user = userEvent.setup(); diff --git a/apps/studio/src/index.ts b/apps/studio/src/index.ts index e7bc8becfb..e9c146ef0d 100644 --- a/apps/studio/src/index.ts +++ b/apps/studio/src/index.ts @@ -29,6 +29,7 @@ import { hasActiveSyncOperations, hasUploadingPushOperations, } from 'src/lib/active-sync-operations'; +import { getBetaFeatures } from 'src/lib/beta-features'; import { bumpStat, bumpAggregatedUniqueStat, @@ -341,6 +342,7 @@ async function appBoot() { await runMigrations( migrations ).catch( Sentry.captureException ); await setupSentryUserId(); + await getBetaFeatures(); // Fetch data from CLI and subscribe to CLI events before starting the user data // watcher. The watcher can trigger getMainWindow() which creates the window early, diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index f57b868f55..d9c4a12707 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -5,8 +5,6 @@ interface ShowNotificationOptions extends Electron.NotificationConstructorOption showIcon: boolean; } -type SiteRuntime = 'playground' | 'native-php'; - interface StoppedSiteDetails { running: false; @@ -46,7 +44,6 @@ interface StoppedSiteDetails { enableDebugDisplay?: boolean; sortOrder?: number; landingPage?: string; - runtime?: SiteRuntime; } interface StartedSiteDetails extends StoppedSiteDetails { @@ -102,8 +99,9 @@ interface FeatureFlags { enableStudioCodeUi: boolean; } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface BetaFeatures {} +interface BetaFeatures { + nativePhpRuntime?: boolean; +} interface AppGlobals extends FeatureFlags { platform: NodeJS.Platform; diff --git a/apps/studio/src/lib/beta-features.ts b/apps/studio/src/lib/beta-features.ts index a7770bbc3a..3432b41268 100644 --- a/apps/studio/src/lib/beta-features.ts +++ b/apps/studio/src/lib/beta-features.ts @@ -1,3 +1,5 @@ +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; +import { __ } from '@wordpress/i18n'; import { lockAppdata, unlockAppdata, loadUserData, saveUserData } from 'src/storage/user-data'; export interface BetaFeatureDefinition { @@ -10,14 +12,23 @@ export interface BetaFeatureDefinition { /** * Default values for beta features. */ -const BETA_FEATURE_DEFAULTS: Record< keyof BetaFeatures, boolean > = {}; +const BETA_FEATURE_DEFAULTS: Record< keyof BetaFeatures, boolean > = { + nativePhpRuntime: false, +}; /** * Returns beta feature definitions with translated labels and descriptions. * Must be called at runtime (not at module load) to ensure translations are loaded. */ export function getBetaFeaturesDefinition(): Record< keyof BetaFeatures, BetaFeatureDefinition > { - return {}; + return { + nativePhpRuntime: { + key: 'nativePhpRuntime', + label: __( 'Native PHP runtime' ), + default: BETA_FEATURE_DEFAULTS.nativePhpRuntime, + description: __( 'Run Studio sites with native PHP instead of Playground.' ), + }, + }; } function buildBetaFeatures( userData: BetaFeatures | undefined ): BetaFeatures { @@ -29,9 +40,17 @@ function buildBetaFeatures( userData: BetaFeatures | undefined ): BetaFeatures { return features; } +function applyBetaFeaturesToEnvironment( features: BetaFeatures ): void { + process.env.STUDIO_RUNTIME = features.nativePhpRuntime + ? SITE_RUNTIME_NATIVE_PHP + : SITE_RUNTIME_PLAYGROUND; +} + export async function getBetaFeatures(): Promise< BetaFeatures > { const userData = await loadUserData(); - return buildBetaFeatures( userData.betaFeatures ); + const betaFeatures = buildBetaFeatures( userData.betaFeatures ); + applyBetaFeaturesToEnvironment( betaFeatures ); + return betaFeatures; } export async function updateBetaFeature( @@ -42,10 +61,12 @@ export async function updateBetaFeature( await lockAppdata(); const userData = await loadUserData(); const betaFeatures = await getBetaFeatures(); - // @ts-expect-error If `BetaFeatures` is empty, `key` will be `never`, and we cannot use it to - // assign to`betaFeatures`.That's fine. Just rely on type checking when this function is called. + // If `BetaFeatures` is empty, `key` will be `never`, and we cannot use it to + // assign to `betaFeatures`. That's fine. Just rely on type checking when this + // function is called. betaFeatures[ key ] = value; userData.betaFeatures = betaFeatures; + applyBetaFeaturesToEnvironment( betaFeatures ); await saveUserData( userData ); } finally { await unlockAppdata(); diff --git a/apps/studio/src/menu.ts b/apps/studio/src/menu.ts index 76e8283e51..8332694797 100644 --- a/apps/studio/src/menu.ts +++ b/apps/studio/src/menu.ts @@ -164,7 +164,7 @@ async function getAppMenu( { label: __( 'Beta Features' ), submenu: betaFeaturesMenu, - enabled: false, + enabled: betaFeaturesMenu.length > 0, }, { type: 'separator' }, ...( process.platform === 'win32' 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 928022d5d2..0adaa54f7e 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 @@ -8,7 +8,12 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; -import { SupportedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; +import { + getRecommendedPHPVersionForRuntime, + getSupportedPHPVersionsForRuntime, + SupportedPHPVersion, +} from '@studio/common/types/php-versions'; import { Icon, SelectControl, Notice } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { __, sprintf, _n } from '@wordpress/i18n'; @@ -23,6 +28,7 @@ import { SiteFormError } from 'src/components/site-form-error'; import TextControlComponent from 'src/components/text-control'; import { WPVersionSelector } from 'src/components/wp-version-selector'; import { cx } from 'src/lib/cx'; +import { useRootSelector } from 'src/stores'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; import type { BlueprintPreferredVersions } from '@studio/common/lib/blueprint-validation'; import type { CreateSiteFormValues, PathValidationResult } from 'src/hooks/use-add-site'; @@ -75,10 +81,17 @@ export const CreateSiteForm = ( { }: CreateSiteFormProps ) => { const { __, isRTL } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); + const runtime = useRootSelector( ( state ) => + state.betaFeatures.features.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND + ); + const supportedPhpVersions = getSupportedPHPVersionsForRuntime( runtime ); + const recommendedPhpVersion = getRecommendedPHPVersionForRuntime( runtime ); const [ siteName, setSiteName ] = useState( defaultValues.siteName ?? '' ); const [ sitePath, setSitePath ] = useState( defaultValues.sitePath ?? '' ); const [ phpVersion, setPhpVersion ] = useState< SupportedPHPVersion >( - defaultValues.phpVersion ?? SupportedPHPVersions[ 0 ] ?? '8.2' + defaultValues.phpVersion && supportedPhpVersions.includes( defaultValues.phpVersion ) + ? defaultValues.phpVersion + : recommendedPhpVersion ); const [ wpVersion, setWpVersion ] = useState( defaultValues.wpVersion ?? DEFAULT_WORDPRESS_VERSION @@ -127,12 +140,27 @@ export const CreateSiteForm = ( { // Sync versions from defaultValues (initial load and deeplink flows) useEffect( () => { if ( defaultValues.phpVersion !== undefined ) { - setPhpVersion( defaultValues.phpVersion ); + setPhpVersion( + supportedPhpVersions.includes( defaultValues.phpVersion ) + ? defaultValues.phpVersion + : recommendedPhpVersion + ); } if ( defaultValues.wpVersion !== undefined ) { setWpVersion( defaultValues.wpVersion ); } - }, [ defaultValues.phpVersion, defaultValues.wpVersion ] ); + }, [ + defaultValues.phpVersion, + defaultValues.wpVersion, + recommendedPhpVersion, + supportedPhpVersions, + ] ); + + useEffect( () => { + if ( ! supportedPhpVersions.includes( phpVersion ) ) { + setPhpVersion( recommendedPhpVersion ); + } + }, [ phpVersion, recommendedPhpVersion, supportedPhpVersions ] ); // Sync admin credentials from Blueprint when they change (only if user hasn't edited) useEffect( () => { @@ -239,7 +267,14 @@ export const CreateSiteForm = ( { setSitePath( result.path ); } }, - [ onSiteNameChange, hasCustomPath ] + [ + onSiteNameChange, + hasCustomPath, + setDoesPathContainWordPress, + setPathError, + setSiteName, + setSitePath, + ] ); const handleSelectPath = useCallback( async () => { @@ -273,7 +308,18 @@ export const CreateSiteForm = ( { if ( result.name && ! siteName ) { setSiteName( result.name ); } - }, [ onSelectPath, onSiteNameChange, sitePath, siteName, hasCustomPath ] ); + }, [ + onSelectPath, + onSiteNameChange, + sitePath, + siteName, + hasCustomPath, + setDoesPathContainWordPress, + setHasCustomPath, + setPathError, + setSiteName, + setSitePath, + ] ); const handleCustomDomainChange = useCallback( ( value: string ) => { @@ -449,7 +495,7 @@ export const CreateSiteForm = ( { id="php-version-select" value={ phpVersion } - options={ SupportedPHPVersions.map( ( version ) => ( { + options={ supportedPhpVersions.map( ( version ) => ( { label: version, value: version, } ) ) } diff --git a/apps/studio/src/modules/site-settings/edit-site-details.tsx b/apps/studio/src/modules/site-settings/edit-site-details.tsx index 0de0b9f6db..ddc3cba9a7 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -1,4 +1,4 @@ -import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; +import { DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { generateCustomDomainFromSiteName, getDomainNameValidationError, @@ -10,10 +10,17 @@ import { validateAdminUsername, } from '@studio/common/lib/passwords'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; -import { SupportedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions'; -import { SelectControl, TabPanel } from '@wordpress/components'; +import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; +import { + getClosestNativePhpVersion, + getRecommendedPHPVersionForRuntime, + getSupportedPHPVersionsForRuntime, + SupportedPHPVersion, +} from '@studio/common/types/php-versions'; +import { Icon, SelectControl, TabPanel } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; import { sprintf } from '@wordpress/i18n'; +import { cautionFilled } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'; import Button from 'src/components/button'; @@ -28,6 +35,7 @@ import { WPVersionSelector } from 'src/components/wp-version-selector'; import { useSiteDetails } from 'src/hooks/use-site-details'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { useRootSelector } from 'src/stores'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; type EditSiteDetailsProps = { @@ -61,6 +69,34 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const [ adminEmail, setAdminEmail ] = useState( selectedSite?.adminEmail || 'admin@localhost.com' ); + const runtime = useRootSelector( ( state ) => + state.betaFeatures.features.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND + ); + const supportedPhpVersions = getSupportedPHPVersionsForRuntime( runtime ); + const recommendedPhpVersion = getRecommendedPHPVersionForRuntime( runtime ); + const selectedSitePhpVersion = selectedSite?.phpVersion; + const resolvedNativePhpVersion = + runtime === SITE_RUNTIME_NATIVE_PHP && selectedSitePhpVersion + ? getClosestNativePhpVersion( selectedSitePhpVersion ) + : undefined; + const selectedSitePhpVersionForRuntime = + selectedSitePhpVersion && + supportedPhpVersions.includes( selectedSitePhpVersion as SupportedPHPVersion ) + ? ( selectedSitePhpVersion as SupportedPHPVersion ) + : resolvedNativePhpVersion ?? recommendedPhpVersion; + const showNativePhpVersionWarning = + runtime === SITE_RUNTIME_NATIVE_PHP && + selectedSitePhpVersion !== undefined && + resolvedNativePhpVersion !== undefined && + resolvedNativePhpVersion !== selectedSitePhpVersion; + const nativePhpVersionWarning = + showNativePhpVersionWarning && selectedSitePhpVersion && resolvedNativePhpVersion + ? sprintf( + __( 'Native PHP does not support PHP %1$s. This site will run with PHP %2$s instead.' ), + selectedSitePhpVersion, + resolvedNativePhpVersion + ) + : undefined; useEffect( () => { if ( selectedSite?.adminEmail || ! selectedSite?.id ) { @@ -92,7 +128,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = }, [ isEditingSite, setIsEditModalOpen ] ); const [ siteName, setSiteName ] = useState( selectedSite?.name ?? '' ); const [ selectedPhpVersion, setSelectedPhpVersion ] = useState< SupportedPHPVersion >( - ( selectedSite?.phpVersion as SupportedPHPVersion ) ?? DEFAULT_PHP_VERSION + selectedSitePhpVersionForRuntime ); const getEffectiveWpVersion = useCallback( () => @@ -173,7 +209,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = return; } setSiteName( selectedSite.name ); - setSelectedPhpVersion( selectedSite.phpVersion as SupportedPHPVersion ); + setSelectedPhpVersion( selectedSitePhpVersionForRuntime ); setSelectedWpVersion( getEffectiveWpVersion() ); setUseCustomDomain( Boolean( selectedSite.customDomain ) ); setCustomDomain( selectedSite.customDomain ?? null ); @@ -186,7 +222,31 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setAdminEmail( selectedSite.adminEmail || 'admin@localhost.com' ); setEnableDebugLog( selectedSite.enableDebugLog ?? false ); setEnableDebugDisplay( selectedSite.enableDebugDisplay ?? false ); - }, [ selectedSite, getEffectiveWpVersion ] ); + }, [ + selectedSite, + getEffectiveWpVersion, + selectedSitePhpVersionForRuntime, + setAdminEmail, + setAdminPassword, + setAdminUsername, + setCustomDomain, + setCustomDomainError, + setEnableDebugDisplay, + setEnableDebugLog, + setEnableHttps, + setEnableXdebug, + setErrorUpdatingWpVersion, + setSelectedPhpVersion, + setSelectedWpVersion, + setSiteName, + setUseCustomDomain, + ] ); + + useEffect( () => { + if ( ! supportedPhpVersions.includes( selectedPhpVersion ) ) { + setSelectedPhpVersion( recommendedPhpVersion ); + } + }, [ selectedPhpVersion, recommendedPhpVersion, supportedPhpVersions ] ); const onSiteEdit = async ( event: FormEvent ) => { event.preventDefault(); @@ -328,12 +388,30 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = htmlFor="php-version-select" className="flex flex-1 flex-col gap-1.5 leading-4" > - { __( 'PHP version' ) } + + { __( 'PHP version' ) } + { nativePhpVersionWarning && ( + + + + + + ) } + id="php-version-select" disabled={ isEditingSite } value={ selectedPhpVersion } - options={ SupportedPHPVersions.map( ( version ) => ( { + options={ supportedPhpVersions.map( ( version ) => ( { label: version, value: version, } ) ) } diff --git a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx index ffe33ef7d6..5d07a4faee 100644 --- a/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx +++ b/apps/studio/src/modules/site-settings/tests/edit-site-details.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import { userEvent } from '@testing-library/user-event'; import { Provider } from 'react-redux'; import { vi } from 'vitest'; @@ -69,8 +69,15 @@ vi.mock( 'src/hooks/use-offline', () => ( { useOffline: vi.fn().mockReturnValue( false ), } ) ); -const renderWithProvider = ( children: React.ReactElement ) => { - const store = createTestStore(); +const renderWithProvider = ( children: React.ReactElement, nativePhpRuntime = false ) => { + const store = createTestStore( { + preloadedState: { + betaFeatures: { + features: { nativePhpRuntime }, + loading: false, + }, + }, + } ); return render( { children } ); }; @@ -223,6 +230,35 @@ describe( 'EditSiteDetails', () => { expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled(); } ); + it( 'should show a native PHP fallback warning for unsupported stored PHP versions', async () => { + const user = userEvent.setup(); + vi.mocked( useSiteDetails ).mockReturnValue( + createMock< ReturnType< typeof useSiteDetails > >( { + ...baseMockSiteDetails, + selectedSite: { + ...baseMockSiteDetails.selectedSite, + phpVersion: '7.4', + }, + isEditModalOpen: true, + } ) + ); + + renderWithProvider( , true ); + + await waitFor( () => { + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + const dialog = screen.getByRole( 'dialog' ); + expect( within( dialog ).getByLabelText( 'PHP version' ) ).toHaveValue( '8.2' ); + await user.hover( within( dialog ).getByRole( 'img', { name: 'PHP version warning' } ) ); + + expect( + await screen.findByText( + 'Native PHP does not support PHP 7.4. This site will run with PHP 8.2 instead.' + ) + ).toBeVisible(); + } ); + it( 'should enable the save button when WordPress version is changed', async () => { vi.mocked( useSiteDetails ).mockReturnValue( createMock< ReturnType< typeof useSiteDetails > >( { diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index 4df5a3fbd6..89a2850796 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -8,9 +8,6 @@ import { z } from 'zod'; import { authTokenSchema } from '@studio/common/lib/auth-token-schema'; import { snapshotSchema } from '@studio/common/types/snapshot'; -export const siteRuntimeSchema = z.enum( [ 'playground', 'native-php' ] ); -export type SiteRuntime = z.infer< typeof siteRuntimeSchema >; - /** * Site data included in events. This is the data Studio needs to display sites. */ @@ -34,7 +31,6 @@ export const siteDetailsSchema = z.object( { technicalSiteDirectory: z.string().optional(), runtimeBlueprintPath: z.string().optional(), landingPage: z.string().optional(), - runtime: siteRuntimeSchema.optional(), } ); export type SiteDetails = z.infer< typeof siteDetailsSchema >; diff --git a/tools/common/lib/php-binary-metadata.ts b/tools/common/lib/php-binary-metadata.ts index ed13faa5aa..d104d253d7 100644 --- a/tools/common/lib/php-binary-metadata.ts +++ b/tools/common/lib/php-binary-metadata.ts @@ -1,20 +1,14 @@ import { sprintf } from '@wordpress/i18n'; import { z } from 'zod'; import { - isSupportedPHPVersion, - SupportedPHPVersions, - type SupportedPHPVersion, + getClosestNativePhpVersion, + LatestNativePhpSupportedVersion, + NativePhpSupportedVersions, + type NativePhpSupportedVersion, } from '@studio/common/types/php-versions'; import phpBinaryCdnMetadataJson from './php-binary-cdn-metadata.json'; -const phpBinaryCdnMetadataVersions = Object.keys( - ( phpBinaryCdnMetadataJson as { versions?: Record< string, unknown > } ).versions ?? {} -); - -export const NativePhpSupportedVersions = SupportedPHPVersions.filter( ( version ) => - phpBinaryCdnMetadataVersions.includes( version ) -) as [ SupportedPHPVersion, ...SupportedPHPVersion[] ]; -export type NativePhpSupportedVersion = ( typeof NativePhpSupportedVersions )[ number ]; +export { NativePhpSupportedVersions, type NativePhpSupportedVersion }; const nativePhpVersionSchema = z.enum( NativePhpSupportedVersions ); export const MinimumNativePhpSupportedVersion = @@ -34,16 +28,18 @@ export function validateNativePhpVersion( version: string ): NativePhpSupportedV return result.data; } -export function coerceNativePhpVersion( version: SupportedPHPVersion ): NativePhpSupportedVersion { +export function resolveNativePhpVersion( version: string ): NativePhpSupportedVersion { const result = nativePhpVersionSchema.safeParse( version ); - return result.success ? result.data : MinimumNativePhpSupportedVersion; -} + if ( result.success ) { + return result.data; + } -export function resolveNativePhpVersion( version: string ): NativePhpSupportedVersion { - if ( isSupportedPHPVersion( version ) ) { - return coerceNativePhpVersion( version ); + if ( ! version ) { + return LatestNativePhpSupportedVersion; } - return validateNativePhpVersion( version ); + + const resolvedVersion = getClosestNativePhpVersion( version ); + return resolvedVersion ?? validateNativePhpVersion( version ); } const phpBinaryArtifactSchema = z.object( { @@ -52,8 +48,8 @@ const phpBinaryArtifactSchema = z.object( { } ); const phpBinaryCdnMetadataSchema = z.object( { - versions: z.partialRecord( - nativePhpVersionSchema, + versions: z.record( + z.string(), z.object( { version: z.string().regex( /^\d+\.\d+\.\d+$/ ), artifacts: z.record( z.string(), phpBinaryArtifactSchema ), diff --git a/tools/common/lib/site-runtime.ts b/tools/common/lib/site-runtime.ts new file mode 100644 index 0000000000..938eddf366 --- /dev/null +++ b/tools/common/lib/site-runtime.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const SITE_RUNTIME_PLAYGROUND = 'playground'; +export const SITE_RUNTIME_NATIVE_PHP = 'native-php'; + +export const siteRuntimeSchema = z.enum( [ SITE_RUNTIME_PLAYGROUND, SITE_RUNTIME_NATIVE_PHP ] ); +export type SiteRuntime = z.infer< typeof siteRuntimeSchema >; diff --git a/tools/common/lib/tests/php-binary-metadata.test.ts b/tools/common/lib/tests/php-binary-metadata.test.ts new file mode 100644 index 0000000000..d0c39b7d14 --- /dev/null +++ b/tools/common/lib/tests/php-binary-metadata.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + NativePhpSupportedVersions, + resolveNativePhpVersion, + validateNativePhpVersion, +} from '@studio/common/lib/php-binary-metadata'; + +describe( 'Native PHP binary metadata', () => { + it( 'supports officially supported PHP versions', () => { + expect( NativePhpSupportedVersions ).toEqual( [ '8.5', '8.4', '8.3', '8.2' ] ); + } ); + + it.each( [ '8.1', '8.0', '7.4' ] )( 'rejects unsupported PHP %s', ( version ) => { + expect( () => validateNativePhpVersion( version ) ).toThrow( + `PHP ${ version } is not supported by the native-php runtime. Supported versions: 8.5, 8.4, 8.3, 8.2.` + ); + } ); + + it.each( [ + [ '8.1', '8.2' ], + [ '8.0', '8.2' ], + [ '7.4', '8.2' ], + [ '8.6', '8.5' ], + [ '', '8.5' ], + ] )( 'resolves PHP %s to native PHP %s', ( version, expectedVersion ) => { + expect( resolveNativePhpVersion( version ) ).toBe( expectedVersion ); + } ); + + it( 'rejects malformed PHP versions when resolving native PHP', () => { + expect( () => resolveNativePhpVersion( 'nonsense' ) ).toThrow( + 'PHP nonsense is not supported by the native-php runtime.' + ); + } ); +} ); diff --git a/tools/common/types/php-versions.ts b/tools/common/types/php-versions.ts index 0e2b4cdfb0..adb0ae82e4 100644 --- a/tools/common/types/php-versions.ts +++ b/tools/common/types/php-versions.ts @@ -1,13 +1,10 @@ -/** - * Local type definitions for PHP versions. - * These replace imports from @php-wasm/universal to avoid bundling PHP-WASM in the desktop app. - * - * Note: Keep these values in sync with @php-wasm/universal if the upstream package changes. - */ +import { SITE_RUNTIME_NATIVE_PHP, type SiteRuntime } from '../lib/site-runtime'; export const SupportedPHPVersions = [ '8.5', '8.4', '8.3', '8.2', '8.1', '8.0', '7.4' ] as const; +export const NativePhpSupportedVersions = [ '8.5', '8.4', '8.3', '8.2' ] as const; export const LatestSupportedPHPVersion = '8.5' as const; +export const LatestNativePhpSupportedVersion = NativePhpSupportedVersions[ 0 ]; /** * We don't have an opportunity to retrieve PHP version from Jetpack connected sites, @@ -18,6 +15,16 @@ export const PressablePHPVersion = '8.5' as const; export const SupportedPHPVersionsList: string[] = [ ...SupportedPHPVersions ]; export type SupportedPHPVersion = ( typeof SupportedPHPVersions )[ number ]; +export type NativePhpSupportedVersion = ( typeof NativePhpSupportedVersions )[ number ]; + +function getPhpVersionScore( version: string ): number | undefined { + const match = version.match( /^(\d+)\.(\d+)$/ ); + if ( ! match ) { + return undefined; + } + + return Number( match[ 1 ] ) * 100 + Number( match[ 2 ] ); +} export function isSupportedPHPVersion( version: string | undefined @@ -25,8 +32,36 @@ export function isSupportedPHPVersion( return SupportedPHPVersions.includes( version as SupportedPHPVersion ); } +export function getSupportedPHPVersionsForRuntime( + runtime: SiteRuntime +): readonly SupportedPHPVersion[] { + return runtime === SITE_RUNTIME_NATIVE_PHP ? NativePhpSupportedVersions : SupportedPHPVersions; +} + +export function getClosestNativePhpVersion( + version: string +): NativePhpSupportedVersion | undefined { + const targetScore = getPhpVersionScore( version ); + if ( targetScore === undefined ) { + return undefined; + } + + return NativePhpSupportedVersions.reduce< NativePhpSupportedVersion >( ( closest, candidate ) => { + const closestDistance = Math.abs( getPhpVersionScore( closest )! - targetScore ); + const candidateDistance = Math.abs( getPhpVersionScore( candidate )! - targetScore ); + return candidateDistance < closestDistance ? candidate : closest; + }, NativePhpSupportedVersions[ 0 ] ); +} + /** * The recommended PHP version for new sites. * This replaces RecommendedPHPVersion from @wp-playground/common. */ export const RecommendedPHPVersion: SupportedPHPVersion = '8.4'; + +export function getRecommendedPHPVersionForRuntime( runtime: SiteRuntime ): SupportedPHPVersion { + const supportedVersions = getSupportedPHPVersionsForRuntime( runtime ); + return supportedVersions.includes( RecommendedPHPVersion ) + ? RecommendedPHPVersion + : supportedVersions[ 0 ] ?? RecommendedPHPVersion; +}