From eea3a723fe165720f7e34e8550c0685c194c4647 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 11 Jun 2026 16:58:14 +0100 Subject: [PATCH 01/16] Add per-site mode and file access settings (RSM-3958) --- apps/cli/ai/tools/create-site.ts | 2 + apps/cli/commands/blueprint/use.ts | 4 +- apps/cli/commands/pull-reprint.ts | 10 +- apps/cli/commands/site/create.ts | 95 ++++++++--- apps/cli/commands/site/set.ts | 71 +++++++- apps/cli/commands/site/tests/create.test.ts | 53 ++++-- apps/cli/commands/site/tests/set.test.ts | 87 +++++++++- apps/cli/commands/wp.ts | 15 +- apps/cli/lib/feature-flags.ts | 9 -- .../import/importers/importer.ts | 20 ++- apps/cli/lib/php-versions.ts | 35 +--- apps/cli/lib/run-wp-cli-command.ts | 9 +- apps/cli/lib/site-utils.ts | 17 +- apps/cli/lib/tests/feature-flags.test.ts | 33 ---- .../tests/wordpress-server-manager.test.ts | 35 +++- apps/cli/lib/types/wordpress-server-ipc.ts | 2 + apps/cli/lib/wordpress-server-manager.ts | 10 +- apps/cli/php-server-child.ts | 49 ++++-- .../src/components/content-tab-settings.tsx | 24 ++- .../tests/content-tab-settings.test.tsx | 9 +- apps/studio/src/ipc-handlers.ts | 16 +- apps/studio/src/ipc-types.d.ts | 2 + apps/studio/src/lib/beta-features.ts | 26 +-- .../05-seed-site-runtime-from-beta-flag.ts | 90 +++++++++++ apps/studio/src/migrations/index.ts | 2 + .../add-site/components/create-site-form.tsx | 34 +--- .../tests/use-blueprint-deeplink.test.tsx | 6 +- .../src/modules/cli/lib/cli-site-creator.ts | 12 ++ .../src/modules/cli/lib/cli-site-editor.ts | 12 ++ .../site-settings/edit-site-details.tsx | 152 +++++++++++++----- .../tests/edit-site-details.test.tsx | 7 +- apps/studio/src/site-server.ts | 7 +- apps/studio/src/tests/site-server.test.ts | 4 + tools/common/lib/cli-events.ts | 4 + tools/common/lib/php-binary-metadata.ts | 4 +- tools/common/lib/site-file-access.ts | 24 +++ tools/common/lib/site-needs-restart.ts | 6 + tools/common/lib/site-runtime.ts | 19 +++ .../lib/tests/blueprint-settings.test.ts | 4 +- tools/common/types/php-versions.ts | 31 ++-- 40 files changed, 756 insertions(+), 295 deletions(-) delete mode 100644 apps/cli/lib/feature-flags.ts delete mode 100644 apps/cli/lib/tests/feature-flags.test.ts create mode 100644 apps/studio/src/migrations/05-seed-site-runtime-from-beta-flag.ts create mode 100644 tools/common/lib/site-file-access.ts diff --git a/apps/cli/ai/tools/create-site.ts b/apps/cli/ai/tools/create-site.ts index 03a238bc07..dea7f33a2e 100644 --- a/apps/cli/ai/tools/create-site.ts +++ b/apps/cli/ai/tools/create-site.ts @@ -1,5 +1,6 @@ import path from 'path'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { Type } from 'typebox'; import { emitLocalSiteSelected } from 'cli/ai/site-selection'; import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create'; @@ -29,6 +30,7 @@ export const createSiteTool = defineTool( name: args.name, wpVersion: 'latest', phpVersion: DEFAULT_PHP_VERSION, + runtime: SITE_RUNTIME_PLAYGROUND, enableHttps: false, noStart: false, skipBrowser: true, diff --git a/apps/cli/commands/blueprint/use.ts b/apps/cli/commands/blueprint/use.ts index 1d1a4d9c75..96ded6db8d 100644 --- a/apps/cli/commands/blueprint/use.ts +++ b/apps/cli/commands/blueprint/use.ts @@ -1,7 +1,6 @@ import fs from 'fs'; import path from 'path'; import { select, input } from '@inquirer/prompts'; -import { SupportedPHPVersions, type SupportedPHPVersion } from '@php-wasm/universal'; import { DEFAULT_PHP_VERSION, DEFAULT_WORDPRESS_VERSION } from '@studio/common/constants'; import { createBlueprintTempDir, @@ -10,8 +9,10 @@ import { } from '@studio/common/lib/blueprint-bundle'; import { isOnline } from '@studio/common/lib/network-utils'; import { readSharedConfig } from '@studio/common/lib/shared-config'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { fetchStudioBlueprints, type Blueprint } from '@studio/common/lib/studio-blueprints-api'; import { BlueprintCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; +import { SupportedPHPVersions, type SupportedPHPVersion } from '@studio/common/types/php-versions'; import { __, _n, sprintf } from '@wordpress/i18n'; import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create'; import { getDefaultSitePath } from 'cli/lib/site-paths'; @@ -124,6 +125,7 @@ export async function runCommand( name: options.name, wpVersion: options.wpVersion ?? DEFAULT_WORDPRESS_VERSION, phpVersion: ( options.phpVersion ?? DEFAULT_PHP_VERSION ) as SupportedPHPVersion, + runtime: SITE_RUNTIME_PLAYGROUND, customDomain: options.customDomain, enableHttps: options.enableHttps, blueprint: { diff --git a/apps/cli/commands/pull-reprint.ts b/apps/cli/commands/pull-reprint.ts index 1f793cb378..7afaa53645 100644 --- a/apps/cli/commands/pull-reprint.ts +++ b/apps/cli/commands/pull-reprint.ts @@ -17,6 +17,7 @@ import { generateNumberedName } from '@studio/common/lib/generate-site-name'; import { encodePassword } from '@studio/common/lib/passwords'; import { portFinder } from '@studio/common/lib/port-finder'; import { readAuthToken, type StoredAuthToken } from '@studio/common/lib/shared-config'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { sortSites } from '@studio/common/lib/sort-sites'; import { PullReprintCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, sprintf } from '@wordpress/i18n'; @@ -1365,10 +1366,15 @@ async function findExistingSite( metadata: PullSessionMetadata ): Promise< SiteD } function printSiteUrls( localUrl: string ): void { - console.log( __( 'Site URL: ' ), buildAutoLoginUrl( localUrl ) ); + // Pulled sites always run on the Playground runtime today. + console.log( __( 'Site URL: ' ), buildAutoLoginUrl( SITE_RUNTIME_PLAYGROUND, localUrl ) ); console.log( __( 'WP Admin: ' ), - buildAutoLoginUrl( localUrl, new URL( '/wp-admin/', localUrl ).toString() ) + buildAutoLoginUrl( + SITE_RUNTIME_PLAYGROUND, + localUrl, + new URL( '/wp-admin/', localUrl ).toString() + ) ); console.log( '' ); } diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index 600c613ca4..fd4e1d0ffd 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -30,7 +30,21 @@ import { removeDbConstants, } from '@studio/common/lib/remove-default-db-constants'; import { readSharedConfig } from '@studio/common/lib/shared-config'; -import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; +import { + isFileAccessAllowedForRuntime, + SITE_FILE_ACCESS_ALL_FILES, + SITE_FILE_ACCESS_SITE_DIRECTORY, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; +import { + SITE_MODE_NATIVE, + SITE_MODE_SANDBOX, + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + siteRuntimeFromMode, + type SiteMode, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { sortSites } from '@studio/common/lib/sort-sites'; import { getServerFilesPath } from '@studio/common/lib/well-known-paths'; import { @@ -39,7 +53,11 @@ 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 { + RecommendedPHPVersion, + SupportedPHPVersions, + 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'; @@ -62,13 +80,8 @@ import { } from 'cli/lib/dependency-management/paths'; import { updateServerFiles } from 'cli/lib/dependency-management/setup'; import { downloadWordPress } from 'cli/lib/dependency-management/wordpress'; -import { getSiteRuntime } from 'cli/lib/feature-flags'; import { copyLanguagePackToSite } from 'cli/lib/language-packs'; -import { - getRecommendedPhpVersionForSiteRuntime, - getSupportedPhpVersionsForSiteRuntime, - validatePhpVersionForSiteRuntime, -} from 'cli/lib/php-versions'; +import { validateSupportedPhpVersion } 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'; @@ -88,6 +101,8 @@ export type CreateCommandOptions = { siteId?: string; wpVersion: string; phpVersion: SupportedPHPVersion; + runtime: SiteRuntime; + fileAccess?: SiteFileAccess; customDomain?: string; enableHttps: boolean; blueprint?: { @@ -106,8 +121,15 @@ export async function runCommand( sitePath: string, options: CreateCommandOptions ): Promise< void > { - const phpVersion = validatePhpVersionForSiteRuntime( options.phpVersion ); - const siteRuntime = getSiteRuntime(); + const siteRuntime = options.runtime; + if ( options.fileAccess && ! isFileAccessAllowedForRuntime( siteRuntime, options.fileAccess ) ) { + throw new LoggerError( + __( + 'File access "all-files" requires native mode. The sandbox only has access to the site directory.' + ) + ); + } + const phpVersion = validateSupportedPhpVersion( options.phpVersion ); const isOnlineStatus = await isOnline(); try { @@ -308,6 +330,8 @@ export async function runCommand( adminEmail, port, phpVersion, + runtime: siteRuntime, + fileAccess: options.fileAccess, running: false, isWpAutoUpdating: options.wpVersion === DEFAULT_WORDPRESS_VERSION, customDomain: options.customDomain, @@ -498,8 +522,6 @@ 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', @@ -520,8 +542,24 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'php', { type: 'string', describe: __( 'PHP version' ), - choices: supportedPhpVersions, - defaultDescription: recommendedPhpVersion, + choices: SupportedPHPVersions, + defaultDescription: RecommendedPHPVersion, + } ) + .option( 'mode', { + type: 'string', + describe: __( + 'Run the site with native PHP ("native") or in the Playground sandbox ("sandbox")' + ), + choices: [ SITE_MODE_NATIVE, SITE_MODE_SANDBOX ], + defaultDescription: SITE_MODE_SANDBOX, + } ) + .option( 'file-access', { + type: 'string', + describe: __( + 'Which files PHP can access in native mode: the site directory only, or all files' + ), + choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ], + defaultDescription: SITE_FILE_ACCESS_SITE_DIRECTORY, } ) .option( 'domain', { type: 'string', @@ -580,8 +618,20 @@ export const registerCommand = ( yargs: StudioArgv ) => { let adminUsername = argv.adminUsername; let adminPassword = argv.adminPassword; let adminEmail = argv.adminEmail; - const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); - const recommendedPhpVersion = getRecommendedPhpVersionForSiteRuntime(); + const runtime = argv.mode + ? siteRuntimeFromMode( argv.mode as SiteMode ) + : SITE_RUNTIME_PLAYGROUND; + const fileAccess = argv.fileAccess as SiteFileAccess | undefined; + if ( fileAccess && ! isFileAccessAllowedForRuntime( runtime, fileAccess ) ) { + logger.reportError( + new LoggerError( + __( + 'File access "all-files" requires native mode. The sandbox only has access to the site directory.' + ) + ) + ); + return; + } // Validate and resolve the WordPress version against available versions before prompting if ( wpVersion && wpVersion !== 'latest' && wpVersion !== 'nightly' ) { @@ -680,11 +730,11 @@ export const registerCommand = ( yargs: StudioArgv ) => { if ( ! phpVersion ) { phpVersion = await select( { message: __( 'PHP version:' ), - choices: supportedPhpVersions.map( ( v ) => ( { - name: v === recommendedPhpVersion ? sprintf( __( '%s (recommended)' ), v ) : v, + choices: SupportedPHPVersions.map( ( v ) => ( { + name: v === RecommendedPHPVersion ? sprintf( __( '%s (recommended)' ), v ) : v, value: v, } ) ), - default: recommendedPhpVersion, + default: RecommendedPHPVersion, } ); } @@ -741,15 +791,14 @@ export const registerCommand = ( yargs: StudioArgv ) => { // Apply defaults for non-interactive mode when flags weren't provided wpVersion = wpVersion ?? DEFAULT_WORDPRESS_VERSION; - const resolvedPhpVersion = validatePhpVersionForSiteRuntime( - phpVersion ?? recommendedPhpVersion - ); const config: CreateCommandOptions = { name: siteName, siteId: argv.id, wpVersion, - phpVersion: resolvedPhpVersion, + phpVersion: ( phpVersion ?? RecommendedPHPVersion ) as SupportedPHPVersion, + runtime, + fileAccess, customDomain, enableHttps, adminUsername, diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index 9a212d3cf9..981454f1cf 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -7,13 +7,28 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; +import { + getSiteFileAccess, + isFileAccessAllowedForRuntime, + SITE_FILE_ACCESS_ALL_FILES, + SITE_FILE_ACCESS_SITE_DIRECTORY, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; +import { + getSiteRuntime, + SITE_MODE_NATIVE, + SITE_MODE_SANDBOX, + siteRuntimeFromMode, + type SiteMode, +} from '@studio/common/lib/site-runtime'; import { getWordPressVersionUrl, isValidWordPressVersion, isWordPressVersionAtLeast, } from '@studio/common/lib/wordpress-version-utils'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; +import { SupportedPHPVersions } from '@studio/common/types/php-versions'; import { __, sprintf } from '@wordpress/i18n'; import { generateSiteCertificate } from 'cli/lib/certificate-manager'; import { @@ -25,10 +40,7 @@ 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 { validateSupportedPhpVersion } 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'; @@ -48,6 +60,8 @@ export interface SetCommandOptions { https?: boolean; php?: string; wp?: string; + mode?: SiteMode; + fileAccess?: SiteFileAccess; xdebug?: boolean; adminUsername?: string; adminPassword?: string; @@ -63,6 +77,8 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) https, php, wp, + mode, + fileAccess, xdebug, adminUsername, adminPassword, @@ -70,7 +86,6 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) debugDisplay, } = options; let { adminEmail } = options; - const validatedPhp = php === undefined ? undefined : validatePhpVersionForSiteRuntime( php ); if ( name === undefined && @@ -78,6 +93,8 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) https === undefined && php === undefined && wp === undefined && + mode === undefined && + fileAccess === undefined && xdebug === undefined && adminUsername === undefined && adminPassword === undefined && @@ -87,7 +104,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) ) { throw new LoggerError( __( - 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --mode, --file-access, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' ) ); } @@ -123,6 +140,17 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) let site = await getSiteByFolder( sitePath ); logger.reportSuccess( __( 'Site loaded' ) ); + const effectiveRuntime = mode ? siteRuntimeFromMode( mode ) : getSiteRuntime( site ); + const effectiveFileAccess = fileAccess ?? getSiteFileAccess( site ); + if ( ! isFileAccessAllowedForRuntime( effectiveRuntime, effectiveFileAccess ) ) { + throw new LoggerError( + __( + 'File access "all-files" requires native mode. The sandbox only has access to the site directory. Use --mode native or --file-access site-directory.' + ) + ); + } + const validatedPhp = php === undefined ? undefined : validateSupportedPhpVersion( php ); + const initialCliConfig = await readCliConfig(); if ( domain ) { @@ -163,6 +191,8 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const httpsChanged = https !== undefined && https !== site.enableHttps; const phpChanged = validatedPhp !== undefined && validatedPhp !== site.phpVersion; const wpChanged = wp !== undefined; + const runtimeChanged = mode !== undefined && effectiveRuntime !== getSiteRuntime( site ); + const fileAccessChanged = fileAccess !== undefined && fileAccess !== getSiteFileAccess( site ); const xdebugChanged = xdebug !== undefined && xdebug !== site.enableXdebug; const adminUsernameChanged = adminUsername !== undefined && adminUsername !== ( site.adminUsername ?? 'admin' ); @@ -179,6 +209,8 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) httpsChanged || phpChanged || wpChanged || + runtimeChanged || + fileAccessChanged || xdebugChanged || credentialsChanged || debugLogChanged || @@ -194,6 +226,8 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) httpsChanged, phpChanged, wpChanged, + runtimeChanged, + fileAccessChanged, xdebugChanged, credentialsChanged, debugLogChanged, @@ -221,6 +255,12 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) if ( phpChanged ) { foundSite.phpVersion = validatedPhp!; } + if ( runtimeChanged ) { + foundSite.runtime = effectiveRuntime; + } + if ( fileAccessChanged ) { + foundSite.fileAccess = fileAccess; + } if ( xdebugChanged ) { foundSite.enableXdebug = xdebug; } @@ -329,7 +369,6 @@ export const registerCommand = ( yargs: StudioArgv ) => { command: 'set', describe: __( 'Configure site settings' ), builder: ( yargs ) => { - const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); return yargs .option( 'name', { type: 'string', @@ -346,7 +385,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'php', { type: 'string', description: __( 'PHP version' ), - choices: supportedPhpVersions, + choices: SupportedPHPVersions, } ) .option( 'wp', { type: 'string', @@ -371,6 +410,20 @@ export const registerCommand = ( yargs: StudioArgv ) => { return value; }, } ) + .option( 'mode', { + type: 'string', + description: __( + 'Run the site with native PHP ("native") or in the Playground sandbox ("sandbox")' + ), + choices: [ SITE_MODE_NATIVE, SITE_MODE_SANDBOX ], + } ) + .option( 'file-access', { + type: 'string', + description: __( + 'Which files PHP can access in native mode: the site directory only, or all files' + ), + choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ], + } ) .option( 'xdebug', { type: 'boolean', description: __( 'Enable Xdebug' ), @@ -404,6 +457,8 @@ export const registerCommand = ( yargs: StudioArgv ) => { https: argv.https, php: argv.php, wp: argv.wp, + mode: argv.mode as SiteMode | undefined, + fileAccess: argv.fileAccess as SiteFileAccess | undefined, xdebug: argv.xdebug, adminUsername: argv.adminUsername, adminPassword: argv.adminPassword, diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index cdbcdee393..f33b8fefd6 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -11,8 +11,13 @@ 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 { + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { getServerFilesPath } from '@studio/common/lib/well-known-paths'; +import { type SupportedPHPVersion } from '@studio/common/types/php-versions'; import { Blueprint, BlueprintV1Declaration } from '@wp-playground/blueprints'; import { vi, type MockInstance } from 'vitest'; import { @@ -90,7 +95,8 @@ describe( 'CLI: studio site create', () => { const defaultTestOptions = { wpVersion: 'latest', - phpVersion: '8.0' as const, + phpVersion: '8.3' as const, + runtime: SITE_RUNTIME_PLAYGROUND as SiteRuntime, enableHttps: false, noStart: false, skipBrowser: false, @@ -235,17 +241,13 @@ 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 ); - + it( 'should error if PHP version is not supported', async () => { await expect( runCommand( mockSitePath, { ...defaultTestOptions, - phpVersion: '8.1', + phpVersion: '8.1' as SupportedPHPVersion, } ) - ).rejects.toThrow( - 'PHP 8.1 is not supported by the native PHP runtime. Supported versions: 8.5, 8.4, 8.3, 8.2.' - ); + ).rejects.toThrow( 'PHP 8.1 is not supported. Supported versions: 8.5, 8.4, 8.3, 8.2.' ); expect( saveCliConfig ).not.toHaveBeenCalled(); } ); @@ -309,6 +311,37 @@ describe( 'CLI: studio site create', () => { expect( loggerReportSuccessSpy ).toHaveBeenCalledWith( 'SQLite integration skipped' ); } ); + it( 'should persist the runtime and file access on the created site', async () => { + await runCommand( mockSitePath, { + ...defaultTestOptions, + runtime: SITE_RUNTIME_NATIVE_PHP, + phpVersion: '8.4', + fileAccess: 'all-files', + } ); + + expect( saveCliConfig ).toHaveBeenCalledWith( + expect.objectContaining( { + sites: expect.arrayContaining( [ + expect.objectContaining( { + runtime: SITE_RUNTIME_NATIVE_PHP, + fileAccess: 'all-files', + } ), + ] ), + } ) + ); + } ); + + it( 'should reject "all-files" file access for sandbox sites', async () => { + await expect( + runCommand( mockSitePath, { + ...defaultTestOptions, + fileAccess: 'all-files', + } ) + ).rejects.toThrow( 'File access "all-files" requires native mode.' ); + + expect( saveCliConfig ).not.toHaveBeenCalled(); + } ); + it( 'should create site with custom name', async () => { await runCommand( mockSitePath, { ...defaultTestOptions, @@ -503,11 +536,11 @@ describe( 'CLI: studio site create', () => { } ); it( 'should download and copy specific WordPress versions for native PHP runtime', async () => { - vi.stubEnv( 'STUDIO_RUNTIME', SITE_RUNTIME_NATIVE_PHP ); vi.mocked( recursiveCopyDirectory ).mockClear(); await runCommand( mockSitePath, { ...defaultTestOptions, + runtime: SITE_RUNTIME_NATIVE_PHP, phpVersion: '8.3', wpVersion: '6.4', } ); diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index a5f2f83f6d..5f9a84f4f8 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -1,7 +1,12 @@ 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 { + SITE_MODE_NATIVE, + SITE_MODE_SANDBOX, + 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'; @@ -104,7 +109,27 @@ describe( 'CLI: studio site set', () => { describe( 'Validation', () => { it( 'should throw when no options provided', async () => { await expect( runCommand( testSitePath, {} ) ).rejects.toThrow( - 'At least one option (--name, --domain, --https, --php, --wp, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --mode, --file-access, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' + ); + } ); + + it( 'should throw when "all-files" file access is combined with sandbox mode', async () => { + await expect( + runCommand( testSitePath, { mode: SITE_MODE_SANDBOX, fileAccess: 'all-files' } ) + ).rejects.toThrow( 'File access "all-files" requires native mode.' ); + + expect( saveCliConfig ).not.toHaveBeenCalled(); + } ); + + it( 'should throw when switching an "all-files" site to sandbox mode without resetting file access', async () => { + vi.mocked( getSiteByFolder ).mockResolvedValue( { + ...getTestSite(), + runtime: 'native-php', + fileAccess: 'all-files', + } ); + + await expect( runCommand( testSitePath, { mode: SITE_MODE_SANDBOX } ) ).rejects.toThrow( + 'File access "all-files" requires native mode.' ); } ); @@ -135,11 +160,9 @@ 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 ); - + it( 'should throw when PHP version is not supported', async () => { 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.' + 'PHP 8.1 is not supported. Supported versions: 8.5, 8.4, 8.3, 8.2.' ); expect( saveCliConfig ).not.toHaveBeenCalled(); @@ -320,6 +343,58 @@ describe( 'CLI: studio site set', () => { } ); } ); + describe( 'Mode and file access changes', () => { + it( 'should update the runtime when the mode changes', async () => { + await runCommand( testSitePath, { mode: SITE_MODE_NATIVE } ); + + expect( saveCliConfig ).toHaveBeenCalledWith( + expect.objectContaining( { + sites: expect.arrayContaining( [ + expect.objectContaining( { runtime: SITE_RUNTIME_NATIVE_PHP } ), + ] ), + } ) + ); + } ); + + it( 'should restart a running site when the mode changes', async () => { + vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); + + await runCommand( testSitePath, { mode: SITE_MODE_NATIVE } ); + + expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); + expect( startWordPressServer ).toHaveBeenCalled(); + } ); + + it( 'should update file access for a native PHP site', async () => { + vi.mocked( getSiteByFolder ).mockResolvedValue( { + ...getTestSite(), + runtime: SITE_RUNTIME_NATIVE_PHP, + } ); + + await runCommand( testSitePath, { fileAccess: 'all-files' } ); + + expect( saveCliConfig ).toHaveBeenCalledWith( + expect.objectContaining( { + sites: expect.arrayContaining( [ + expect.objectContaining( { fileAccess: 'all-files' } ), + ] ), + } ) + ); + } ); + + it( 'should report no changes when the mode matches the current runtime', async () => { + await expect( runCommand( testSitePath, { mode: SITE_MODE_SANDBOX } ) ).rejects.toThrow( + 'No changes to apply. The site already has the specified settings.' + ); + } ); + + it( 'should report no changes when the file access matches the default', async () => { + await expect( runCommand( testSitePath, { fileAccess: 'site-directory' } ) ).rejects.toThrow( + 'No changes to apply. The site already has the specified settings.' + ); + } ); + } ); + describe( 'Multiple options', () => { it( 'should apply multiple changes at once', async () => { await runCommand( testSitePath, { diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index 9ff428733f..2100fd18ce 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,7 +1,11 @@ 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 { + getSiteRuntime, + 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'; @@ -10,7 +14,6 @@ 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/config'; import { DETACH_FOR_GROUP_KILL, reapPhpTreeOnInterrupt } from 'cli/lib/native-php/php-process'; import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; @@ -96,11 +99,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, { runtime } ); + await using command = await runGlobalWpCliCommand( args, { + runtime: SITE_RUNTIME_PLAYGROUND, + } ); await pipePHPResponse( command.response ); process.exitCode = await command.response.exitCode; @@ -110,7 +113,7 @@ export async function runCommand( const site = await getSiteByFolder( siteFolder ); - if ( runtime === SITE_RUNTIME_NATIVE_PHP ) { + if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { await runNativePhpWpCliCommand( site, args ); return; } diff --git a/apps/cli/lib/feature-flags.ts b/apps/cli/lib/feature-flags.ts deleted file mode 100644 index 72ed0e194d..0000000000 --- a/apps/cli/lib/feature-flags.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 6b8ae3b809..16b8e2d94b 100644 --- a/apps/cli/lib/import-export/import/importers/importer.ts +++ b/apps/cli/lib/import-export/import/importers/importer.ts @@ -6,16 +6,16 @@ import { generateBackupFilename } from '@studio/common/lib/generate-backup-filen import { ImportEvents } from '@studio/common/lib/import-export-events'; import { isErrnoException } from '@studio/common/lib/is-errno-exception'; import { serializePlugins } from '@studio/common/lib/serialize-plugins'; -import { type SupportedPHPVersion } from '@studio/common/types/php-versions'; +import { + RecommendedPHPVersion, + SupportedPHPVersions, + 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'; @@ -308,21 +308,19 @@ abstract class BaseBackupImporter extends BaseImporter { } protected parsePhpVersion( version: string | undefined ): string { - const defaultPhpVersion = getRecommendedPhpVersionForSiteRuntime(); if ( ! version ) { - return defaultPhpVersion; + return RecommendedPHPVersion; } const phpVersion = semver.coerce( version ); if ( ! phpVersion ) { - return defaultPhpVersion; + return RecommendedPHPVersion; } const parsedVersion = `${ phpVersion.major }.${ phpVersion.minor }`; - const supportedPhpVersions = getSupportedPhpVersionsForSiteRuntime(); - return supportedPhpVersions.includes( parsedVersion as SupportedPHPVersion ) + return SupportedPHPVersions.includes( parsedVersion as SupportedPHPVersion ) ? parsedVersion - : defaultPhpVersion; + : RecommendedPHPVersion; } } diff --git a/apps/cli/lib/php-versions.ts b/apps/cli/lib/php-versions.ts index 5f3490370a..01b74c39ec 100644 --- a/apps/cli/lib/php-versions.ts +++ b/apps/cli/lib/php-versions.ts @@ -1,42 +1,17 @@ -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 { SupportedPHPVersions, 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 ) ) { +export function validateSupportedPhpVersion( version: string ): SupportedPHPVersion { + if ( SupportedPHPVersions.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.' ), + __( 'PHP %1$s is not supported. Supported versions: %2$s.' ), version, - runtimeLabel, - supportedVersions.join( ', ' ) + SupportedPHPVersions.join( ', ' ) ) ); } diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 0aeae71056..e52ca080c3 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -19,7 +19,11 @@ 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 { + getSiteRuntime, + 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'; @@ -28,7 +32,6 @@ 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/config'; import { @@ -226,7 +229,7 @@ export async function runWpCliCommand( ): Promise< DisposableWpCliResponse > { const siteFolder = site.path; - if ( getSiteRuntime() === SITE_RUNTIME_NATIVE_PHP ) { + if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { return runNativeWpCliCommand( site, args, options ); } diff --git a/apps/cli/lib/site-utils.ts b/apps/cli/lib/site-utils.ts index 739ca96e02..59183358ba 100644 --- a/apps/cli/lib/site-utils.ts +++ b/apps/cli/lib/site-utils.ts @@ -1,5 +1,9 @@ import { decodePassword } from '@studio/common/lib/passwords'; -import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + type SiteRuntime, +} 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'; @@ -12,7 +16,6 @@ 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'; @@ -37,8 +40,12 @@ export async function startProxyIfNeeded( logger: Logger< LoggerAction > ): Prom * Centralised here so every caller agrees on the query-param shape * the studio-auto-login mu-plugin expects. */ -export function buildAutoLoginUrl( siteUrl: string, redirectTo?: string ): string { - if ( getSiteRuntime() === SITE_RUNTIME_NATIVE_PHP ) { +export function buildAutoLoginUrl( + runtime: SiteRuntime, + siteUrl: string, + redirectTo?: string +): string { + if ( runtime === SITE_RUNTIME_NATIVE_PHP ) { return `${ siteUrl }/`; } @@ -61,7 +68,7 @@ export async function openSiteInBrowser( site: SiteData ): Promise< void > { try { const targetPath = site.landingPage || '/wp-admin/'; const target = new URL( targetPath, siteUrl ).toString(); - await openBrowser( buildAutoLoginUrl( siteUrl, target ) ); + await openBrowser( buildAutoLoginUrl( getSiteRuntime( site ), siteUrl, target ) ); } catch ( error ) { // Silently fail if browser can't be opened } diff --git a/apps/cli/lib/tests/feature-flags.test.ts b/apps/cli/lib/tests/feature-flags.test.ts deleted file mode 100644 index bf60a257bc..0000000000 --- a/apps/cli/lib/tests/feature-flags.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -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 9bf423f3be..f5f53830e0 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -149,11 +149,13 @@ describe( 'WordPress Server Manager', () => { expect( result ).toEqual( mockProcessDescription ); } ); - it( 'should use the native-php child script when STUDIO_RUNTIME is native-php', async () => { - vi.stubEnv( 'STUDIO_RUNTIME', SITE_RUNTIME_NATIVE_PHP ); + it( 'should use the native-php child script when the site runtime is native-php', async () => { setupIpcMocks(); - await startWordPressServer( mockSiteData, mockLogger ); + await startWordPressServer( + { ...mockSiteData, runtime: SITE_RUNTIME_NATIVE_PHP }, + mockLogger + ); expect( vi.mocked( daemonClient.startProcess ) ).toHaveBeenCalledWith( 'studio-site-test-site-id', @@ -163,10 +165,12 @@ describe( 'WordPress Server Manager', () => { } ); 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, phpVersion: '7.4' }, mockLogger ); + await startWordPressServer( + { ...mockSiteData, runtime: SITE_RUNTIME_NATIVE_PHP, phpVersion: '7.4' }, + mockLogger + ); expect( vi.mocked( ensurePhpBinaryAvailable ) ).toHaveBeenCalledWith( '8.2', @@ -183,7 +187,7 @@ describe( 'WordPress Server Manager', () => { ); } ); - it( 'should use the playground child script when STUDIO_RUNTIME is unset', async () => { + it( 'should use the playground child script when the site has no runtime set', async () => { setupIpcMocks(); await startWordPressServer( mockSiteData, mockLogger ); @@ -195,6 +199,25 @@ describe( 'WordPress Server Manager', () => { ); } ); + it( 'should include the file access setting in the server config', async () => { + setupIpcMocks(); + + await startWordPressServer( + { ...mockSiteData, runtime: SITE_RUNTIME_NATIVE_PHP, fileAccess: 'all-files' }, + mockLogger + ); + + expect( vi.mocked( daemonClient.sendMessageToProcess ) ).toHaveBeenCalledWith( + mockProcessDescription.pmId, + expect.objectContaining( { + topic: 'start-server', + data: expect.objectContaining( { + config: expect.objectContaining( { fileAccess: 'all-files' } ), + } ), + } ) + ); + } ); + it( 'should handle start process failure', async () => { vi.mocked( daemonClient.startProcess ).mockRejectedValue( new Error( 'Failed to start process' ) diff --git a/apps/cli/lib/types/wordpress-server-ipc.ts b/apps/cli/lib/types/wordpress-server-ipc.ts index 7ef510daeb..445fa273ee 100644 --- a/apps/cli/lib/types/wordpress-server-ipc.ts +++ b/apps/cli/lib/types/wordpress-server-ipc.ts @@ -1,3 +1,4 @@ +import { siteFileAccessSchema } from '@studio/common/lib/site-file-access'; import { z } from 'zod'; // Zod schemas for validating IPC messages from wordpress-server-manager @@ -26,6 +27,7 @@ const serverConfig = z.object( { siteTitle: z.string().optional(), siteLanguage: z.string().optional(), isWpAutoUpdating: z.boolean().optional(), + fileAccess: siteFileAccessSchema.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 ddb74d6d4d..63a312b991 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -13,6 +13,7 @@ import { } from '@studio/common/constants'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; import { + getSiteRuntime, SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND, type SiteRuntime, @@ -30,7 +31,6 @@ 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'; @@ -166,6 +166,10 @@ function buildServerConfig( serverConfig.useExactMountLayout = true; } + if ( site.fileAccess ) { + serverConfig.fileAccess = site.fileAccess; + } + if ( site.enableXdebug ) { serverConfig.enableXdebug = true; } @@ -219,7 +223,7 @@ export async function startWordPressServer( } } - const runtime = getSiteRuntime(); + const runtime = getSiteRuntime( site ); await ensurePhpBinaryAvailableIfNeeded( site, logger, runtime ); const startMessage = options?.blueprint @@ -547,7 +551,7 @@ export async function runBlueprint( logger: Logger< string >, options: RunBlueprintOptions ): Promise< void > { - const runtime = getSiteRuntime(); + const runtime = getSiteRuntime( site ); await ensurePhpBinaryAvailableIfNeeded( site, logger, runtime ); logger.reportStart( SiteCommandLoggerAction.APPLY_BLUEPRINT, __( 'Applying Blueprint…' ) ); diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index 23c8bbe801..5bdf91e44d 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -14,6 +14,10 @@ import os from 'node:os'; import path from 'node:path'; import { writeStudioMuPluginsForNativePhpRuntime } from '@studio/common/lib/mu-plugins'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; +import { + getSiteFileAccess, + SITE_FILE_ACCESS_SITE_DIRECTORY, +} from '@studio/common/lib/site-file-access'; import { z } from 'zod'; import { managerMessageSchema, @@ -105,6 +109,12 @@ const SYMLINK_RESTART_DEBOUNCE_MS = 750; const STOP_SERVER_TIMEOUT = 5000; const NATIVE_PHP_WORKER_POOL_SIZE = 4; +// "Site directory" file access applies the open_basedir jail and +// disable_functions list; "all files" runs PHP unrestricted. +function isFileAccessRestricted( config: ServerConfig ): boolean { + return getSiteFileAccess( config ) === SITE_FILE_ACCESS_SITE_DIRECTORY; +} + function logToConsole( ...args: Parameters< typeof console.log > ) { console.log( `[PHP Server]`, ...args ); } @@ -468,20 +478,24 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise stopSignal.throwIfAborted(); } - // Snapshot existing symlink targets so open_basedir grants them upfront. New - // symlinks added while the server runs are picked up by startSymlinkWatcher - // below and trigger a debounced restart with an extended allowlist. - const symlinkAllowlistEntries = await collectSymlinkAllowlistEntries( config.sitePath ); - stopSignal.throwIfAborted(); + // With "all files" access the allowlist stays empty, which disables + // open_basedir entirely (see getDefaultPhpArgs). + if ( isFileAccessRestricted( config ) ) { + // Snapshot existing symlink targets so open_basedir grants them upfront. New + // symlinks added while the server runs are picked up by startSymlinkWatcher + // below and trigger a debounced restart with an extended allowlist. + const symlinkAllowlistEntries = await collectSymlinkAllowlistEntries( config.sitePath ); + stopSignal.throwIfAborted(); - currentOpenBasedirAllowlist.add( config.sitePath ); - currentOpenBasedirAllowlist.add( ROUTER_PATH ); - currentOpenBasedirAllowlist.add( getPhpMyAdminPath() ); - currentOpenBasedirAllowlist.add( getNativePhpMyAdminWpEnvPath( config ) ); - currentOpenBasedirAllowlist.add( getPhpMyAdminSessionPath( config ) ); - currentOpenBasedirAllowlist.add( muPluginsPath ); - currentOpenBasedirAllowlist.add( os.tmpdir() ); - symlinkAllowlistEntries.forEach( ( entry ) => currentOpenBasedirAllowlist.add( entry ) ); + currentOpenBasedirAllowlist.add( config.sitePath ); + currentOpenBasedirAllowlist.add( ROUTER_PATH ); + currentOpenBasedirAllowlist.add( getPhpMyAdminPath() ); + currentOpenBasedirAllowlist.add( getNativePhpMyAdminWpEnvPath( config ) ); + currentOpenBasedirAllowlist.add( getPhpMyAdminSessionPath( config ) ); + currentOpenBasedirAllowlist.add( muPluginsPath ); + currentOpenBasedirAllowlist.add( os.tmpdir() ); + symlinkAllowlistEntries.forEach( ( entry ) => currentOpenBasedirAllowlist.add( entry ) ); + } runningConfig = config; @@ -547,7 +561,7 @@ async function doStartServer( STUDIO_PHPMYADMIN_SESSION_PATH: getPhpMyAdminSessionPath( config ), }, onlyPathsThatPhpCanAccess: Array.from( openBasedirAllowlist ), - disallowRiskyFunctions: true, + disallowRiskyFunctions: isFileAccessRestricted( config ), enableXdebug: config.enableXdebug, } ); spawnedChildren.push( serverChild ); @@ -591,8 +605,11 @@ async function doStartServer( // Watch for symlinks created after startup. open_basedir cannot be extended // at runtime, so the watcher triggers a debounced restart with an updated - // allowlist when a new symlink target is discovered. - startSymlinkWatcher( config.sitePath ); + // allowlist when a new symlink target is discovered. With "all files" + // access there is no open_basedir to extend, so no watcher is needed. + if ( isFileAccessRestricted( config ) ) { + startSymlinkWatcher( config.sitePath ); + } return spawnedChildren[ 0 ]; } catch ( error ) { const serverToClose = proxyServer; diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index fc2672bf6e..84819b63b5 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -1,5 +1,7 @@ import { decodePassword } from '@studio/common/lib/passwords'; -import { getClosestNativePhpVersion } from '@studio/common/types/php-versions'; +import { getSiteFileAccess, SITE_FILE_ACCESS_ALL_FILES } from '@studio/common/lib/site-file-access'; +import { getSiteRuntime, SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; +import { getClosestSupportedPhpVersion } from '@studio/common/types/php-versions'; import { DropdownMenu, MenuGroup, @@ -21,7 +23,7 @@ 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, useRootSelector } from 'src/stores'; +import { useAppDispatch } from 'src/stores'; import { certificateTrustApi, useCheckCertificateTrustQuery, @@ -46,9 +48,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) const dispatch = useAppDispatch(); const { __ } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); - const isNativePhpRuntime = useRootSelector( - ( state ) => state.betaFeatures.features.nativePhpRuntime - ); + const isNativePhpRuntime = getSiteRuntime( selectedSite ) === SITE_RUNTIME_NATIVE_PHP; const username = selectedSite.adminUsername || 'admin'; // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); @@ -60,7 +60,7 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) : `localhost:${ selectedSite.port }`; const protocol = selectedSite.customDomain && selectedSite.enableHttps ? 'https' : 'http'; const resolvedNativePhpVersion = isNativePhpRuntime - ? getClosestNativePhpVersion( selectedSite.phpVersion ) + ? getClosestSupportedPhpVersion( selectedSite.phpVersion ) : undefined; const showNativePhpVersionWarning = isNativePhpRuntime && @@ -202,6 +202,18 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) ) } + + { /* translators: value for the Mode setting on the site settings screen */ } + { isNativePhpRuntime ? __( 'Native' ) : __( 'Sandbox' ) } + + + { /* translators: value for the File access setting on the site settings screen */ } + + { getSiteFileAccess( selectedSite ) === SITE_FILE_ACCESS_ALL_FILES + ? __( 'All files' ) + : __( 'Site directory' ) } + +

{ __( 'Debugging' ) }

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 04e86020ca..0f3b5f9467 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( nativePhpRuntime = false ) { +function createCustomTestStore() { const store = createTestStore( { preloadedState: { betaFeatures: { - features: { remoteSession: false, nativePhpRuntime }, + features: { remoteSession: false, nativePhpRuntime: false }, loading: false, }, }, @@ -293,10 +293,11 @@ 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( () => { diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 3b4c567778..0e73f276a1 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -76,6 +76,8 @@ import { updateSharedConfig, updateSharedSession, } from '@studio/common/lib/shared-config'; +import { getSiteFileAccess } from '@studio/common/lib/site-file-access'; +import { getSiteRuntime, siteModeFromRuntime } from '@studio/common/lib/site-runtime'; import { SYNC_IGNORE_DEFAULTS } from '@studio/common/lib/sync/constants'; import { shouldExcludeFromSync } from '@studio/common/lib/sync/exclude-from-sync'; import { shouldLimitDepth } from '@studio/common/lib/sync/tree-utils'; @@ -975,6 +977,14 @@ export async function updateSite( options.wp = isWordPressDevVersion( wpVersion ) ? 'nightly' : wpVersion; } + if ( getSiteRuntime( updatedSite ) !== getSiteRuntime( currentSite ) ) { + options.mode = siteModeFromRuntime( getSiteRuntime( updatedSite ) ); + } + + if ( getSiteFileAccess( updatedSite ) !== getSiteFileAccess( currentSite ) ) { + options.fileAccess = getSiteFileAccess( updatedSite ); + } + if ( updatedSite.enableXdebug !== currentSite.enableXdebug ) { options.xdebug = updatedSite.enableXdebug ?? false; } @@ -1220,6 +1230,10 @@ export async function copySite( name: siteName, siteId: newSiteId, phpVersion: sourceSite.phpVersion, + // Copies keep the source site's runtime settings rather than picking up + // the default for new sites. + runtime: getSiteRuntime( sourceSite ), + fileAccess: sourceSite.fileAccess, adminUsername: sourceSite.adminUsername, adminPassword: sourceSite.adminPassword ? decodePassword( sourceSite.adminPassword ) @@ -2442,7 +2456,7 @@ export async function startRemoteSessionDaemon( // // `STUDIO_ENABLE_REMOTE_SESSION=true` is required: the CLI gates the entire // `code remote-session` subcommand tree behind that env var (see - // `apps/cli/lib/feature-flags.ts`). Without it, the spawned child fails with + // `tools/common/lib/remote-session.ts`). Without it, the spawned child fails with // "Unknown arguments: remote-session, start". The `remoteSession` beta // feature is the user-facing opt-in, so we lift the CLI gate in the spawned // child rather than asking users to set the env var manually. diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index 7539ea97b3..6b5e4e27e2 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -6,6 +6,7 @@ interface ShowNotificationOptions extends Electron.NotificationConstructorOption } type SiteRuntime = 'playground' | 'native-php'; +type SiteFileAccess = 'site-directory' | 'all-files'; interface StoppedSiteDetails { running: false; @@ -47,6 +48,7 @@ interface StoppedSiteDetails { sortOrder?: number; landingPage?: string; runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; } interface StartedSiteDetails extends StoppedSiteDetails { diff --git a/apps/studio/src/lib/beta-features.ts b/apps/studio/src/lib/beta-features.ts index 45a07ed2f8..b4379d4278 100644 --- a/apps/studio/src/lib/beta-features.ts +++ b/apps/studio/src/lib/beta-features.ts @@ -1,4 +1,8 @@ -import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; +import { + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { __ } from '@wordpress/i18n'; import { lockAppdata, unlockAppdata, loadUserData, saveUserData } from 'src/storage/user-data'; @@ -33,7 +37,7 @@ export function getBetaFeaturesDefinition(): Record< keyof BetaFeatures, BetaFea key: 'nativePhpRuntime', label: __( 'Native PHP runtime' ), default: BETA_FEATURE_DEFAULTS.nativePhpRuntime, - description: __( 'Run Studio sites with native PHP instead of Playground.' ), + description: __( 'Use native PHP instead of the Playground sandbox for new sites.' ), }, }; } @@ -47,17 +51,16 @@ function buildBetaFeatures( userData: BetaFeatures | undefined ): BetaFeatures { return features as BetaFeatures; } -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(); - const betaFeatures = buildBetaFeatures( userData.betaFeatures ); - applyBetaFeaturesToEnvironment( betaFeatures ); - return betaFeatures; + return buildBetaFeatures( userData.betaFeatures ); +} + +// The runtime is a per-site setting; the beta feature only selects the +// default for newly created sites. +export async function getDefaultSiteRuntime(): Promise< SiteRuntime > { + const betaFeatures = await getBetaFeatures(); + return betaFeatures.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND; } export async function updateBetaFeature( @@ -72,7 +75,6 @@ export async function updateBetaFeature( // line stops type-checking. That's fine β€” rely on type checking at the call site. betaFeatures[ key ] = value; userData.betaFeatures = betaFeatures; - applyBetaFeaturesToEnvironment( betaFeatures ); await saveUserData( userData ); } finally { await unlockAppdata(); diff --git a/apps/studio/src/migrations/05-seed-site-runtime-from-beta-flag.ts b/apps/studio/src/migrations/05-seed-site-runtime-from-beta-flag.ts new file mode 100644 index 0000000000..763d0a5824 --- /dev/null +++ b/apps/studio/src/migrations/05-seed-site-runtime-from-beta-flag.ts @@ -0,0 +1,90 @@ +/** + * The native PHP runtime used to be a global toggle: the `nativePhpRuntime` + * beta feature made every site run on native PHP via the `STUDIO_RUNTIME` + * environment variable. The runtime is now stored per site in `cli.json` + * (`runtime` field, unset means Playground), so without this migration the + * sites of users who had the beta feature enabled would silently fall back to + * the Playground runtime. Seed `runtime: 'native-php'` on every site that has + * no explicit runtime yet when the beta feature is on. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { + CLI_CONFIG_LOCKFILE_NAME, + LOCKFILE_STALE_TIME, + LOCKFILE_WAIT_TIME, +} from '@studio/common/constants'; +import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; +import { SITE_RUNTIME_NATIVE_PHP, siteRuntimeSchema } from '@studio/common/lib/site-runtime'; +import { + getAppConfigPath, + getCliConfigPath, + getConfigDirectory, +} from '@studio/common/lib/well-known-paths'; +import { readFile, writeFile } from 'atomically'; +import { z } from 'zod'; +import type { Migration } from '@studio/common/lib/migration'; + +const appBetaFeaturesShapeSchema = z + .object( { + betaFeatures: z.object( { nativePhpRuntime: z.boolean().optional() } ).loose().optional(), + } ) + .loose(); + +const cliConfigShapeSchema = z + .object( { + sites: z + .array( z.object( { runtime: siteRuntimeSchema.optional() } ).loose() ) + .default( () => [] ), + } ) + .loose(); + +async function readJsonFile< T extends z.ZodType >( + filePath: string, + schema: T +): Promise< z.infer< T > | null > { + if ( ! fs.existsSync( filePath ) ) { + return null; + } + try { + const raw = await readFile( filePath, { encoding: 'utf8' } ); + return schema.parse( JSON.parse( raw ) ); + } catch { + return null; + } +} + +async function isNativePhpBetaFeatureEnabled(): Promise< boolean > { + const appConfig = await readJsonFile( getAppConfigPath(), appBetaFeaturesShapeSchema ); + return appConfig?.betaFeatures?.nativePhpRuntime === true; +} + +export const seedSiteRuntimeFromBetaFlag: Migration = { + async needsToRun() { + if ( ! ( await isNativePhpBetaFeatureEnabled() ) ) { + return false; + } + const cliConfig = await readJsonFile( getCliConfigPath(), cliConfigShapeSchema ); + return !! cliConfig?.sites.some( ( site ) => site.runtime === undefined ); + }, + async run() { + const lockfilePath = path.join( getConfigDirectory(), CLI_CONFIG_LOCKFILE_NAME ); + try { + await lockFileAsync( lockfilePath, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); + const cliConfig = await readJsonFile( getCliConfigPath(), cliConfigShapeSchema ); + if ( ! cliConfig ) { + return; + } + for ( const site of cliConfig.sites ) { + site.runtime ??= SITE_RUNTIME_NATIVE_PHP; + } + await writeFile( getCliConfigPath(), JSON.stringify( cliConfig, null, 2 ) + '\n', { + encoding: 'utf8', + } ); + console.log( `Seeded native PHP runtime on ${ cliConfig.sites.length } existing site(s)` ); + } finally { + await unlockFileAsync( lockfilePath ); + } + }, +}; diff --git a/apps/studio/src/migrations/index.ts b/apps/studio/src/migrations/index.ts index 80cde46b78..c521af20a0 100644 --- a/apps/studio/src/migrations/index.ts +++ b/apps/studio/src/migrations/index.ts @@ -2,6 +2,7 @@ import { renameLaunchUniquesStat } from './01-rename-launch-uniques-stat'; import { migrateAppConfig } from './02-migrate-to-split-config'; import { copyHttpsCertsToWellKnown } from './03-copy-https-certs-to-well-known'; import { migrateConnectedSitesToShared } from './04-migrate-connected-sites-to-shared'; +import { seedSiteRuntimeFromBetaFlag } from './05-seed-site-runtime-from-beta-flag'; import type { Migration } from '@studio/common/lib/migration'; export const migrations: Migration[] = [ @@ -9,4 +10,5 @@ export const migrations: Migration[] = [ renameLaunchUniquesStat, copyHttpsCertsToWellKnown, migrateConnectedSitesToShared, + seedSiteRuntimeFromBetaFlag, ]; 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 e4be53755f..fd05bf7230 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,11 +8,10 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; -import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { - getRecommendedPHPVersionForRuntime, - getSupportedPHPVersionsForRuntime, + RecommendedPHPVersion, SupportedPHPVersion, + SupportedPHPVersions, } from '@studio/common/types/php-versions'; import { Icon, SelectControl, Notice } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; @@ -28,7 +27,6 @@ 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'; @@ -81,17 +79,12 @@ 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.includes( defaultValues.phpVersion ) + defaultValues.phpVersion && SupportedPHPVersions.includes( defaultValues.phpVersion ) ? defaultValues.phpVersion - : recommendedPhpVersion + : RecommendedPHPVersion ); const [ wpVersion, setWpVersion ] = useState( defaultValues.wpVersion ?? DEFAULT_WORDPRESS_VERSION @@ -141,26 +134,15 @@ export const CreateSiteForm = ( { useEffect( () => { if ( defaultValues.phpVersion !== undefined ) { setPhpVersion( - supportedPhpVersions.includes( defaultValues.phpVersion ) + SupportedPHPVersions.includes( defaultValues.phpVersion ) ? defaultValues.phpVersion - : recommendedPhpVersion + : RecommendedPHPVersion ); } if ( defaultValues.wpVersion !== undefined ) { setWpVersion( defaultValues.wpVersion ); } - }, [ - defaultValues.phpVersion, - defaultValues.wpVersion, - recommendedPhpVersion, - supportedPhpVersions, - ] ); - - useEffect( () => { - if ( ! supportedPhpVersions.includes( phpVersion ) ) { - setPhpVersion( recommendedPhpVersion ); - } - }, [ phpVersion, recommendedPhpVersion, supportedPhpVersions ] ); + }, [ defaultValues.phpVersion, defaultValues.wpVersion ] ); // Sync admin credentials from Blueprint when they change (only if user hasn't edited) useEffect( () => { @@ -495,7 +477,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/add-site/hooks/tests/use-blueprint-deeplink.test.tsx b/apps/studio/src/modules/add-site/hooks/tests/use-blueprint-deeplink.test.tsx index d40552a559..63cfce9db2 100644 --- a/apps/studio/src/modules/add-site/hooks/tests/use-blueprint-deeplink.test.tsx +++ b/apps/studio/src/modules/add-site/hooks/tests/use-blueprint-deeplink.test.tsx @@ -102,7 +102,7 @@ describe( 'useBlueprintDeeplink', () => { const mockBlueprintData = { steps: [], preferredVersions: { - php: '8.0', + php: '8.2', wp: '6.4', }, }; @@ -121,10 +121,10 @@ describe( 'useBlueprintDeeplink', () => { } ); expect( mockSetBlueprintPreferredVersions ).toHaveBeenCalledWith( { - php: '8.0', + php: '8.2', wp: '6.4', } ); - expect( mockSetPhpVersion ).toHaveBeenCalledWith( '8.0' ); + expect( mockSetPhpVersion ).toHaveBeenCalledWith( '8.2' ); expect( mockSetWpVersion ).toHaveBeenCalledWith( '6.4' ); } ); diff --git a/apps/studio/src/modules/cli/lib/cli-site-creator.ts b/apps/studio/src/modules/cli/lib/cli-site-creator.ts index f035a23902..d9a58c9d0f 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-creator.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-creator.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; +import { siteModeFromRuntime, type SiteRuntime } from '@studio/common/lib/site-runtime'; import { isWordPressDevVersion } from '@studio/common/lib/wordpress-version-utils'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; import { z } from 'zod'; @@ -32,6 +34,8 @@ export interface CreateSiteOptions { name?: string; wpVersion?: string; phpVersion?: string; + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; customDomain?: string; enableHttps?: boolean; siteId?: string; @@ -138,6 +142,14 @@ function buildCliArgs( options: CreateSiteOptions ): string[] { args.push( '--php', options.phpVersion ); } + if ( options.runtime ) { + args.push( '--mode', siteModeFromRuntime( options.runtime ) ); + } + + if ( options.fileAccess ) { + args.push( '--file-access', options.fileAccess ); + } + if ( options.customDomain ) { args.push( '--domain', options.customDomain ); } diff --git a/apps/studio/src/modules/cli/lib/cli-site-editor.ts b/apps/studio/src/modules/cli/lib/cli-site-editor.ts index 1b978ed654..19d1ebfb6b 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-editor.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-editor.ts @@ -1,3 +1,5 @@ +import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; +import { type SiteMode } from '@studio/common/lib/site-runtime'; import { SiteCommandLoggerAction } from '@studio/common/logger-actions'; import { z } from 'zod'; import { executeCliCommand } from './execute-command'; @@ -16,6 +18,8 @@ export interface EditSiteOptions { https?: boolean; php?: string; wp?: string; + mode?: SiteMode; + fileAccess?: SiteFileAccess; xdebug?: boolean; adminUsername?: string; adminPassword?: string; @@ -80,6 +84,14 @@ function buildCliArgs( options: EditSiteOptions ): string[] { args.push( '--wp', options.wp ); } + if ( options.mode !== undefined ) { + args.push( '--mode', options.mode ); + } + + if ( options.fileAccess !== undefined ) { + args.push( '--file-access', options.fileAccess ); + } + if ( options.xdebug !== undefined ) { args.push( options.xdebug ? '--xdebug' : '--no-xdebug' ); } 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 ddc3cba9a7..db86751f10 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -9,13 +9,24 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; +import { + getSiteFileAccess, + SITE_FILE_ACCESS_ALL_FILES, + SITE_FILE_ACCESS_SITE_DIRECTORY, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; import { siteNeedsRestart } from '@studio/common/lib/site-needs-restart'; -import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { - getClosestNativePhpVersion, - getRecommendedPHPVersionForRuntime, - getSupportedPHPVersionsForRuntime, + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; +import { + getClosestSupportedPhpVersion, + RecommendedPHPVersion, SupportedPHPVersion, + SupportedPHPVersions, } from '@studio/common/types/php-versions'; import { Icon, SelectControl, TabPanel } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; @@ -35,7 +46,6 @@ 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 = { @@ -43,6 +53,13 @@ type EditSiteDetailsProps = { onSave: () => void; }; +function resolvePhpVersion( phpVersion: string | undefined ): SupportedPHPVersion { + if ( phpVersion && SupportedPHPVersions.includes( phpVersion as SupportedPHPVersion ) ) { + return phpVersion as SupportedPHPVersion; + } + return ( phpVersion && getClosestSupportedPhpVersion( phpVersion ) ) || RecommendedPHPVersion; +} + const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) => { const { __ } = useI18n(); const { @@ -69,32 +86,26 @@ 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 [ selectedRuntime, setSelectedRuntime ] = useState< SiteRuntime >( + getSiteRuntime( selectedSite ?? {} ) + ); + const [ selectedFileAccess, setSelectedFileAccess ] = useState< SiteFileAccess >( + getSiteFileAccess( selectedSite ?? {} ) ); - const supportedPhpVersions = getSupportedPHPVersionsForRuntime( runtime ); - const recommendedPhpVersion = getRecommendedPHPVersionForRuntime( runtime ); + // The sandbox only has access to the site directory, so "all files" is + // forced back to "site directory" when the sandbox mode is selected. + const usedFileAccess = + selectedRuntime === SITE_RUNTIME_PLAYGROUND + ? SITE_FILE_ACCESS_SITE_DIRECTORY + : selectedFileAccess; 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 + const resolvedSitePhpVersion = resolvePhpVersion( selectedSitePhpVersion ); + const phpVersionWarning = + selectedSitePhpVersion !== undefined && selectedSitePhpVersion !== resolvedSitePhpVersion ? sprintf( - __( 'Native PHP does not support PHP %1$s. This site will run with PHP %2$s instead.' ), + __( 'PHP %1$s is no longer supported. Saving will update this site to PHP %2$s.' ), selectedSitePhpVersion, - resolvedNativePhpVersion + resolvedSitePhpVersion ) : undefined; @@ -127,9 +138,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setIsEditModalOpen( false ); }, [ isEditingSite, setIsEditModalOpen ] ); const [ siteName, setSiteName ] = useState( selectedSite?.name ?? '' ); - const [ selectedPhpVersion, setSelectedPhpVersion ] = useState< SupportedPHPVersion >( - selectedSitePhpVersionForRuntime - ); + const [ selectedPhpVersion, setSelectedPhpVersion ] = + useState< SupportedPHPVersion >( resolvedSitePhpVersion ); const getEffectiveWpVersion = useCallback( () => // undefined means that this site was created before the isWpAutoUpdating option was introduced to Studio @@ -186,6 +196,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = !! selectedSite && selectedSite.name === siteName && selectedSite.phpVersion === selectedPhpVersion && + getSiteRuntime( selectedSite ) === selectedRuntime && + getSiteFileAccess( selectedSite ) === usedFileAccess && getEffectiveWpVersion() === selectedWpVersion && Boolean( selectedSite.customDomain ) === useCustomDomain && usedCustomDomain === customDomain && @@ -209,7 +221,9 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = return; } setSiteName( selectedSite.name ); - setSelectedPhpVersion( selectedSitePhpVersionForRuntime ); + setSelectedRuntime( getSiteRuntime( selectedSite ) ); + setSelectedFileAccess( getSiteFileAccess( selectedSite ) ); + setSelectedPhpVersion( resolvePhpVersion( selectedSite.phpVersion ) ); setSelectedWpVersion( getEffectiveWpVersion() ); setUseCustomDomain( Boolean( selectedSite.customDomain ) ); setCustomDomain( selectedSite.customDomain ?? null ); @@ -225,7 +239,6 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = }, [ selectedSite, getEffectiveWpVersion, - selectedSitePhpVersionForRuntime, setAdminEmail, setAdminPassword, setAdminUsername, @@ -242,12 +255,6 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = setUseCustomDomain, ] ); - useEffect( () => { - if ( ! supportedPhpVersions.includes( selectedPhpVersion ) ) { - setSelectedPhpVersion( recommendedPhpVersion ); - } - }, [ selectedPhpVersion, recommendedPhpVersion, supportedPhpVersions ] ); - const onSiteEdit = async ( event: FormEvent ) => { event.preventDefault(); if ( ! selectedSite?.id ) { @@ -258,6 +265,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = const hasWpVersionChanged = selectedWpVersion !== getEffectiveWpVersion(); const hasPhpVersionChanged = selectedPhpVersion !== selectedSite.phpVersion; + const hasRuntimeChanged = selectedRuntime !== getSiteRuntime( selectedSite ); + const hasFileAccessChanged = usedFileAccess !== getSiteFileAccess( selectedSite ); const hasXdebugChanged = enableXdebug !== ( selectedSite.enableXdebug ?? false ); const hasDebugLogChanged = enableDebugLog !== ( selectedSite.enableDebugLog ?? false ); const hasDebugDisplayChanged = @@ -279,6 +288,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = httpsChanged: hasHttpsChanged, phpChanged: hasPhpVersionChanged, wpChanged: hasWpVersionChanged, + runtimeChanged: hasRuntimeChanged, + fileAccessChanged: hasFileAccessChanged, xdebugChanged: hasXdebugChanged, credentialsChanged: hasCredentialsChanged, debugLogChanged: hasDebugLogChanged, @@ -298,6 +309,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ...selectedSite, name: siteName, phpVersion: selectedPhpVersion, + runtime: selectedRuntime, + fileAccess: usedFileAccess, isWpAutoUpdating: selectedWpVersion === DEFAULT_WORDPRESS_VERSION, customDomain: usedCustomDomain, enableHttps: !! usedCustomDomain && enableHttps, @@ -390,8 +403,8 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = > { __( 'PHP version' ) } - { nativePhpVersionWarning && ( - + { phpVersionWarning && ( + ( { + options={ SupportedPHPVersions.map( ( version ) => ( { label: version, value: version, } ) ) } @@ -446,6 +459,63 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ) } +
+ + + +
+
{ expect( screen.getByRole( 'button', { name: 'Save' } ) ).toBeEnabled(); } ); - it( 'should show a native PHP fallback warning for unsupported stored PHP versions', async () => { + it( 'should show a fallback warning for unsupported stored PHP versions', async () => { const user = userEvent.setup(); vi.mocked( useSiteDetails ).mockReturnValue( createMock< ReturnType< typeof useSiteDetails > >( { ...baseMockSiteDetails, selectedSite: { ...baseMockSiteDetails.selectedSite, + runtime: 'native-php', phpVersion: '7.4', }, isEditModalOpen: true, } ) ); - renderWithProvider( , true ); + renderWithProvider( ); await waitFor( () => { expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); @@ -254,7 +255,7 @@ describe( 'EditSiteDetails', () => { expect( await screen.findByText( - 'Native PHP does not support PHP 7.4. This site will run with PHP 8.2 instead.' + 'PHP 7.4 is no longer supported. Saving will update this site to PHP 8.2.' ) ).toBeVisible(); } ); diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 34b0b4907d..facf38de4c 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -11,6 +11,7 @@ import { WP_CLI_DEFAULT_RESPONSE_TIMEOUT, WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT, } from 'src/constants'; +import { getDefaultSiteRuntime } from 'src/lib/beta-features'; import { CliServerProcess } from 'src/modules/cli/lib/cli-server-process'; import { createSiteViaCli, type CreateSiteOptions } from 'src/modules/cli/lib/cli-site-creator'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; @@ -179,7 +180,9 @@ export class SiteServer { }; const server = SiteServer.register( placeholderDetails, meta ); - const result = await createSiteViaCli( { ...options, siteId } ); + const runtime = options.runtime ?? ( await getDefaultSiteRuntime() ); + const result = await createSiteViaCli( { ...options, runtime, siteId } ); + server.details.runtime = runtime; server.details.port = result.port; if ( result.running ) { @@ -236,6 +239,8 @@ export class SiteServer { name: site.name, path: site.path, phpVersion: site.phpVersion, + runtime: site.runtime, + fileAccess: site.fileAccess, isWpAutoUpdating: site.isWpAutoUpdating, customDomain: site.customDomain, enableHttps: site.enableHttps, diff --git a/apps/studio/src/tests/site-server.test.ts b/apps/studio/src/tests/site-server.test.ts index fa1c963549..cd21bf3507 100644 --- a/apps/studio/src/tests/site-server.test.ts +++ b/apps/studio/src/tests/site-server.test.ts @@ -77,6 +77,10 @@ vi.mock( 'src/modules/cli/lib/cli-server-process', () => { vi.mock( 'src/storage/user-data' ); +vi.mock( 'src/lib/beta-features', () => ( { + getDefaultSiteRuntime: vi.fn().mockResolvedValue( 'playground' ), +} ) ); + describe( 'SiteServer', () => { describe( 'create', () => { beforeEach( () => { diff --git a/tools/common/lib/cli-events.ts b/tools/common/lib/cli-events.ts index 89a2850796..64b9f5fa0d 100644 --- a/tools/common/lib/cli-events.ts +++ b/tools/common/lib/cli-events.ts @@ -6,6 +6,8 @@ */ import { z } from 'zod'; import { authTokenSchema } from '@studio/common/lib/auth-token-schema'; +import { siteFileAccessSchema } from '@studio/common/lib/site-file-access'; +import { siteRuntimeSchema } from '@studio/common/lib/site-runtime'; import { snapshotSchema } from '@studio/common/types/snapshot'; /** @@ -18,6 +20,8 @@ export const siteDetailsSchema = z.object( { port: z.number(), url: z.string(), phpVersion: z.string(), + runtime: siteRuntimeSchema.optional(), + fileAccess: siteFileAccessSchema.optional(), customDomain: z.string().optional(), enableHttps: z.boolean().optional(), adminUsername: z.string().optional(), diff --git a/tools/common/lib/php-binary-metadata.ts b/tools/common/lib/php-binary-metadata.ts index d104d253d7..cf3c221e11 100644 --- a/tools/common/lib/php-binary-metadata.ts +++ b/tools/common/lib/php-binary-metadata.ts @@ -1,7 +1,7 @@ import { sprintf } from '@wordpress/i18n'; import { z } from 'zod'; import { - getClosestNativePhpVersion, + getClosestSupportedPhpVersion, LatestNativePhpSupportedVersion, NativePhpSupportedVersions, type NativePhpSupportedVersion, @@ -38,7 +38,7 @@ export function resolveNativePhpVersion( version: string ): NativePhpSupportedVe return LatestNativePhpSupportedVersion; } - const resolvedVersion = getClosestNativePhpVersion( version ); + const resolvedVersion = getClosestSupportedPhpVersion( version ); return resolvedVersion ?? validateNativePhpVersion( version ); } diff --git a/tools/common/lib/site-file-access.ts b/tools/common/lib/site-file-access.ts new file mode 100644 index 0000000000..ed43a13ce9 --- /dev/null +++ b/tools/common/lib/site-file-access.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; +import { SITE_RUNTIME_NATIVE_PHP, type SiteRuntime } from '@studio/common/lib/site-runtime'; + +export const SITE_FILE_ACCESS_SITE_DIRECTORY = 'site-directory'; +export const SITE_FILE_ACCESS_ALL_FILES = 'all-files'; + +export const siteFileAccessSchema = z.enum( [ + SITE_FILE_ACCESS_SITE_DIRECTORY, + SITE_FILE_ACCESS_ALL_FILES, +] ); +export type SiteFileAccess = z.infer< typeof siteFileAccessSchema >; + +export function getSiteFileAccess( site: { fileAccess?: SiteFileAccess } ): SiteFileAccess { + return site.fileAccess ?? SITE_FILE_ACCESS_SITE_DIRECTORY; +} + +// The Playground sandbox can only ever access the site directory, so +// "all files" is only valid for the native PHP runtime. +export function isFileAccessAllowedForRuntime( + runtime: SiteRuntime, + fileAccess: SiteFileAccess +): boolean { + return fileAccess !== SITE_FILE_ACCESS_ALL_FILES || runtime === SITE_RUNTIME_NATIVE_PHP; +} diff --git a/tools/common/lib/site-needs-restart.ts b/tools/common/lib/site-needs-restart.ts index 870ae81a7d..9c557bf6af 100644 --- a/tools/common/lib/site-needs-restart.ts +++ b/tools/common/lib/site-needs-restart.ts @@ -3,6 +3,8 @@ export interface SiteSettingChanges { httpsChanged?: boolean; phpChanged?: boolean; wpChanged?: boolean; + runtimeChanged?: boolean; + fileAccessChanged?: boolean; xdebugChanged?: boolean; credentialsChanged?: boolean; debugLogChanged?: boolean; @@ -15,6 +17,8 @@ export function siteNeedsRestart( changes: SiteSettingChanges ): boolean { httpsChanged, phpChanged, wpChanged, + runtimeChanged, + fileAccessChanged, xdebugChanged, credentialsChanged, debugLogChanged, @@ -26,6 +30,8 @@ export function siteNeedsRestart( changes: SiteSettingChanges ): boolean { httpsChanged || phpChanged || wpChanged || + runtimeChanged || + fileAccessChanged || xdebugChanged || credentialsChanged || debugLogChanged || diff --git a/tools/common/lib/site-runtime.ts b/tools/common/lib/site-runtime.ts index 938eddf366..a26cd9e401 100644 --- a/tools/common/lib/site-runtime.ts +++ b/tools/common/lib/site-runtime.ts @@ -5,3 +5,22 @@ 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 >; + +export function getSiteRuntime( site: { runtime?: SiteRuntime } ): SiteRuntime { + return site.runtime ?? SITE_RUNTIME_PLAYGROUND; +} + +// User-facing names for the runtimes ("mode"), used by the CLI and the app UI. +export const SITE_MODE_NATIVE = 'native'; +export const SITE_MODE_SANDBOX = 'sandbox'; + +export const siteModeSchema = z.enum( [ SITE_MODE_NATIVE, SITE_MODE_SANDBOX ] ); +export type SiteMode = z.infer< typeof siteModeSchema >; + +export function siteRuntimeFromMode( mode: SiteMode ): SiteRuntime { + return mode === SITE_MODE_NATIVE ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND; +} + +export function siteModeFromRuntime( runtime: SiteRuntime ): SiteMode { + return runtime === SITE_RUNTIME_NATIVE_PHP ? SITE_MODE_NATIVE : SITE_MODE_SANDBOX; +} diff --git a/tools/common/lib/tests/blueprint-settings.test.ts b/tools/common/lib/tests/blueprint-settings.test.ts index cc25d8c1a0..a038aadfe6 100644 --- a/tools/common/lib/tests/blueprint-settings.test.ts +++ b/tools/common/lib/tests/blueprint-settings.test.ts @@ -100,7 +100,7 @@ describe( 'blueprint-settings', () => { it( 'should extract all values from a complete blueprint', () => { const blueprint: BlueprintV1Declaration = { preferredVersions: { - php: '8.0', + php: '8.2', wp: '6.4', }, steps: [ { step: 'defineSiteUrl', siteUrl: 'https://dev.local' } ], @@ -109,7 +109,7 @@ describe( 'blueprint-settings', () => { const result = extractFormValuesFromBlueprint( blueprint ); expect( result ).toEqual( { - phpVersion: '8.0', + phpVersion: '8.2', wpVersion: '6.4', customDomain: 'dev.local', enableHttps: true, diff --git a/tools/common/types/php-versions.ts b/tools/common/types/php-versions.ts index adb0ae82e4..989d490860 100644 --- a/tools/common/types/php-versions.ts +++ b/tools/common/types/php-versions.ts @@ -1,7 +1,9 @@ -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; +// Studio offers the same PHP versions for both runtimes: the versions the +// bundled native PHP binaries are built for. Playground (PHP WASM) supports +// older versions at runtime, which keeps existing sites stored on a +// no-longer-offered version working until they are edited. +export const SupportedPHPVersions = [ '8.5', '8.4', '8.3', '8.2' ] as const; +export const NativePhpSupportedVersions = SupportedPHPVersions; export const LatestSupportedPHPVersion = '8.5' as const; export const LatestNativePhpSupportedVersion = NativePhpSupportedVersions[ 0 ]; @@ -32,25 +34,17 @@ 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 { +export function getClosestSupportedPhpVersion( version: string ): SupportedPHPVersion | undefined { const targetScore = getPhpVersionScore( version ); if ( targetScore === undefined ) { return undefined; } - return NativePhpSupportedVersions.reduce< NativePhpSupportedVersion >( ( closest, candidate ) => { + return SupportedPHPVersions.reduce< SupportedPHPVersion >( ( closest, candidate ) => { const closestDistance = Math.abs( getPhpVersionScore( closest )! - targetScore ); const candidateDistance = Math.abs( getPhpVersionScore( candidate )! - targetScore ); return candidateDistance < closestDistance ? candidate : closest; - }, NativePhpSupportedVersions[ 0 ] ); + }, SupportedPHPVersions[ 0 ] ); } /** @@ -58,10 +52,3 @@ export function getClosestNativePhpVersion( * 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; -} From e1cfcd8970a93c6f182c27335b5b738bfdf9d2eb Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 12 Jun 2026 10:46:15 +0100 Subject: [PATCH 02/16] Let users pick the PHP runtime at site creation, rename mode to PHP runtime, and surface it in site status --- apps/cli/commands/site/create.ts | 12 +-- apps/cli/commands/site/set.ts | 20 ++--- apps/cli/commands/site/status.ts | 32 +++++++- apps/cli/commands/site/tests/create.test.ts | 2 +- apps/cli/commands/site/tests/set.test.ts | 28 +++---- apps/cli/commands/site/tests/status.test.ts | 25 ++++++ .../src/components/content-tab-settings.tsx | 4 +- .../src/hooks/tests/use-add-site.test.tsx | 4 +- apps/studio/src/hooks/use-add-site.ts | 8 +- apps/studio/src/hooks/use-site-details.tsx | 10 ++- apps/studio/src/ipc-handlers.ts | 8 +- .../add-site/components/create-site-form.tsx | 81 +++++++++++++++++++ .../modules/add-site/tests/add-site.test.tsx | 60 +++++++++++++- .../src/modules/cli/lib/cli-site-creator.ts | 2 +- .../src/modules/cli/lib/cli-site-editor.ts | 6 +- .../site-settings/edit-site-details.tsx | 6 +- apps/studio/src/site-server.ts | 1 + tools/common/lib/site-runtime.ts | 2 +- 18 files changed, 261 insertions(+), 50 deletions(-) diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index fd4e1d0ffd..815b2e6db9 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -125,7 +125,7 @@ export async function runCommand( if ( options.fileAccess && ! isFileAccessAllowedForRuntime( siteRuntime, options.fileAccess ) ) { throw new LoggerError( __( - 'File access "all-files" requires native mode. The sandbox only has access to the site directory.' + 'File access "all-files" requires the native PHP runtime. The sandbox only has access to the site directory.' ) ); } @@ -545,7 +545,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { choices: SupportedPHPVersions, defaultDescription: RecommendedPHPVersion, } ) - .option( 'mode', { + .option( 'runtime', { type: 'string', describe: __( 'Run the site with native PHP ("native") or in the Playground sandbox ("sandbox")' @@ -556,7 +556,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'file-access', { type: 'string', describe: __( - 'Which files PHP can access in native mode: the site directory only, or all files' + 'Which files PHP can access with the native PHP runtime: the site directory only, or all files' ), choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ], defaultDescription: SITE_FILE_ACCESS_SITE_DIRECTORY, @@ -618,15 +618,15 @@ export const registerCommand = ( yargs: StudioArgv ) => { let adminUsername = argv.adminUsername; let adminPassword = argv.adminPassword; let adminEmail = argv.adminEmail; - const runtime = argv.mode - ? siteRuntimeFromMode( argv.mode as SiteMode ) + const runtime = argv.runtime + ? siteRuntimeFromMode( argv.runtime as SiteMode ) : SITE_RUNTIME_PLAYGROUND; const fileAccess = argv.fileAccess as SiteFileAccess | undefined; if ( fileAccess && ! isFileAccessAllowedForRuntime( runtime, fileAccess ) ) { logger.reportError( new LoggerError( __( - 'File access "all-files" requires native mode. The sandbox only has access to the site directory.' + 'File access "all-files" requires the native PHP runtime. The sandbox only has access to the site directory.' ) ) ); diff --git a/apps/cli/commands/site/set.ts b/apps/cli/commands/site/set.ts index 981454f1cf..a2090183df 100644 --- a/apps/cli/commands/site/set.ts +++ b/apps/cli/commands/site/set.ts @@ -60,7 +60,7 @@ export interface SetCommandOptions { https?: boolean; php?: string; wp?: string; - mode?: SiteMode; + runtime?: SiteMode; fileAccess?: SiteFileAccess; xdebug?: boolean; adminUsername?: string; @@ -77,7 +77,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) https, php, wp, - mode, + runtime, fileAccess, xdebug, adminUsername, @@ -93,7 +93,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) https === undefined && php === undefined && wp === undefined && - mode === undefined && + runtime === undefined && fileAccess === undefined && xdebug === undefined && adminUsername === undefined && @@ -104,7 +104,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) ) { throw new LoggerError( __( - 'At least one option (--name, --domain, --https, --php, --wp, --mode, --file-access, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --runtime, --file-access, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' ) ); } @@ -140,12 +140,12 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) let site = await getSiteByFolder( sitePath ); logger.reportSuccess( __( 'Site loaded' ) ); - const effectiveRuntime = mode ? siteRuntimeFromMode( mode ) : getSiteRuntime( site ); + const effectiveRuntime = runtime ? siteRuntimeFromMode( runtime ) : getSiteRuntime( site ); const effectiveFileAccess = fileAccess ?? getSiteFileAccess( site ); if ( ! isFileAccessAllowedForRuntime( effectiveRuntime, effectiveFileAccess ) ) { throw new LoggerError( __( - 'File access "all-files" requires native mode. The sandbox only has access to the site directory. Use --mode native or --file-access site-directory.' + 'File access "all-files" requires the native PHP runtime. The sandbox only has access to the site directory. Use --runtime native or --file-access site-directory.' ) ); } @@ -191,7 +191,7 @@ export async function runCommand( sitePath: string, options: SetCommandOptions ) const httpsChanged = https !== undefined && https !== site.enableHttps; const phpChanged = validatedPhp !== undefined && validatedPhp !== site.phpVersion; const wpChanged = wp !== undefined; - const runtimeChanged = mode !== undefined && effectiveRuntime !== getSiteRuntime( site ); + const runtimeChanged = runtime !== undefined && effectiveRuntime !== getSiteRuntime( site ); const fileAccessChanged = fileAccess !== undefined && fileAccess !== getSiteFileAccess( site ); const xdebugChanged = xdebug !== undefined && xdebug !== site.enableXdebug; const adminUsernameChanged = @@ -410,7 +410,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { return value; }, } ) - .option( 'mode', { + .option( 'runtime', { type: 'string', description: __( 'Run the site with native PHP ("native") or in the Playground sandbox ("sandbox")' @@ -420,7 +420,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'file-access', { type: 'string', description: __( - 'Which files PHP can access in native mode: the site directory only, or all files' + 'Which files PHP can access with the native PHP runtime: the site directory only, or all files' ), choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ], } ) @@ -457,7 +457,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { https: argv.https, php: argv.php, wp: argv.wp, - mode: argv.mode as SiteMode | undefined, + runtime: argv.runtime as SiteMode | undefined, fileAccess: argv.fileAccess as SiteFileAccess | undefined, xdebug: argv.xdebug, adminUsername: argv.adminUsername, diff --git a/apps/cli/commands/site/status.ts b/apps/cli/commands/site/status.ts index 996d5612b5..91280a2f04 100644 --- a/apps/cli/commands/site/status.ts +++ b/apps/cli/commands/site/status.ts @@ -1,5 +1,11 @@ import { getWordPressVersion } from '@studio/common/lib/get-wordpress-version'; import { decodePassword } from '@studio/common/lib/passwords'; +import { getSiteFileAccess, SITE_FILE_ACCESS_ALL_FILES } from '@studio/common/lib/site-file-access'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + siteModeFromRuntime, +} from '@studio/common/lib/site-runtime'; import { SiteCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; import { __, _n } from '@wordpress/i18n'; import CliTable3 from 'cli-table3'; @@ -34,10 +40,20 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) /* translators: status value for the Xdebug setting in the site status output */ const xdebugStatus = site.enableXdebug ? __( 'Enabled' ) : __( 'Disabled' ); + const runtime = getSiteRuntime( site ); + const fileAccess = getSiteFileAccess( site ); + /* translators: value for the PHP runtime in the site status output */ + const runtimeLabel = runtime === SITE_RUNTIME_NATIVE_PHP ? __( 'Native' ) : __( 'Sandbox' ); + const fileAccessLabel = + /* translators: value for the File access setting in the site status output */ + fileAccess === SITE_FILE_ACCESS_ALL_FILES ? __( 'All files' ) : __( 'Site directory' ); + const siteData: { key: string; jsonKey: string; value: string | undefined; + // Raw machine-readable value for the JSON output; defaults to `value` + jsonValue?: string; type?: string; hidden?: boolean; }[] = [ @@ -57,6 +73,18 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) { key: __( 'Site Path' ), jsonKey: 'sitePath', value: sitePath }, { key: __( 'Status' ), jsonKey: 'status', value: status }, { key: __( 'PHP version' ), jsonKey: 'phpVersion', value: site.phpVersion }, + { + key: __( 'PHP runtime' ), + jsonKey: 'runtime', + value: runtimeLabel, + jsonValue: siteModeFromRuntime( runtime ), + }, + { + key: __( 'File access' ), + jsonKey: 'fileAccess', + value: fileAccessLabel, + jsonValue: fileAccess, + }, { key: __( 'WP version' ), jsonKey: 'wpVersion', value: wpVersion }, { key: __( 'Xdebug' ), jsonKey: 'xdebug', value: xdebugStatus }, { @@ -89,13 +117,13 @@ export async function runCommand( siteFolder: string, format: 'table' | 'json' ) console.table( table.toString() ); } else { const logData = Object.fromEntries( - siteData.flatMap( ( { jsonKey, value } ) => + siteData.flatMap( ( { jsonKey, value, jsonValue } ) => jsonKey === 'status' ? [ [ jsonKey, value ], [ 'isOnline', isOnline ], ] - : [ [ jsonKey, value ] ] + : [ [ jsonKey, jsonValue ?? value ] ] ) ); diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index f33b8fefd6..45c803d875 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -337,7 +337,7 @@ describe( 'CLI: studio site create', () => { ...defaultTestOptions, fileAccess: 'all-files', } ) - ).rejects.toThrow( 'File access "all-files" requires native mode.' ); + ).rejects.toThrow( 'File access "all-files" requires the native PHP runtime.' ); expect( saveCliConfig ).not.toHaveBeenCalled(); } ); diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index 5f9a84f4f8..d591318881 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -109,27 +109,27 @@ describe( 'CLI: studio site set', () => { describe( 'Validation', () => { it( 'should throw when no options provided', async () => { await expect( runCommand( testSitePath, {} ) ).rejects.toThrow( - 'At least one option (--name, --domain, --https, --php, --wp, --mode, --file-access, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' + 'At least one option (--name, --domain, --https, --php, --wp, --runtime, --file-access, --xdebug, --admin-username, --admin-password, --admin-email, --debug-log, --debug-display) is required.' ); } ); - it( 'should throw when "all-files" file access is combined with sandbox mode', async () => { + it( 'should throw when "all-files" file access is combined with the sandbox runtime', async () => { await expect( - runCommand( testSitePath, { mode: SITE_MODE_SANDBOX, fileAccess: 'all-files' } ) - ).rejects.toThrow( 'File access "all-files" requires native mode.' ); + runCommand( testSitePath, { runtime: SITE_MODE_SANDBOX, fileAccess: 'all-files' } ) + ).rejects.toThrow( 'File access "all-files" requires the native PHP runtime.' ); expect( saveCliConfig ).not.toHaveBeenCalled(); } ); - it( 'should throw when switching an "all-files" site to sandbox mode without resetting file access', async () => { + it( 'should throw when switching an "all-files" site to the sandbox runtime without resetting file access', async () => { vi.mocked( getSiteByFolder ).mockResolvedValue( { ...getTestSite(), runtime: 'native-php', fileAccess: 'all-files', } ); - await expect( runCommand( testSitePath, { mode: SITE_MODE_SANDBOX } ) ).rejects.toThrow( - 'File access "all-files" requires native mode.' + await expect( runCommand( testSitePath, { runtime: SITE_MODE_SANDBOX } ) ).rejects.toThrow( + 'File access "all-files" requires the native PHP runtime.' ); } ); @@ -343,9 +343,9 @@ describe( 'CLI: studio site set', () => { } ); } ); - describe( 'Mode and file access changes', () => { - it( 'should update the runtime when the mode changes', async () => { - await runCommand( testSitePath, { mode: SITE_MODE_NATIVE } ); + describe( 'Runtime and file access changes', () => { + it( 'should update the stored runtime when it changes', async () => { + await runCommand( testSitePath, { runtime: SITE_MODE_NATIVE } ); expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { @@ -356,10 +356,10 @@ describe( 'CLI: studio site set', () => { ); } ); - it( 'should restart a running site when the mode changes', async () => { + it( 'should restart a running site when the runtime changes', async () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - await runCommand( testSitePath, { mode: SITE_MODE_NATIVE } ); + await runCommand( testSitePath, { runtime: SITE_MODE_NATIVE } ); expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); expect( startWordPressServer ).toHaveBeenCalled(); @@ -382,8 +382,8 @@ describe( 'CLI: studio site set', () => { ); } ); - it( 'should report no changes when the mode matches the current runtime', async () => { - await expect( runCommand( testSitePath, { mode: SITE_MODE_SANDBOX } ) ).rejects.toThrow( + it( 'should report no changes when the runtime matches the current one', async () => { + await expect( runCommand( testSitePath, { runtime: SITE_MODE_SANDBOX } ) ).rejects.toThrow( 'No changes to apply. The site already has the specified settings.' ); } ); diff --git a/apps/cli/commands/site/tests/status.test.ts b/apps/cli/commands/site/tests/status.test.ts index 2882940e78..0b67c5d10a 100644 --- a/apps/cli/commands/site/tests/status.test.ts +++ b/apps/cli/commands/site/tests/status.test.ts @@ -84,6 +84,8 @@ describe( 'CLI: studio site status', () => { status: 'πŸ”΄ Offline', isOnline: false, phpVersion: '8.0', + runtime: 'sandbox', + fileAccess: 'site-directory', wpVersion: '6.4', xdebug: 'Disabled', adminUsername: 'admin', @@ -119,6 +121,8 @@ describe( 'CLI: studio site status', () => { status: '🟒 Online', isOnline: true, phpVersion: '8.0', + runtime: 'sandbox', + fileAccess: 'site-directory', wpVersion: '6.4', xdebug: 'Disabled', adminUsername: 'admin', @@ -132,6 +136,25 @@ describe( 'CLI: studio site status', () => { consoleSpy.mockRestore(); } ); + it( 'should report the native runtime and file access', async () => { + vi.mocked( getSiteByFolder ).mockResolvedValue( { + ...testSite, + runtime: 'native-php', + fileAccess: 'all-files', + } ); + + const consoleSpy = vi.spyOn( console, 'log' ).mockImplementation( () => {} ); + + await runCommand( '/path/to/site', 'json' ); + + expect( consoleSpy ).toHaveBeenCalledWith( expect.stringContaining( '"runtime": "native"' ) ); + expect( consoleSpy ).toHaveBeenCalledWith( + expect.stringContaining( '"fileAccess": "all-files"' ) + ); + + consoleSpy.mockRestore(); + } ); + it( 'should handle custom domain in site URL', async () => { vi.mocked( getSiteUrl ).mockReturnValue( 'http://my-site.wp.local' ); @@ -160,6 +183,8 @@ describe( 'CLI: studio site status', () => { sitePath: '/path/to/site', status: 'πŸ”΄ Offline', isOnline: false, + runtime: 'sandbox', + fileAccess: 'site-directory', wpVersion: '6.4', xdebug: 'Disabled', adminUsername: 'admin', diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index 84819b63b5..98a1da87cf 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -202,8 +202,8 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) ) }
- - { /* translators: value for the Mode setting on the site settings screen */ } + + { /* translators: value for the PHP runtime setting on the site settings screen */ } { isNativePhpRuntime ? __( 'Native' ) : __( 'Sandbox' ) } 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 5af5a82b6b..fde9bb51ff 100644 --- a/apps/studio/src/hooks/tests/use-add-site.test.tsx +++ b/apps/studio/src/hooks/tests/use-add-site.test.tsx @@ -173,7 +173,9 @@ describe( 'useAddSite', () => { false, undefined, // adminUsername undefined, // adminPassword - undefined // adminEmail + undefined, // adminEmail + undefined, // runtime + undefined // fileAccess ); } ); diff --git a/apps/studio/src/hooks/use-add-site.ts b/apps/studio/src/hooks/use-add-site.ts index 5dc79b4f06..2439eb6d1b 100644 --- a/apps/studio/src/hooks/use-add-site.ts +++ b/apps/studio/src/hooks/use-add-site.ts @@ -2,6 +2,8 @@ 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 { 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'; import { SupportedPHPVersion } from '@studio/common/types/php-versions'; import { useI18n } from '@wordpress/react-i18n'; import { useCallback, useMemo, useState } from 'react'; @@ -26,6 +28,8 @@ export interface CreateSiteFormValues { sitePath: string; phpVersion: SupportedPHPVersion; wpVersion: string; + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; useCustomDomain: boolean; customDomain: string | null; enableHttps: boolean; @@ -294,7 +298,9 @@ export function useAddSite() { shouldSkipStart, formValues.adminUsername, formValues.adminPassword, - formValues.adminEmail + formValues.adminEmail, + formValues.runtime, + formValues.fileAccess ); } 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 a93d98f903..cf8eeaad90 100644 --- a/apps/studio/src/hooks/use-site-details.tsx +++ b/apps/studio/src/hooks/use-site-details.tsx @@ -35,7 +35,9 @@ interface SiteDetailsContext { noStart?: boolean, adminUsername?: string, adminPassword?: string, - adminEmail?: string + adminEmail?: string, + runtime?: SiteRuntime, + fileAccess?: SiteFileAccess ) => Promise< SiteDetails | void >; copySite: ( sourceSiteId: string ) => Promise< SiteDetails | void >; startServer: ( site: SiteDetails ) => Promise< void >; @@ -249,7 +251,9 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { noStart?: boolean, adminUsername?: string, adminPassword?: string, - adminEmail?: string + adminEmail?: string, + runtime?: SiteRuntime, + fileAccess?: SiteFileAccess ) => { // Function to handle error messages and cleanup const showError = ( error?: unknown, hasBlueprint?: boolean ) => { @@ -322,6 +326,8 @@ export function SiteDetailsProvider( { children }: SiteDetailsProviderProps ) { enableHttps, siteId: tempSiteId, phpVersion, + runtime, + fileAccess, blueprint, adminUsername, adminPassword, diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 0e73f276a1..0a64e27cc3 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -815,6 +815,8 @@ export async function createSite( enableHttps?: boolean; siteId?: string; phpVersion?: string; + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; blueprint?: Blueprint; adminUsername?: string; adminPassword?: string; @@ -830,6 +832,8 @@ export async function createSite( siteId: providedSiteId, blueprint, phpVersion, + runtime, + fileAccess, adminUsername, adminPassword, adminEmail, @@ -858,6 +862,8 @@ export async function createSite( name: siteName, wpVersion, phpVersion, + runtime, + fileAccess, customDomain, enableHttps, siteId, @@ -978,7 +984,7 @@ export async function updateSite( } if ( getSiteRuntime( updatedSite ) !== getSiteRuntime( currentSite ) ) { - options.mode = siteModeFromRuntime( getSiteRuntime( updatedSite ) ); + options.runtime = siteModeFromRuntime( getSiteRuntime( updatedSite ) ); } if ( getSiteFileAccess( updatedSite ) !== getSiteFileAccess( currentSite ) ) { 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 fd05bf7230..94b61388ad 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,6 +8,16 @@ import { validateAdminEmail, validateAdminUsername, } from '@studio/common/lib/passwords'; +import { + SITE_FILE_ACCESS_ALL_FILES, + SITE_FILE_ACCESS_SITE_DIRECTORY, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; +import { + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { RecommendedPHPVersion, SupportedPHPVersion, @@ -27,6 +37,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'; @@ -89,6 +100,20 @@ export const CreateSiteForm = ( { const [ wpVersion, setWpVersion ] = useState( defaultValues.wpVersion ?? DEFAULT_WORDPRESS_VERSION ); + // The "Native PHP runtime" beta feature selects the default mode for new sites + const defaultRuntime = useRootSelector( ( state ) => + state.betaFeatures.features.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND + ); + const [ selectedRuntime, setSelectedRuntime ] = useState< SiteRuntime >( defaultRuntime ); + const [ selectedFileAccess, setSelectedFileAccess ] = useState< SiteFileAccess >( + SITE_FILE_ACCESS_SITE_DIRECTORY + ); + // The sandbox only has access to the site directory, so "all files" is + // forced back to "site directory" when the sandbox mode is selected. + const usedFileAccess = + selectedRuntime === SITE_RUNTIME_PLAYGROUND + ? SITE_FILE_ACCESS_SITE_DIRECTORY + : selectedFileAccess; const [ useCustomDomain, setUseCustomDomain ] = useState( false ); const [ customDomain, setCustomDomain ] = useState< string | null >( null ); const [ enableHttps, setEnableHttps ] = useState( false ); @@ -319,6 +344,8 @@ export const CreateSiteForm = ( { sitePath, phpVersion, wpVersion, + runtime: selectedRuntime, + fileAccess: usedFileAccess, useCustomDomain, customDomain, enableHttps, @@ -331,6 +358,8 @@ export const CreateSiteForm = ( { sitePath, phpVersion, wpVersion, + selectedRuntime, + usedFileAccess, useCustomDomain, customDomain, enableHttps, @@ -499,6 +528,58 @@ export const CreateSiteForm = ( { />
+
+
+ + + id="php-runtime-select" + value={ selectedRuntime } + options={ [ + { label: __( 'Native' ), value: SITE_RUNTIME_NATIVE_PHP }, + { label: __( 'Sandbox' ), value: SITE_RUNTIME_PLAYGROUND }, + ] } + onChange={ ( value ) => setSelectedRuntime( value ) } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + + { selectedRuntime === SITE_RUNTIME_NATIVE_PHP + ? __( 'Runs the site with native PHP for the best performance.' ) + : __( 'Runs the site in an isolated WordPress Playground sandbox.' ) } + +
+ +
+ + + id="file-access-select" + disabled={ selectedRuntime === SITE_RUNTIME_PLAYGROUND } + value={ usedFileAccess } + options={ [ + { + label: __( 'Site directory' ), + value: SITE_FILE_ACCESS_SITE_DIRECTORY, + }, + { label: __( 'All files' ), value: SITE_FILE_ACCESS_ALL_FILES }, + ] } + onChange={ ( value ) => setSelectedFileAccess( value ) } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + + { selectedRuntime === SITE_RUNTIME_PLAYGROUND + ? __( 'The sandbox can only access the site directory.' ) + : usedFileAccess === SITE_FILE_ACCESS_ALL_FILES + ? __( 'PHP can access any file your user account can access.' ) + : __( 'PHP can only access files inside the site directory.' ) } + +
+
+
{ __( 'Admin credentials' ) }
diff --git a/apps/studio/src/modules/add-site/tests/add-site.test.tsx b/apps/studio/src/modules/add-site/tests/add-site.test.tsx index 54bb5759f6..6b4e5316fe 100644 --- a/apps/studio/src/modules/add-site/tests/add-site.test.tsx +++ b/apps/studio/src/modules/add-site/tests/add-site.test.tsx @@ -215,7 +215,9 @@ describe( 'AddSite', () => { false, 'admin', expect.any( String ), - 'admin@localhost.com' + 'admin@localhost.com', + 'playground', + 'site-directory' ); } ); @@ -433,10 +435,64 @@ describe( 'AddSite', () => { false, 'admin', expect.any( String ), - 'admin@localhost.com' + 'admin@localhost.com', + 'playground', + 'site-directory' ); } ); + it( 'should allow selecting the native PHP runtime and file access', async () => { + const user = userEvent.setup(); + mockGenerateProposedSitePath.mockResolvedValue( { + path: '/default_path/my-wordpress-website', + name: 'My WordPress Website', + isEmpty: true, + isWordPress: false, + } ); + + renderWithProvider( ); + + await user.click( screen.getByRole( 'button', { name: 'Add site' } ) ); + await user.click( screen.getByTestId( 'create-site-option-button' ) ); + await user.click( await screen.findByRole( 'button', { name: /Empty site/ } ) ); + await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); + await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); + + // File access only becomes selectable with the native PHP runtime + expect( screen.getByLabelText( 'File access' ) ).toBeDisabled(); + await user.selectOptions( screen.getByLabelText( 'PHP runtime' ), 'native-php' ); + await user.selectOptions( screen.getByLabelText( 'File access' ), 'all-files' ); + + mockShowOpenFolderDialog.mockResolvedValue( { + path: 'test', + name: 'test', + isEmpty: true, + isWordPress: false, + } ); + await user.click( screen.getByTestId( 'select-path-button' ) ); + const dialog = screen.getByRole( 'dialog' ); + await user.click( within( dialog ).getByRole( 'button', { name: 'Add site' } ) ); + + await waitFor( () => { + expect( mockCreateSite ).toHaveBeenCalledWith( + 'test', + 'My WordPress Website', + 'latest', + undefined, + false, + expect.objectContaining( { slug: 'empty' } ), + '8.4', + expect.any( Function ), + false, + 'admin', + expect.any( String ), + 'admin@localhost.com', + 'native-php', + 'all-files' + ); + } ); + } ); + it( 'should allow selecting a different PHP version', async () => { const user = userEvent.setup(); mockGenerateProposedSitePath.mockResolvedValue( { diff --git a/apps/studio/src/modules/cli/lib/cli-site-creator.ts b/apps/studio/src/modules/cli/lib/cli-site-creator.ts index d9a58c9d0f..fb5fae3274 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-creator.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-creator.ts @@ -143,7 +143,7 @@ function buildCliArgs( options: CreateSiteOptions ): string[] { } if ( options.runtime ) { - args.push( '--mode', siteModeFromRuntime( options.runtime ) ); + args.push( '--runtime', siteModeFromRuntime( options.runtime ) ); } if ( options.fileAccess ) { diff --git a/apps/studio/src/modules/cli/lib/cli-site-editor.ts b/apps/studio/src/modules/cli/lib/cli-site-editor.ts index 19d1ebfb6b..9649def25d 100644 --- a/apps/studio/src/modules/cli/lib/cli-site-editor.ts +++ b/apps/studio/src/modules/cli/lib/cli-site-editor.ts @@ -18,7 +18,7 @@ export interface EditSiteOptions { https?: boolean; php?: string; wp?: string; - mode?: SiteMode; + runtime?: SiteMode; fileAccess?: SiteFileAccess; xdebug?: boolean; adminUsername?: string; @@ -84,8 +84,8 @@ function buildCliArgs( options: EditSiteOptions ): string[] { args.push( '--wp', options.wp ); } - if ( options.mode !== undefined ) { - args.push( '--mode', options.mode ); + if ( options.runtime !== undefined ) { + args.push( '--runtime', options.runtime ); } if ( options.fileAccess !== undefined ) { 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 db86751f10..37488e3306 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -461,12 +461,12 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) =
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 37488e3306..5771336850 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -511,7 +511,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = ? __( 'The sandbox can only access the site directory.' ) : usedFileAccess === SITE_FILE_ACCESS_ALL_FILES ? __( 'PHP can access any file your user account can access.' ) - : __( 'PHP can only access files inside the site directory.' ) } + : __( "Restricts the site's file access to the site directory." ) }
From 6ae6876824e24aa5c45391fba9f4a4f478d4294a Mon Sep 17 00:00:00 2001 From: bcotrim Date: Fri, 12 Jun 2026 17:19:29 +0100 Subject: [PATCH 05/16] Track weekly active sites by PHP runtime --- apps/studio/src/lib/bump-stats.ts | 31 +++++++ apps/studio/src/lib/site-runtime-stats.ts | 38 ++++++++ apps/studio/src/lib/tests/bump-stats.test.ts | 93 +++++++++++++++++++- apps/studio/src/site-server.ts | 6 ++ apps/studio/src/storage/storage-types.ts | 3 + 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 apps/studio/src/lib/site-runtime-stats.ts diff --git a/apps/studio/src/lib/bump-stats.ts b/apps/studio/src/lib/bump-stats.ts index ce3ccc3223..8bb6f8c99b 100644 --- a/apps/studio/src/lib/bump-stats.ts +++ b/apps/studio/src/lib/bump-stats.ts @@ -4,6 +4,16 @@ import { LastBumpStatsProvider, AggregateInterval, } from '@studio/common/lib/bump-stat'; +import { + getSiteFileAccess, + SITE_FILE_ACCESS_ALL_FILES, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; import type { ImporterType } from '@studio/common/lib/import-export-events'; @@ -31,6 +41,9 @@ export enum StatsGroup { STUDIO_CODE_UI_RUN = 'studio-code-ui-run', STUDIO_CODE_UI_WKLY_UNQ = 'studio-code-ui-wk-unq', STUDIO_CODE_UI_MON_UNQ = 'studio-code-ui-mon-unq', + // Weekly count of active sites by runtime + file access. Bumped on site + // start, deduped to once per site per week so restarts don't inflate it. + STUDIO_SITE_RUNTIME_WEEKLY = 'studio-app-runtime-wk', } export enum StatsMetric { @@ -56,6 +69,10 @@ export enum StatsMetric { REMOTE_BLUEPRINT = 'remote-blueprint', FILE_BLUEPRINT = 'file-blueprint', NO_BLUEPRINT = 'no-blueprint', + // Site runtime (composite of runtime + file access) + RUNTIME_NATIVE_SITE_DIR = 'native-site-dir', + RUNTIME_NATIVE_ALL_FILES = 'native-all-files', + RUNTIME_SANDBOX = 'sandbox', } const lastBumpStatsProvider: LastBumpStatsProvider = { @@ -117,6 +134,20 @@ export function getImporterMetric( importer?: ImporterType ): StatsMetric { } } +// Composite runtime + file-access metric for the weekly active-sites stat. +// Sandbox is always confined to the site directory, so it has no file-access split. +export function getSiteRuntimeStat( site: { + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; +} ): StatsMetric { + if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { + return getSiteFileAccess( site ) === SITE_FILE_ACCESS_ALL_FILES + ? StatsMetric.RUNTIME_NATIVE_ALL_FILES + : StatsMetric.RUNTIME_NATIVE_SITE_DIR; + } + return StatsMetric.RUNTIME_SANDBOX; +} + export function getBlueprintMetric( blueprintSlug: string | undefined ): string { if ( ! blueprintSlug ) { return StatsMetric.NO_BLUEPRINT; diff --git a/apps/studio/src/lib/site-runtime-stats.ts b/apps/studio/src/lib/site-runtime-stats.ts new file mode 100644 index 0000000000..d0d4913e9a --- /dev/null +++ b/apps/studio/src/lib/site-runtime-stats.ts @@ -0,0 +1,38 @@ +import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; +import { type SiteRuntime } from '@studio/common/lib/site-runtime'; +import { isSameWeek } from 'date-fns'; +import { bumpStat, getSiteRuntimeStat, StatsGroup } from 'src/lib/bump-stats'; +import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; + +interface SiteRuntimeUsage { + id: string; + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; +} + +/** + * Counts a site toward the weekly active-sites-by-runtime stat, deduped to once + * per site per week so successive restarts don't inflate the numbers. The + * per-site marker lives in `app.json`'s site metadata. + */ +export async function recordSiteRuntimeUsage( site: SiteRuntimeUsage ): Promise< void > { + const now = Date.now(); + let shouldBump = false; + + try { + await lockAppdata(); + const userData = await loadUserData(); + const metadata = userData.siteMetadata[ site.id ]; + if ( ! metadata?.runtimeStatBumpedAt || ! isSameWeek( metadata.runtimeStatBumpedAt, now ) ) { + userData.siteMetadata[ site.id ] = { ...metadata, runtimeStatBumpedAt: now }; + await saveUserData( userData ); + shouldBump = true; + } + } finally { + await unlockAppdata(); + } + + if ( shouldBump ) { + bumpStat( StatsGroup.STUDIO_SITE_RUNTIME_WEEKLY, getSiteRuntimeStat( site ) ); + } +} diff --git a/apps/studio/src/lib/tests/bump-stats.test.ts b/apps/studio/src/lib/tests/bump-stats.test.ts index 6a3e41ef69..6b231d2897 100644 --- a/apps/studio/src/lib/tests/bump-stats.test.ts +++ b/apps/studio/src/lib/tests/bump-stats.test.ts @@ -3,7 +3,14 @@ import { waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { EMPTY_USER_DATA } from 'src/storage/storage-types'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; -import { bumpStat, bumpAggregatedUniqueStat, StatsGroup, StatsMetric } from '../bump-stats'; +import { + bumpStat, + bumpAggregatedUniqueStat, + getSiteRuntimeStat, + StatsGroup, + StatsMetric, +} from '../bump-stats'; +import { recordSiteRuntimeUsage } from '../site-runtime-stats'; vi.mock( 'src/storage/user-data', () => ( { loadUserData: vi.fn(), @@ -224,3 +231,87 @@ describe( 'bumpAggregatedUniqueStat', () => { } ); } ); + +describe( 'getSiteRuntimeStat', () => { + test( 'maps native + all files to the all-files metric', () => { + expect( getSiteRuntimeStat( { runtime: 'native-php', fileAccess: 'all-files' } ) ).toBe( + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + } ); + + test( 'maps native + site directory to the site-dir metric', () => { + expect( getSiteRuntimeStat( { runtime: 'native-php', fileAccess: 'site-directory' } ) ).toBe( + StatsMetric.RUNTIME_NATIVE_SITE_DIR + ); + } ); + + test( 'defaults native without file access to the site-dir metric', () => { + expect( getSiteRuntimeStat( { runtime: 'native-php' } ) ).toBe( + StatsMetric.RUNTIME_NATIVE_SITE_DIR + ); + } ); + + test( 'maps sandbox to the sandbox metric regardless of file access', () => { + expect( getSiteRuntimeStat( { runtime: 'playground', fileAccess: 'all-files' } ) ).toBe( + StatsMetric.RUNTIME_SANDBOX + ); + } ); + + test( 'defaults an unset runtime to sandbox', () => { + expect( getSiteRuntimeStat( {} ) ).toBe( StatsMetric.RUNTIME_SANDBOX ); + } ); +} ); + +describe( 'recordSiteRuntimeUsage', () => { + const nativeAllFilesSite = { + id: 'site-1', + runtime: 'native-php', + fileAccess: 'all-files', + } as const; + + test( 'bumps the weekly runtime stat and records the marker on first start', async () => { + const mockRequest = mockBumpStatRequest( + StatsGroup.STUDIO_SITE_RUNTIME_WEEKLY, + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); + expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStatBumpedAt ).toBeTypeOf( 'number' ); + } ); + + test( 'does not bump again within the same week', async () => { + // Feb 6 2024 and Feb 4 2024 fall in the same week (Sunday starts the week). + mockCurrentTime( Date.UTC( 2024, 1, 6 ) ); + mockUserData.siteMetadata[ 'site-1' ] = { runtimeStatBumpedAt: Date.UTC( 2024, 1, 4 ) }; + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( saveUserData ).not.toHaveBeenCalled(); + } ); + + test( 'bumps again once a new week has started', async () => { + // Feb 1 2024 and Feb 4 2024 fall in different weeks (Feb 4 is a Sunday). + mockCurrentTime( Date.UTC( 2024, 1, 4 ) ); + mockUserData.siteMetadata[ 'site-1' ] = { runtimeStatBumpedAt: Date.UTC( 2024, 1, 1 ) }; + const mockRequest = mockBumpStatRequest( + StatsGroup.STUDIO_SITE_RUNTIME_WEEKLY, + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); + expect( saveUserData ).toHaveBeenCalled(); + } ); + + test( 'preserves existing site metadata when recording the marker', async () => { + mockUserData.siteMetadata[ 'site-1' ] = { sortOrder: 3 }; + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( mockUserData.siteMetadata[ 'site-1' ]?.sortOrder ).toBe( 3 ); + expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStatBumpedAt ).toBeTypeOf( 'number' ); + } ); +} ); diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 9bd879beeb..29a5da7347 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -12,6 +12,7 @@ import { WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT, } from 'src/constants'; import { getDefaultSiteRuntime } from 'src/lib/beta-features'; +import { recordSiteRuntimeUsage } from 'src/lib/site-runtime-stats'; import { CliServerProcess } from 'src/modules/cli/lib/cli-server-process'; import { createSiteViaCli, type CreateSiteOptions } from 'src/modules/cli/lib/cli-site-creator'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; @@ -232,6 +233,11 @@ export class SiteServer { console.log( `Starting server for '${ this.details.name }'` ); await this.server.start(); + + // Fire-and-forget: telemetry must never block or fail a site start. + recordSiteRuntimeUsage( this.details ).catch( ( error ) => { + console.error( 'Failed to record site runtime usage stat:', error ); + } ); } updateSiteDetails( site: SiteDetails ) { diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index 317e7408cc..a0973eb76e 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -15,6 +15,9 @@ export interface AppdataSiteData { themeDetails?: SiteDetails[ 'themeDetails' ]; siteIconPath?: SiteDetails[ 'siteIconPath' ]; sortOrder?: number; + // Unix ms of the last time this site's runtime was counted in usage stats. + // Dedupes the weekly per-site runtime bump so restarts don't inflate it. + runtimeStatBumpedAt?: number; } export interface AiSessionSitePlacement { From b173b0fd8b3f5ee7e7ca2830a057973abd7703b0 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 15 Jun 2026 19:06:37 +0100 Subject: [PATCH 06/16] Default new sites to native PHP, remove the beta toggle, and track runtime usage daily --- apps/cli/commands/site/create.ts | 2 +- apps/cli/commands/wp.ts | 31 +----- apps/cli/index.ts | 10 +- apps/cli/lib/run-wp-cli-command.ts | 100 +----------------- apps/studio/e2e/e2e-helpers.ts | 1 - .../tests/content-tab-settings.test.tsx | 4 +- apps/studio/src/ipc-types.d.ts | 1 - apps/studio/src/lib/beta-features.ts | 19 ---- apps/studio/src/lib/bump-stats.ts | 7 +- apps/studio/src/lib/site-runtime-copy.ts | 24 +++++ apps/studio/src/lib/site-runtime-stats.ts | 25 +++-- apps/studio/src/lib/tests/bump-stats.test.ts | 51 +++++++-- .../06-seed-site-runtime-from-beta-flag.ts | 90 ---------------- apps/studio/src/migrations/index.ts | 2 - .../add-site/components/create-site-form.tsx | 17 ++- .../modules/add-site/tests/add-site.test.tsx | 11 +- .../site-settings/edit-site-details.tsx | 8 +- .../tests/edit-site-details.test.tsx | 4 +- apps/studio/src/site-server.ts | 6 +- apps/studio/src/storage/storage-types.ts | 6 +- apps/studio/src/stores/beta-features-slice.ts | 2 +- 21 files changed, 121 insertions(+), 300 deletions(-) create mode 100644 apps/studio/src/lib/site-runtime-copy.ts delete mode 100644 apps/studio/src/migrations/06-seed-site-runtime-from-beta-flag.ts diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index e4f9216d77..f6e9deea63 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -118,7 +118,7 @@ export async function runCommand( sitePath: string, options: CreateCommandOptions ): Promise< void > { - const siteRuntime = options.runtime ?? SITE_RUNTIME_PLAYGROUND; + const siteRuntime = options.runtime ?? SITE_RUNTIME_NATIVE_PHP; if ( options.fileAccess && ! isFileAccessAllowedForRuntime( siteRuntime, options.fileAccess ) ) { throw new LoggerError( __( diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index 2100fd18ce..dd96f57a90 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -16,7 +16,7 @@ import { getPhpBinaryPath, getWpCliPharPath } from 'cli/lib/dependency-managemen import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; import { getDefaultPhpArgs } from 'cli/lib/native-php/config'; import { DETACH_FOR_GROUP_KILL, reapPhpTreeOnInterrupt } from 'cli/lib/native-php/php-process'; -import { runWpCliCommand, runGlobalWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; +import { runWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; @@ -45,11 +45,6 @@ async function pipePHPResponse( response: WpCliResponse ) { await Promise.all( [ stderrPipe(), stdoutPipe() ] ); } -enum Mode { - GLOBAL = 'global', - SITE = 'site', -} - async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promise< void > { const phpVersion = resolveNativePhpVersion( site.phpVersion ); await ensurePhpBinaryAvailable( phpVersion ); @@ -94,23 +89,10 @@ async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promi } export async function runCommand( - mode: Mode, siteFolder: string, args: string[], options: { phpVersion?: string } = {} ): Promise< void > { - // 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, { - runtime: SITE_RUNTIME_PLAYGROUND, - } ); - - await pipePHPResponse( command.response ); - process.exitCode = await command.response.exitCode; - - return; - } - const site = await getSiteByFolder( siteFolder ); if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { @@ -174,14 +156,9 @@ function removeArgumentFromArgv( return argv; } -interface WpCommandOptions extends GlobalOptions { - studioNoPath?: boolean; -} - -export async function commandHandler( argv: ArgumentsCamelCase< WpCommandOptions > ) { +export async function commandHandler( argv: ArgumentsCamelCase< GlobalOptions > ) { try { let wpCliArgv = removeArgumentFromArgv( process.argv.slice( 3 ), 'path' ); - wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'studio-no-path', false ); const parsedWpCliArgs = yargsParser( wpCliArgv ); if ( parsedWpCliArgs._[ 0 ] === 'shell' ) { @@ -199,9 +176,7 @@ export async function commandHandler( argv: ArgumentsCamelCase< WpCommandOptions wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'php-version' ); wpCliArgv = removeArgumentFromArgv( wpCliArgv, 'avoid-telemetry', false ); - await runCommand( argv.studioNoPath ? Mode.GLOBAL : Mode.SITE, argv.path, wpCliArgv, { - phpVersion, - } ); + await runCommand( argv.path, wpCliArgv, { phpVersion } ); } catch ( error ) { if ( error instanceof LoggerError ) { logger.reportError( error ); diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3e61f24cda..5208170eea 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -275,15 +275,7 @@ async function main() { command: 'wp', describe: __( 'WP-CLI' ), builder: ( wpYargs ) => { - return wpYargs - .help( false ) - .showHelpOnFail( false ) - .strict( false ) - .version( false ) - .option( 'studio-no-path', { - type: 'boolean', - hidden: true, - } ); + return wpYargs.help( false ).showHelpOnFail( false ).strict( false ).version( false ); }, handler: async ( argv ) => { const { commandHandler: wpCliCommandHandler } = await import( 'cli/commands/wp' ); diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index e52ca080c3..4280994ba7 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -19,12 +19,7 @@ import { writeStudioMuPluginsForNativePhpRuntime, } from '@studio/common/lib/mu-plugins'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; -import { - getSiteRuntime, - SITE_RUNTIME_NATIVE_PHP, - type SiteRuntime, -} from '@studio/common/lib/site-runtime'; -import { LatestSupportedPHPVersion } from '@studio/common/types/php-versions'; +import { getSiteRuntime, SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; import { __ } from '@wordpress/i18n'; import { setupPlatformLevelMuPlugins } from '@wp-playground/wordpress'; import { @@ -308,96 +303,3 @@ export async function runWpCliCommand( throw new Error( __( 'An error occurred while running the WP-CLI command.' ) ); } } - -async function runNativeGlobalWpCliCommand( args: string[] ): Promise< DisposableWpCliResponse > { - const phpVersion = resolveNativePhpVersion( DEFAULT_PHP_VERSION ); - // Don't apply open_basedir or disable_functions to the WP-CLI process - const defaultArgs = getDefaultPhpArgs( phpVersion ); - const child = spawn( - getPhpBinaryPath( phpVersion ), - [ ...defaultArgs, getWpCliPharPath(), ...args ], - { stdio: [ 'ignore', 'pipe', 'pipe' ], detached: DETACH_FOR_GROUP_KILL } - ); - - await ensureChildSpawned( child ); - const removeReaper = reapPhpTreeOnInterrupt( child ); - - const exitCode = new Promise< number >( ( resolve, reject ) => { - child.once( 'error', ( error: Error ) => reject( error ) ); - child.once( 'exit', ( code ) => resolve( code ?? 1 ) ); - } ); - - return { - response: new WpCliResponse( - drainToMemory( child.stdout ), - drainToMemory( child.stderr ), - exitCode - ), - [ Symbol.dispose ]() { - removeReaper(); - // Tree-kill so any subprocess WP-CLI spawned dies with it, not just the php.exe itself. - if ( child.exitCode === null && child.signalCode === null && ! child.killed ) { - killPhpProcessTree( child, 'SIGKILL' ); - } - }, - }; -} - -type RunGlobalWpCliCommandOptions = { - runtime?: SiteRuntime; -}; - -/** - * Run a global WP-CLI command without requiring a site. - * Useful for commands like --version that don't need a WordPress installation. - */ -export async function runGlobalWpCliCommand( - args: string[], - options: RunGlobalWpCliCommandOptions = {} -): Promise< DisposableWpCliResponse > { - if ( options.runtime === SITE_RUNTIME_NATIVE_PHP ) { - return runNativeGlobalWpCliCommand( args ); - } - - const id = await loadNodeRuntime( LatestSupportedPHPVersion, { - followSymlinks: true, - withRedis: false, - withMemcached: false, - emscriptenOptions: { - processId: processIdAllocator.claim(), - }, - } ); - const php = new PHP( id ); - - try { - await php.setSapiName( 'cli' ); - - // Setup SSL certificates - php.writeFile( '/tmp/ca-bundle.crt', rootCertificates.join( '\n' ) ); - await setPhpIniEntries( php, { - 'openssl.cafile': '/tmp/ca-bundle.crt', - 'curl.cainfo': '/tmp/ca-bundle.crt', - allow_url_fopen: 1, - } ); - - await php.setSpawnHandler( createNoopSpawnHandler() ); - - await php.mount( '/tmp/wp-cli.phar', createNodeFsMountHandler( getWpCliPharPath() ) ); - - const streamedResponse = await php.cli( [ 'php', '/tmp/wp-cli.phar', ...args ] ); - - return { - response: new WpCliResponse( - Readable.fromWeb( streamedResponse.stdout as WebReadableStream ), - Readable.fromWeb( streamedResponse.stderr as WebReadableStream ), - streamedResponse.exitCode - ), - [ Symbol.dispose ]() { - php.exit(); - }, - }; - } catch ( error ) { - php.exit(); - throw new Error( __( 'An error occurred while running the WP-CLI command.' ) ); - } -} diff --git a/apps/studio/e2e/e2e-helpers.ts b/apps/studio/e2e/e2e-helpers.ts index d9998ddb1a..960a1a881d 100644 --- a/apps/studio/e2e/e2e-helpers.ts +++ b/apps/studio/e2e/e2e-helpers.ts @@ -48,7 +48,6 @@ export class E2ESession { snapshots: [], betaFeatures: { studioSitesCli: true, - nativePhpRuntime: process.env.STUDIO_RUNTIME === 'native-php', }, }; 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 0f3b5f9467..b70d0fbea0 100644 --- a/apps/studio/src/components/tests/content-tab-settings.test.tsx +++ b/apps/studio/src/components/tests/content-tab-settings.test.tsx @@ -38,7 +38,7 @@ const snapshotTestActions = { let testStore = createTestStore( { preloadedState: { betaFeatures: { - features: { remoteSession: false, nativePhpRuntime: false }, + features: { remoteSession: false }, loading: false, }, }, @@ -49,7 +49,7 @@ function createCustomTestStore() { const store = createTestStore( { preloadedState: { betaFeatures: { - features: { remoteSession: false, nativePhpRuntime: false }, + features: { remoteSession: false }, loading: false, }, }, diff --git a/apps/studio/src/ipc-types.d.ts b/apps/studio/src/ipc-types.d.ts index 6b5e4e27e2..f32a74430d 100644 --- a/apps/studio/src/ipc-types.d.ts +++ b/apps/studio/src/ipc-types.d.ts @@ -106,7 +106,6 @@ interface FeatureFlags { interface BetaFeatures { remoteSession: boolean; - nativePhpRuntime?: boolean; } interface AppGlobals extends FeatureFlags { diff --git a/apps/studio/src/lib/beta-features.ts b/apps/studio/src/lib/beta-features.ts index b4379d4278..61326c560a 100644 --- a/apps/studio/src/lib/beta-features.ts +++ b/apps/studio/src/lib/beta-features.ts @@ -1,8 +1,3 @@ -import { - SITE_RUNTIME_NATIVE_PHP, - SITE_RUNTIME_PLAYGROUND, - type SiteRuntime, -} from '@studio/common/lib/site-runtime'; import { __ } from '@wordpress/i18n'; import { lockAppdata, unlockAppdata, loadUserData, saveUserData } from 'src/storage/user-data'; @@ -18,7 +13,6 @@ export interface BetaFeatureDefinition { */ const BETA_FEATURE_DEFAULTS: Record< keyof BetaFeatures, boolean > = { remoteSession: false, - nativePhpRuntime: false, }; /** @@ -33,12 +27,6 @@ export function getBetaFeaturesDefinition(): Record< keyof BetaFeatures, BetaFea default: BETA_FEATURE_DEFAULTS.remoteSession, description: __( 'Control Studio from Telegram via the remote-session daemon.' ), }, - nativePhpRuntime: { - key: 'nativePhpRuntime', - label: __( 'Native PHP runtime' ), - default: BETA_FEATURE_DEFAULTS.nativePhpRuntime, - description: __( 'Use native PHP instead of the Playground sandbox for new sites.' ), - }, }; } @@ -56,13 +44,6 @@ export async function getBetaFeatures(): Promise< BetaFeatures > { return buildBetaFeatures( userData.betaFeatures ); } -// The runtime is a per-site setting; the beta feature only selects the -// default for newly created sites. -export async function getDefaultSiteRuntime(): Promise< SiteRuntime > { - const betaFeatures = await getBetaFeatures(); - return betaFeatures.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND; -} - export async function updateBetaFeature( key: keyof BetaFeatures, value: boolean diff --git a/apps/studio/src/lib/bump-stats.ts b/apps/studio/src/lib/bump-stats.ts index 8bb6f8c99b..c90666c4ec 100644 --- a/apps/studio/src/lib/bump-stats.ts +++ b/apps/studio/src/lib/bump-stats.ts @@ -41,9 +41,10 @@ export enum StatsGroup { STUDIO_CODE_UI_RUN = 'studio-code-ui-run', STUDIO_CODE_UI_WKLY_UNQ = 'studio-code-ui-wk-unq', STUDIO_CODE_UI_MON_UNQ = 'studio-code-ui-mon-unq', - // Weekly count of active sites by runtime + file access. Bumped on site - // start, deduped to once per site per week so restarts don't inflate it. - STUDIO_SITE_RUNTIME_WEEKLY = 'studio-app-runtime-wk', + // Daily count of active sites by runtime + file access. Bumped on site start, + // deduped to once per site per day (re-counted when the runtime changes) so + // restarts don't inflate it. + STUDIO_SITE_RUNTIME_DAILY = 'studio-app-runtime-day', } export enum StatsMetric { diff --git a/apps/studio/src/lib/site-runtime-copy.ts b/apps/studio/src/lib/site-runtime-copy.ts new file mode 100644 index 0000000000..bee8614613 --- /dev/null +++ b/apps/studio/src/lib/site-runtime-copy.ts @@ -0,0 +1,24 @@ +import { + SITE_FILE_ACCESS_ALL_FILES, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; +import { SITE_RUNTIME_PLAYGROUND, type SiteRuntime } from '@studio/common/lib/site-runtime'; + +type Translate = ( text: string ) => string; + +// Explainer copy shown under the File access control in the create/edit site +// forms. Takes the component's translate fn so the strings resolve against the +// active locale (and stay extractable for translation). +export function getFileAccessDescription( + __: Translate, + runtime: SiteRuntime, + fileAccess: SiteFileAccess +): string { + if ( runtime === SITE_RUNTIME_PLAYGROUND ) { + return __( 'The sandbox can only access the site directory.' ); + } + if ( fileAccess === SITE_FILE_ACCESS_ALL_FILES ) { + return __( 'PHP can access any file on your system.' ); + } + return __( "Restricts the site's file access to the site directory." ); +} diff --git a/apps/studio/src/lib/site-runtime-stats.ts b/apps/studio/src/lib/site-runtime-stats.ts index d0d4913e9a..e4069fd360 100644 --- a/apps/studio/src/lib/site-runtime-stats.ts +++ b/apps/studio/src/lib/site-runtime-stats.ts @@ -1,6 +1,6 @@ import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; import { type SiteRuntime } from '@studio/common/lib/site-runtime'; -import { isSameWeek } from 'date-fns'; +import { isSameDay } from 'date-fns'; import { bumpStat, getSiteRuntimeStat, StatsGroup } from 'src/lib/bump-stats'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; @@ -11,20 +11,31 @@ interface SiteRuntimeUsage { } /** - * Counts a site toward the weekly active-sites-by-runtime stat, deduped to once - * per site per week so successive restarts don't inflate the numbers. The - * per-site marker lives in `app.json`'s site metadata. + * Counts a site toward the daily active-sites-by-runtime stat. Deduped per site + * per day so successive restarts don't inflate the numbers, but re-counted when + * the day rolls over or the runtime/file-access choice changes (so a switch is + * captured on the next start rather than at the next day boundary). The per-site + * marker lives in `app.json`'s site metadata. */ export async function recordSiteRuntimeUsage( site: SiteRuntimeUsage ): Promise< void > { const now = Date.now(); + const stat = getSiteRuntimeStat( site ); let shouldBump = false; try { await lockAppdata(); const userData = await loadUserData(); const metadata = userData.siteMetadata[ site.id ]; - if ( ! metadata?.runtimeStatBumpedAt || ! isSameWeek( metadata.runtimeStatBumpedAt, now ) ) { - userData.siteMetadata[ site.id ] = { ...metadata, runtimeStatBumpedAt: now }; + const countedTodayForSameRuntime = + metadata?.runtimeStatBumpedAt !== undefined && + isSameDay( metadata.runtimeStatBumpedAt, now ) && + metadata.runtimeStat === stat; + if ( ! countedTodayForSameRuntime ) { + userData.siteMetadata[ site.id ] = { + ...metadata, + runtimeStatBumpedAt: now, + runtimeStat: stat, + }; await saveUserData( userData ); shouldBump = true; } @@ -33,6 +44,6 @@ export async function recordSiteRuntimeUsage( site: SiteRuntimeUsage ): Promise< } if ( shouldBump ) { - bumpStat( StatsGroup.STUDIO_SITE_RUNTIME_WEEKLY, getSiteRuntimeStat( site ) ); + bumpStat( StatsGroup.STUDIO_SITE_RUNTIME_DAILY, stat ); } } diff --git a/apps/studio/src/lib/tests/bump-stats.test.ts b/apps/studio/src/lib/tests/bump-stats.test.ts index 6b231d2897..5b5dbeed22 100644 --- a/apps/studio/src/lib/tests/bump-stats.test.ts +++ b/apps/studio/src/lib/tests/bump-stats.test.ts @@ -269,9 +269,9 @@ describe( 'recordSiteRuntimeUsage', () => { fileAccess: 'all-files', } as const; - test( 'bumps the weekly runtime stat and records the marker on first start', async () => { + test( 'bumps the daily runtime stat and records the marker on first start', async () => { const mockRequest = mockBumpStatRequest( - StatsGroup.STUDIO_SITE_RUNTIME_WEEKLY, + StatsGroup.STUDIO_SITE_RUNTIME_DAILY, StatsMetric.RUNTIME_NATIVE_ALL_FILES ); @@ -279,24 +279,32 @@ describe( 'recordSiteRuntimeUsage', () => { await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStatBumpedAt ).toBeTypeOf( 'number' ); + expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStat ).toBe( + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); } ); - test( 'does not bump again within the same week', async () => { - // Feb 6 2024 and Feb 4 2024 fall in the same week (Sunday starts the week). - mockCurrentTime( Date.UTC( 2024, 1, 6 ) ); - mockUserData.siteMetadata[ 'site-1' ] = { runtimeStatBumpedAt: Date.UTC( 2024, 1, 4 ) }; + test( 'does not bump again the same day for the same runtime', async () => { + const today = Date.UTC( 2024, 1, 6 ); + mockCurrentTime( today ); + mockUserData.siteMetadata[ 'site-1' ] = { + runtimeStatBumpedAt: today, + runtimeStat: StatsMetric.RUNTIME_NATIVE_ALL_FILES, + }; await recordSiteRuntimeUsage( nativeAllFilesSite ); expect( saveUserData ).not.toHaveBeenCalled(); } ); - test( 'bumps again once a new week has started', async () => { - // Feb 1 2024 and Feb 4 2024 fall in different weeks (Feb 4 is a Sunday). - mockCurrentTime( Date.UTC( 2024, 1, 4 ) ); - mockUserData.siteMetadata[ 'site-1' ] = { runtimeStatBumpedAt: Date.UTC( 2024, 1, 1 ) }; + test( 'bumps again once a new day has started', async () => { + mockCurrentTime( Date.UTC( 2024, 1, 7 ) ); + mockUserData.siteMetadata[ 'site-1' ] = { + runtimeStatBumpedAt: Date.UTC( 2024, 1, 6 ), + runtimeStat: StatsMetric.RUNTIME_NATIVE_ALL_FILES, + }; const mockRequest = mockBumpStatRequest( - StatsGroup.STUDIO_SITE_RUNTIME_WEEKLY, + StatsGroup.STUDIO_SITE_RUNTIME_DAILY, StatsMetric.RUNTIME_NATIVE_ALL_FILES ); @@ -306,6 +314,27 @@ describe( 'recordSiteRuntimeUsage', () => { expect( saveUserData ).toHaveBeenCalled(); } ); + test( 'bumps again the same day when the runtime changed', async () => { + const today = Date.UTC( 2024, 1, 6 ); + mockCurrentTime( today ); + // Counted earlier today as sandbox; the site is now native + all files. + mockUserData.siteMetadata[ 'site-1' ] = { + runtimeStatBumpedAt: today, + runtimeStat: StatsMetric.RUNTIME_SANDBOX, + }; + const mockRequest = mockBumpStatRequest( + StatsGroup.STUDIO_SITE_RUNTIME_DAILY, + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); + expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStat ).toBe( + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + } ); + test( 'preserves existing site metadata when recording the marker', async () => { mockUserData.siteMetadata[ 'site-1' ] = { sortOrder: 3 }; diff --git a/apps/studio/src/migrations/06-seed-site-runtime-from-beta-flag.ts b/apps/studio/src/migrations/06-seed-site-runtime-from-beta-flag.ts deleted file mode 100644 index 763d0a5824..0000000000 --- a/apps/studio/src/migrations/06-seed-site-runtime-from-beta-flag.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * The native PHP runtime used to be a global toggle: the `nativePhpRuntime` - * beta feature made every site run on native PHP via the `STUDIO_RUNTIME` - * environment variable. The runtime is now stored per site in `cli.json` - * (`runtime` field, unset means Playground), so without this migration the - * sites of users who had the beta feature enabled would silently fall back to - * the Playground runtime. Seed `runtime: 'native-php'` on every site that has - * no explicit runtime yet when the beta feature is on. - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import { - CLI_CONFIG_LOCKFILE_NAME, - LOCKFILE_STALE_TIME, - LOCKFILE_WAIT_TIME, -} from '@studio/common/constants'; -import { lockFileAsync, unlockFileAsync } from '@studio/common/lib/lockfile'; -import { SITE_RUNTIME_NATIVE_PHP, siteRuntimeSchema } from '@studio/common/lib/site-runtime'; -import { - getAppConfigPath, - getCliConfigPath, - getConfigDirectory, -} from '@studio/common/lib/well-known-paths'; -import { readFile, writeFile } from 'atomically'; -import { z } from 'zod'; -import type { Migration } from '@studio/common/lib/migration'; - -const appBetaFeaturesShapeSchema = z - .object( { - betaFeatures: z.object( { nativePhpRuntime: z.boolean().optional() } ).loose().optional(), - } ) - .loose(); - -const cliConfigShapeSchema = z - .object( { - sites: z - .array( z.object( { runtime: siteRuntimeSchema.optional() } ).loose() ) - .default( () => [] ), - } ) - .loose(); - -async function readJsonFile< T extends z.ZodType >( - filePath: string, - schema: T -): Promise< z.infer< T > | null > { - if ( ! fs.existsSync( filePath ) ) { - return null; - } - try { - const raw = await readFile( filePath, { encoding: 'utf8' } ); - return schema.parse( JSON.parse( raw ) ); - } catch { - return null; - } -} - -async function isNativePhpBetaFeatureEnabled(): Promise< boolean > { - const appConfig = await readJsonFile( getAppConfigPath(), appBetaFeaturesShapeSchema ); - return appConfig?.betaFeatures?.nativePhpRuntime === true; -} - -export const seedSiteRuntimeFromBetaFlag: Migration = { - async needsToRun() { - if ( ! ( await isNativePhpBetaFeatureEnabled() ) ) { - return false; - } - const cliConfig = await readJsonFile( getCliConfigPath(), cliConfigShapeSchema ); - return !! cliConfig?.sites.some( ( site ) => site.runtime === undefined ); - }, - async run() { - const lockfilePath = path.join( getConfigDirectory(), CLI_CONFIG_LOCKFILE_NAME ); - try { - await lockFileAsync( lockfilePath, { wait: LOCKFILE_WAIT_TIME, stale: LOCKFILE_STALE_TIME } ); - const cliConfig = await readJsonFile( getCliConfigPath(), cliConfigShapeSchema ); - if ( ! cliConfig ) { - return; - } - for ( const site of cliConfig.sites ) { - site.runtime ??= SITE_RUNTIME_NATIVE_PHP; - } - await writeFile( getCliConfigPath(), JSON.stringify( cliConfig, null, 2 ) + '\n', { - encoding: 'utf8', - } ); - console.log( `Seeded native PHP runtime on ${ cliConfig.sites.length } existing site(s)` ); - } finally { - await unlockFileAsync( lockfilePath ); - } - }, -}; diff --git a/apps/studio/src/migrations/index.ts b/apps/studio/src/migrations/index.ts index 6ac5ad65d3..8af7cab2f0 100644 --- a/apps/studio/src/migrations/index.ts +++ b/apps/studio/src/migrations/index.ts @@ -3,7 +3,6 @@ import { migrateAppConfig } from './02-migrate-to-split-config'; import { copyHttpsCertsToWellKnown } from './03-copy-https-certs-to-well-known'; import { migrateConnectedSitesToShared } from './04-migrate-connected-sites-to-shared'; import { removeOldServerFilesAndCertificates } from './05-remove-old-server-files-and-certificates'; -import { seedSiteRuntimeFromBetaFlag } from './06-seed-site-runtime-from-beta-flag'; import type { Migration } from '@studio/common/lib/migration'; export const migrations: Migration[] = [ @@ -12,5 +11,4 @@ export const migrations: Migration[] = [ copyHttpsCertsToWellKnown, migrateConnectedSitesToShared, removeOldServerFilesAndCertificates, - seedSiteRuntimeFromBetaFlag, ]; 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 997ec16f8b..979afb6941 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 @@ -37,7 +37,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 { getFileAccessDescription } from 'src/lib/site-runtime-copy'; 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'; @@ -100,11 +100,9 @@ export const CreateSiteForm = ( { const [ wpVersion, setWpVersion ] = useState( defaultValues.wpVersion ?? DEFAULT_WORDPRESS_VERSION ); - // The "Native PHP runtime" beta feature selects the default mode for new sites - const defaultRuntime = useRootSelector( ( state ) => - state.betaFeatures.features.nativePhpRuntime ? SITE_RUNTIME_NATIVE_PHP : SITE_RUNTIME_PLAYGROUND - ); - const [ selectedRuntime, setSelectedRuntime ] = useState< SiteRuntime >( defaultRuntime ); + // New sites default to the native PHP runtime. + const [ selectedRuntime, setSelectedRuntime ] = + useState< SiteRuntime >( SITE_RUNTIME_NATIVE_PHP ); const [ selectedFileAccess, setSelectedFileAccess ] = useState< SiteFileAccess >( SITE_FILE_ACCESS_SITE_DIRECTORY ); @@ -114,6 +112,7 @@ export const CreateSiteForm = ( { selectedRuntime === SITE_RUNTIME_PLAYGROUND ? SITE_FILE_ACCESS_SITE_DIRECTORY : selectedFileAccess; + const fileAccessDescription = getFileAccessDescription( __, selectedRuntime, usedFileAccess ); const [ useCustomDomain, setUseCustomDomain ] = useState( false ); const [ customDomain, setCustomDomain ] = useState< string | null >( null ); const [ enableHttps, setEnableHttps ] = useState( false ); @@ -571,11 +570,7 @@ export const CreateSiteForm = ( { __nextHasNoMarginBottom /> - { selectedRuntime === SITE_RUNTIME_PLAYGROUND - ? __( 'The sandbox can only access the site directory.' ) - : usedFileAccess === SITE_FILE_ACCESS_ALL_FILES - ? __( 'PHP can access any file your user account can access.' ) - : __( "Restricts the site's file access to the site directory." ) } + { fileAccessDescription } diff --git a/apps/studio/src/modules/add-site/tests/add-site.test.tsx b/apps/studio/src/modules/add-site/tests/add-site.test.tsx index 6b4e5316fe..2e0d014068 100644 --- a/apps/studio/src/modules/add-site/tests/add-site.test.tsx +++ b/apps/studio/src/modules/add-site/tests/add-site.test.tsx @@ -216,7 +216,7 @@ describe( 'AddSite', () => { 'admin', expect.any( String ), 'admin@localhost.com', - 'playground', + 'native-php', 'site-directory' ); } ); @@ -436,12 +436,12 @@ describe( 'AddSite', () => { 'admin', expect.any( String ), 'admin@localhost.com', - 'playground', + 'native-php', 'site-directory' ); } ); - it( 'should allow selecting the native PHP runtime and file access', async () => { + it( 'should allow selecting the runtime and file access', async () => { const user = userEvent.setup(); mockGenerateProposedSitePath.mockResolvedValue( { path: '/default_path/my-wordpress-website', @@ -458,7 +458,10 @@ describe( 'AddSite', () => { await user.click( screen.getByRole( 'button', { name: 'Continue' } ) ); await user.click( screen.getByRole( 'button', { name: 'Advanced settings' } ) ); - // File access only becomes selectable with the native PHP runtime + // Native is the default, so File access is selectable; switching to the + // sandbox disables it (the sandbox only sees the site directory). + expect( screen.getByLabelText( 'File access' ) ).toBeEnabled(); + await user.selectOptions( screen.getByLabelText( 'PHP runtime' ), 'playground' ); expect( screen.getByLabelText( 'File access' ) ).toBeDisabled(); await user.selectOptions( screen.getByLabelText( 'PHP runtime' ), 'native-php' ); await user.selectOptions( screen.getByLabelText( 'File access' ), 'all-files' ); 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 5771336850..0339f8cd31 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -46,6 +46,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 { getFileAccessDescription } from 'src/lib/site-runtime-copy'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; type EditSiteDetailsProps = { @@ -98,6 +99,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = selectedRuntime === SITE_RUNTIME_PLAYGROUND ? SITE_FILE_ACCESS_SITE_DIRECTORY : selectedFileAccess; + const fileAccessDescription = getFileAccessDescription( __, selectedRuntime, usedFileAccess ); const selectedSitePhpVersion = selectedSite?.phpVersion; const resolvedSitePhpVersion = resolvePhpVersion( selectedSitePhpVersion ); const phpVersionWarning = @@ -507,11 +509,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = __nextHasNoMarginBottom /> - { selectedRuntime === SITE_RUNTIME_PLAYGROUND - ? __( 'The sandbox can only access the site directory.' ) - : usedFileAccess === SITE_FILE_ACCESS_ALL_FILES - ? __( 'PHP can access any file your user account can access.' ) - : __( "Restricts the site's file access to the site directory." ) } + { fileAccessDescription } 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 2e2e72e213..43464195cd 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 @@ -69,11 +69,11 @@ vi.mock( 'src/hooks/use-offline', () => ( { useOffline: vi.fn().mockReturnValue( false ), } ) ); -const renderWithProvider = ( children: React.ReactElement, nativePhpRuntime = false ) => { +const renderWithProvider = ( children: React.ReactElement ) => { const store = createTestStore( { preloadedState: { betaFeatures: { - features: { remoteSession: false, nativePhpRuntime }, + features: { remoteSession: false }, loading: false, }, }, diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index 29a5da7347..d90bf58aae 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -4,6 +4,7 @@ import * as Sentry from '@sentry/electron/main'; import { SQLITE_FILENAME } from '@studio/common/constants'; import { siteListSchema, type SiteListItem } from '@studio/common/lib/cli-events'; import { parseJsonFromPhpOutput } from '@studio/common/lib/php-output-parser'; +import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; import fsExtra from 'fs-extra'; import { parse } from 'shell-quote'; import { z } from 'zod'; @@ -11,7 +12,6 @@ import { WP_CLI_DEFAULT_RESPONSE_TIMEOUT, WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT, } from 'src/constants'; -import { getDefaultSiteRuntime } from 'src/lib/beta-features'; import { recordSiteRuntimeUsage } from 'src/lib/site-runtime-stats'; import { CliServerProcess } from 'src/modules/cli/lib/cli-server-process'; import { createSiteViaCli, type CreateSiteOptions } from 'src/modules/cli/lib/cli-site-creator'; @@ -181,7 +181,9 @@ export class SiteServer { }; const server = SiteServer.register( placeholderDetails, meta ); - const runtime = options.runtime ?? ( await getDefaultSiteRuntime() ); + // New sites default to the native PHP runtime; existing sites keep + // whatever they have (sandbox when unset, via getSiteRuntime). + const runtime = options.runtime ?? SITE_RUNTIME_NATIVE_PHP; const result = await createSiteViaCli( { ...options, runtime, siteId } ); server.details.runtime = runtime; server.details.fileAccess = options.fileAccess; diff --git a/apps/studio/src/storage/storage-types.ts b/apps/studio/src/storage/storage-types.ts index a0973eb76e..55d28d677d 100644 --- a/apps/studio/src/storage/storage-types.ts +++ b/apps/studio/src/storage/storage-types.ts @@ -15,9 +15,11 @@ export interface AppdataSiteData { themeDetails?: SiteDetails[ 'themeDetails' ]; siteIconPath?: SiteDetails[ 'siteIconPath' ]; sortOrder?: number; - // Unix ms of the last time this site's runtime was counted in usage stats. - // Dedupes the weekly per-site runtime bump so restarts don't inflate it. + // The last runtime stat counted for this site, and when (Unix ms). Dedupes + // the daily per-site runtime bump so restarts don't inflate it, while still + // re-counting when the day rolls over or the runtime/file-access choice changes. runtimeStatBumpedAt?: number; + runtimeStat?: string; } export interface AiSessionSitePlacement { diff --git a/apps/studio/src/stores/beta-features-slice.ts b/apps/studio/src/stores/beta-features-slice.ts index 5706690120..2de5c132b0 100644 --- a/apps/studio/src/stores/beta-features-slice.ts +++ b/apps/studio/src/stores/beta-features-slice.ts @@ -8,7 +8,7 @@ type BetaFeaturesState = { }; const initialState: BetaFeaturesState = { - features: { remoteSession: false, nativePhpRuntime: false }, + features: { remoteSession: false }, loading: false, }; From 36d768602a6cecf114e02afafbe3fcea7935c062 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Mon, 15 Jun 2026 21:18:02 +0100 Subject: [PATCH 07/16] Run native blueprint WP-CLI steps with the bundled PHP --- apps/cli/lib/native-php/blueprints.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/cli/lib/native-php/blueprints.ts b/apps/cli/lib/native-php/blueprints.ts index b5b7b5cb3e..361cf78a58 100644 --- a/apps/cli/lib/native-php/blueprints.ts +++ b/apps/cli/lib/native-php/blueprints.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import { getBlueprintsPharPath } from 'cli/lib/dependency-management/paths'; +import { getBlueprintsPharPath, getPhpBinaryPath } from 'cli/lib/dependency-management/paths'; 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'; @@ -72,7 +72,18 @@ export async function runBlueprint( `--site-url=${ config.absoluteUrl ?? `http://localhost:${ config.port }` }`, '--db-engine=sqlite', ], - { phpVersion, signal } + { + phpVersion, + signal, + // blueprints.phar runs `wp-cli` steps by shelling out to `php` on the + // PATH. Expose the bundled binary so blueprints work on machines + // without a system PHP install (e.g. CI and most users). + env: { + PATH: `${ path.dirname( getPhpBinaryPath( phpVersion ) ) }${ path.delimiter }${ + process.env.PATH ?? '' + }`, + }, + } ); } finally { await fs.promises.unlink( tmpPath ).catch( () => {} ); From 35de3c57bd5aa2b4abc2f87f44fa3ba58192e3ce Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 16 Jun 2026 14:52:20 +0200 Subject: [PATCH 08/16] Fix WP-CLI calls from Studio Code --- apps/cli/ai/tests/tools.test.ts | 102 +++++++++---------- apps/cli/ai/tools/scaffold-theme.ts | 26 +++-- apps/cli/ai/tools/wp-cli.ts | 33 +++--- apps/cli/commands/wp.ts | 113 +++++---------------- apps/cli/lib/run-wp-cli-command.ts | 152 ++++++++++++++++++++++++---- 5 files changed, 238 insertions(+), 188 deletions(-) diff --git a/apps/cli/ai/tests/tools.test.ts b/apps/cli/ai/tests/tools.test.ts index 0cb8172adb..6a633453ab 100644 --- a/apps/cli/ai/tests/tools.test.ts +++ b/apps/cli/ai/tests/tools.test.ts @@ -17,7 +17,8 @@ import { runCommand as runUpdatePreviewCommand } from 'cli/commands/preview/upda import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create'; import { readCliConfig } from 'cli/lib/cli-config/core'; import { getSiteByFolder } from 'cli/lib/cli-config/sites'; -import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; +import { runWpCliCommandWithMessaging } from 'cli/lib/run-wp-cli-command'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { getProgressCallback, setProgressCallback } from 'cli/logger'; import { captureCommandOutput, @@ -96,9 +97,12 @@ vi.mock( 'cli/lib/daemon-client', () => ( { disconnectFromDaemon: vi.fn(), } ) ); +vi.mock( 'cli/lib/run-wp-cli-command', () => ( { + runWpCliCommandWithMessaging: vi.fn(), +} ) ); + vi.mock( 'cli/lib/wordpress-server-manager', () => ( { isServerRunning: vi.fn(), - sendWpCliCommand: vi.fn(), } ) ); describe( 'Studio AI MCP tools', () => { @@ -126,6 +130,18 @@ describe( 'Studio AI MCP tools', () => { tool: ReturnType< typeof resolveStudioToolDefinitions >[ number ], args: Record< string, unknown > ) => tool.execute( 'tool-call-1', args as never, new AbortController().signal, () => {} ); + const mockWpCliResponse = ( { + stdout = '', + stderr = '', + exitCode = 0, + }: { stdout?: string; stderr?: string; exitCode?: number } = {} ) => ( { + response: { + exitCode: Promise.resolve( exitCode ), + stdoutText: Promise.resolve( stdout ), + stderrText: Promise.resolve( stderr ), + }, + [ Symbol.dispose ]() {}, + } ); const mockValidatedFix = ( fixedContent: string, blockName = 'core/paragraph' ) => { vi.mocked( validateBlocks ).mockResolvedValue( { totalBlocks: 1, @@ -878,7 +894,7 @@ describe( 'Studio AI MCP tools', () => { } as never ) ).rejects.toThrow( /does not run in a shell/ ); - expect( sendWpCliCommand ).not.toHaveBeenCalled(); + expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled(); } ); it( 'treats unquoted post_content as a single trailing literal argument', async () => { @@ -889,11 +905,9 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '123', - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: '123' } ) as never + ); await getTool( 'wp_cli' ).rawHandler( { nameOrPath: 'My Site', @@ -902,7 +916,7 @@ describe( 'Studio AI MCP tools', () => { `, } as never ); - expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [ + expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [ 'post', 'create', '--post_type=page', @@ -920,18 +934,16 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '123', - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: '123' } ) as never + ); await getTool( 'wp_cli' ).rawHandler( { nameOrPath: 'My Site', command: 'post create --post_type=page --post_title="About" --post_content="Hello world"', } as never ); - expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [ + expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [ 'post', 'create', '--post_type=page', @@ -948,11 +960,9 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '123', - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: '123' } ) as never + ); await getTool( 'wp_cli' ).rawHandler( { nameOrPath: 'My Site', @@ -960,7 +970,7 @@ describe( 'Studio AI MCP tools', () => { 'post create --post_type=page --post_title="About" --post_content="Hello world" --porcelain', } as never ); - expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [ + expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [ 'post', 'create', '--post_type=page', @@ -978,18 +988,16 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '123', - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: '123' } ) as never + ); await getTool( 'wp_cli' ).rawHandler( { nameOrPath: 'My Site', command: 'post create --post_type=page --post_title="About" --post_content="" --porcelain', } as never ); - expect( sendWpCliCommand ).toHaveBeenCalledWith( 'site-123', [ + expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( mockSite, [ 'post', 'create', '--post_type=page', @@ -1007,11 +1015,9 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '123', - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: '123' } ) as never + ); const tool = resolveStudioToolDefinitions( { emitChatArtifacts: true, } ).find( ( definition ) => definition.name === 'wp_cli' ); @@ -1040,11 +1046,9 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '123', - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: '123' } ) as never + ); const tool = resolveStudioToolDefinitions( { emitChatArtifacts: true, } ).find( ( definition ) => definition.name === 'wp_cli' ); @@ -1082,7 +1086,7 @@ describe( 'Studio AI MCP tools', () => { } as never ) ).rejects.toThrow( /typographic dash/ ); - expect( sendWpCliCommand ).not.toHaveBeenCalled(); + expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled(); } ); describe( 'scaffold_theme', () => { @@ -1232,18 +1236,16 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: "Success: Switched to 'Acme Studio' theme.", - stderr: '', - exitCode: 0, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stdout: "Success: Switched to 'Acme Studio' theme." } ) as never + ); const result = await getTool( 'scaffold_theme' ).rawHandler( { nameOrPath: scaffoldSite.name, name: 'Acme Studio', } as never ); - expect( sendWpCliCommand ).toHaveBeenCalledWith( scaffoldSite.id, [ + expect( runWpCliCommandWithMessaging ).toHaveBeenCalledWith( scaffoldSite, [ 'theme', 'activate', 'acme-studio', @@ -1269,7 +1271,7 @@ describe( 'Studio AI MCP tools', () => { activate: false, } as never ); - expect( sendWpCliCommand ).not.toHaveBeenCalled(); + expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled(); expect( getTextContent( result ) ).toContain( 'Activate with: wp theme activate acme-studio' ); @@ -1283,7 +1285,7 @@ describe( 'Studio AI MCP tools', () => { name: 'Acme Studio', } as never ); - expect( sendWpCliCommand ).not.toHaveBeenCalled(); + expect( runWpCliCommandWithMessaging ).not.toHaveBeenCalled(); expect( getTextContent( result ) ).toContain( 'Activation skipped:' ); expect( getTextContent( result ) ).toContain( 'Site is not running' ); expect( getTextContent( result ) ).toContain( @@ -1299,11 +1301,9 @@ describe( 'Studio AI MCP tools', () => { pid: 1234, runtime: SITE_RUNTIME_PLAYGROUND, } ); - vi.mocked( sendWpCliCommand ).mockResolvedValue( { - stdout: '', - stderr: 'Error: stylesheet missing.', - exitCode: 1, - } ); + vi.mocked( runWpCliCommandWithMessaging ).mockResolvedValue( + mockWpCliResponse( { stderr: 'Error: stylesheet missing.', exitCode: 1 } ) as never + ); const result = await getTool( 'scaffold_theme' ).rawHandler( { nameOrPath: scaffoldSite.name, diff --git a/apps/cli/ai/tools/scaffold-theme.ts b/apps/cli/ai/tools/scaffold-theme.ts index 88084005bd..5ef93759e0 100644 --- a/apps/cli/ai/tools/scaffold-theme.ts +++ b/apps/cli/ai/tools/scaffold-theme.ts @@ -1,34 +1,42 @@ import { mkdir, stat, writeFile } from 'fs/promises'; import path from 'path'; import { Type } from 'typebox'; +import { SiteData } from 'cli/lib/cli-config/core'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; -import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; +import { runWpCliCommandWithMessaging } from 'cli/lib/run-wp-cli-command'; +import { isServerRunning } from 'cli/lib/wordpress-server-manager'; import { defineTool } from './define-tool'; import { resolveSite, textResult } from './utils'; async function activateTheme( - siteId: string, + site: SiteData, slug: string ): Promise< { ok: boolean; message: string } > { try { await connectToDaemon(); try { - const running = await isServerRunning( siteId ); + const running = await isServerRunning( site.id ); if ( ! running ) { return { ok: false, message: `Site is not running. Start it (site_start) then run \`wp theme activate ${ slug }\`.`, }; } - const result = await sendWpCliCommand( siteId, [ 'theme', 'activate', slug ] ); - if ( result.exitCode !== 0 ) { - const detail = ( result.stderr || result.stdout || '' ).trim(); + await using command = await runWpCliCommandWithMessaging( site, [ + 'theme', + 'activate', + slug, + ] ); + const exitCode = await command.response.exitCode; + const stderr = await command.response.stderrText; + const stdout = await command.response.stdoutText; + if ( exitCode !== 0 ) { + const detail = ( stderr || stdout || '' ).trim(); return { ok: false, - message: `WP-CLI exited with code ${ result.exitCode }${ detail ? `: ${ detail }` : '' }`, + message: `WP-CLI exited with code ${ exitCode }${ detail ? `: ${ detail }` : '' }`, }; } - const stdout = result.stdout.trim(); return { ok: true, message: stdout || `Activated theme '${ slug }'.` }; } finally { await disconnectFromDaemon(); @@ -347,7 +355,7 @@ export const scaffoldThemeTool = defineTool( } const shouldActivate = args.activate ?? true; - const activation = shouldActivate ? await activateTheme( site.id, slug ) : null; + const activation = shouldActivate ? await activateTheme( site, slug ) : null; const summaryLines = [ `Block theme '${ trimmedName }' scaffolded at wp-content/themes/${ slug }/.`, diff --git a/apps/cli/ai/tools/wp-cli.ts b/apps/cli/ai/tools/wp-cli.ts index c12e4d3d05..0e72de1a08 100644 --- a/apps/cli/ai/tools/wp-cli.ts +++ b/apps/cli/ai/tools/wp-cli.ts @@ -1,7 +1,7 @@ import { Type } from 'typebox'; import { connectToDaemon, disconnectFromDaemon } from 'cli/lib/daemon-client'; import { getUnsupportedWpCliPostContentMessage } from 'cli/lib/rewrite-wp-cli-post-content'; -import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; +import { runWpCliCommandWithMessaging } from 'cli/lib/run-wp-cli-command'; import { defineTool } from './define-tool'; import { resolveSite } from './utils'; import type { StudioChatArtifactWidgetDraft } from '@studio/common/ai/chat-artifacts'; @@ -169,7 +169,6 @@ function getWpCliArtifacts( return undefined; } -// Note: wp.ts runCommand calls process.exit(), so we use the lower-level sendWpCliCommand directly. export const runWpCliTool = defineTool( 'wp_cli', 'Runs a WP-CLI command on a specific WordPress site. The site must be running. ' + @@ -188,13 +187,6 @@ export const runWpCliTool = defineTool( try { await connectToDaemon(); - const runningProcess = await isServerRunning( site.id ); - if ( ! runningProcess ) { - throw new Error( - `Site "${ site.name }" is not running. Start it first using site_start.` - ); - } - const wpCliArgs = splitCommandArgs( args.command ); const unsupportedOptionMessage = getUnsupportedWpCliOptionMessage( wpCliArgs ); if ( unsupportedOptionMessage ) { @@ -205,27 +197,30 @@ export const runWpCliTool = defineTool( throw new Error( unsupportedPostContentMessage ); } - const result = await sendWpCliCommand( site.id, wpCliArgs ); + await using command = await runWpCliCommandWithMessaging( site, wpCliArgs ); + const exitCode = await command.response.exitCode; + const stdout = await command.response.stdoutText; + const stderr = await command.response.stderrText; let output = ''; - if ( result.stdout ) { - output += result.stdout; + if ( stdout ) { + output += stdout; } - if ( result.stderr ) { - output += ( output ? '\n' : '' ) + `stderr: ${ result.stderr }`; + if ( stderr ) { + output += ( output ? '\n' : '' ) + `stderr: ${ stderr }`; } - if ( result.exitCode !== 0 ) { - output += `\nExit code: ${ result.exitCode }`; + if ( exitCode !== 0 ) { + output += `\nExit code: ${ exitCode }`; } - if ( result.exitCode !== 0 ) { - throw new Error( output || `WP-CLI exited with code ${ result.exitCode }` ); + if ( exitCode !== 0 ) { + throw new Error( output || `WP-CLI exited with code ${ exitCode }` ); } return { content: [ { type: 'text' as const, text: output || 'Command completed with no output.' }, ], - studioArtifacts: getWpCliArtifacts( wpCliArgs, result.stdout ), + studioArtifacts: getWpCliArtifacts( wpCliArgs, stdout ), }; } finally { await disconnectFromDaemon(); diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index dd96f57a90..e4482c5e75 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,24 +1,15 @@ -import { spawn } from 'node:child_process'; -import { writeStudioMuPluginsForNativePhpRuntime } from '@studio/common/lib/mu-plugins'; -import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; -import { - getSiteRuntime, - SITE_RUNTIME_NATIVE_PHP, - SITE_RUNTIME_PLAYGROUND, -} from '@studio/common/lib/site-runtime'; +import { SITE_RUNTIME_NATIVE_PHP, getSiteRuntime } from '@studio/common/lib/site-runtime'; import { __ } from '@wordpress/i18n'; import { ArgumentsCamelCase } from 'yargs'; import yargsParser from 'yargs-parser'; -import { SiteData } from 'cli/lib/cli-config/core'; 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 { getDefaultPhpArgs } from 'cli/lib/native-php/config'; -import { DETACH_FOR_GROUP_KILL, reapPhpTreeOnInterrupt } from 'cli/lib/native-php/php-process'; -import { runWpCliCommand, WpCliResponse } from 'cli/lib/run-wp-cli-command'; +import { + WpCliResponse, + runWpCliCommandWithMessaging, + runWpCliCommand, +} from 'cli/lib/run-wp-cli-command'; import { validatePhpVersion } from 'cli/lib/utils'; -import { isServerRunning, sendWpCliCommand } from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; import { GlobalOptions } from 'cli/types'; @@ -45,94 +36,38 @@ async function pipePHPResponse( response: WpCliResponse ) { await Promise.all( [ stderrPipe(), stdoutPipe() ] ); } -async function runNativePhpWpCliCommand( site: SiteData, args: string[] ): Promise< void > { - const phpVersion = resolveNativePhpVersion( site.phpVersion ); - await ensurePhpBinaryAvailable( phpVersion ); - await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating ); - // Don't apply open_basedir or disable_functions to the WP-CLI process - const defaultArgs = getDefaultPhpArgs( phpVersion ); - const child = spawn( - getPhpBinaryPath( phpVersion ), - [ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...args ], - { - cwd: site.path, - stdio: 'inherit', - detached: DETACH_FOR_GROUP_KILL, - } - ); - - // Reap php.exe and any subprocess it spawned if this command is interrupted before the child exits. - const removeReaper = reapPhpTreeOnInterrupt( child ); - - let code: number | null; - let signal: NodeJS.Signals | null; - try { - ( { code, signal } = await new Promise< { - code: number | null; - signal: NodeJS.Signals | null; - } >( ( resolve, reject ) => { - child.once( 'error', reject ); - child.once( 'exit', ( exitCode, exitSignal ) => - resolve( { code: exitCode, signal: exitSignal } ) - ); - } ) ); - } finally { - removeReaper(); - } - - if ( signal ) { - process.kill( process.pid, signal ); - return; - } - - process.exit( code ?? 1 ); -} - export async function runCommand( siteFolder: string, args: string[], options: { phpVersion?: string } = {} ): Promise< void > { const site = await getSiteByFolder( siteFolder ); + const phpVersion = validatePhpVersion( options.phpVersion ?? site.phpVersion ); + // The native runtime always spawns a local PHP child, so connect it directly to + // the terminal for piped/interactive stdin, live streaming output and colors. It + // never uses the daemon, and `reapPhpTreeOnInterrupt` handles Ctrl+C, so there's + // no daemon connection or signal handler to set up here. if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { - await runNativePhpWpCliCommand( site, args ); + await using command = await runWpCliCommand( site, args, { phpVersion, stdio: 'inherit' } ); + process.exitCode = await command.exitCode; return; } - const phpVersion = validatePhpVersion( options.phpVersion ?? site.phpVersion ); + // Playground sites run in the daemon (when running) or a fresh in-process PHP-WASM + // instance (when stopped), so their output can only be streamed, not inherited. + process.on( 'SIGINT', disconnectFromDaemon ); + process.on( 'SIGTERM', disconnectFromDaemon ); - // If there's already a running Playground instance for this site AND we're not requesting - // a different PHP version, pass the command to it… - const useCustomPhpVersion = options.phpVersion && options.phpVersion !== site.phpVersion; - - if ( ! useCustomPhpVersion ) { - process.on( 'SIGINT', disconnectFromDaemon ); - process.on( 'SIGTERM', disconnectFromDaemon ); - - try { - await connectToDaemon(); + try { + await connectToDaemon(); - 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 ); - process.exit( result.exitCode ); - } - } finally { - await disconnectFromDaemon(); - } + await using command = await runWpCliCommandWithMessaging( site, args, { phpVersion } ); + await pipePHPResponse( command.response ); + process.exitCode = await command.response.exitCode; + } finally { + await disconnectFromDaemon(); } - - process.on( 'SIGINT', () => process.exit( 1 ) ); - process.on( 'SIGTERM', () => process.exit( 1 ) ); - - // …If not, run the command in a new runtime instance (PHP-WASM or native PHP) - await using command = await runWpCliCommand( site, args, { phpVersion } ); - - await pipePHPResponse( command.response ); - process.exitCode = await command.response.exitCode; } function removeArgumentFromArgv( diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 4280994ba7..6c3056e8d6 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -19,7 +19,11 @@ import { writeStudioMuPluginsForNativePhpRuntime, } from '@studio/common/lib/mu-plugins'; import { resolveNativePhpVersion } from '@studio/common/lib/php-binary-metadata'; -import { getSiteRuntime, SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, +} from '@studio/common/lib/site-runtime'; import { __ } from '@wordpress/i18n'; import { setupPlatformLevelMuPlugins } from '@wp-playground/wordpress'; import { @@ -34,6 +38,7 @@ import { killPhpProcessTree, reapPhpTreeOnInterrupt, } from './native-php/php-process'; +import { isServerRunning, sendWpCliCommand } from './wordpress-server-manager'; import type { SiteData } from 'cli/lib/cli-config/core'; import type { ReadableStream as WebReadableStream } from 'node:stream/web'; @@ -103,15 +108,20 @@ function drainToMemory( source: Readable ): Readable { } type RunWpCliCommandOptions = { - siteUrl?: string; - requireSqliteCliCommand?: boolean; phpVersion?: SupportedPHPVersion; + requireSqliteCliCommand?: boolean; + siteUrl?: string; + stdio?: 'inherit' | 'pipe'; }; type DisposableWpCliResponse = Disposable & { response: WpCliResponse; }; +type DisposableExitCode = Disposable & { + exitCode: Promise< number >; +}; + const WASM_SQLITE_COMMAND_PATH = '/tmp/sqlite-command/command.php'; function applyWpCliCommandOptions( @@ -152,11 +162,16 @@ async function ensureChildSpawned( child: ChildProcess ): Promise< void > { } ); } -async function runNativeWpCliCommand( +// Spawn the native PHP WP-CLI child for a site and wire up the interrupt reaper. +// `stdio` controls how the child's streams are connected: `'pipe'` captures +// stdout/stderr (callers read them via `WpCliResponse`), while `'inherit'` hands +// the child the parent's terminal fds so it gets stdin, live streaming and TTY +// detection (colors) β€” used for interactive terminal passthrough. +async function spawnNativeWpCli( site: SiteData, args: string[], - options: RunWpCliCommandOptions = {} -): Promise< DisposableWpCliResponse > { + options: RunWpCliCommandOptions +): Promise< Disposable & { child: ChildProcess; exitCode: Promise< number > } > { const nativeArgs = applyWpCliCommandOptions( 'native', args, options ); const phpVersion = resolveNativePhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating ); @@ -167,7 +182,7 @@ async function runNativeWpCliCommand( [ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...nativeArgs ], { cwd: site.path, - stdio: [ 'ignore', 'pipe', 'pipe' ], + stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], detached: DETACH_FOR_GROUP_KILL, } ); @@ -181,11 +196,10 @@ async function runNativeWpCliCommand( } ); return { - response: new WpCliResponse( - drainToMemory( child.stdout ), - drainToMemory( child.stderr ), - exitCode - ), + child, + exitCode, + // Tear down the child: stop reaping interrupts and tree-kill so any subprocess + // WP-CLI spawned dies with it, not just the php.exe itself. [ Symbol.dispose ]() { removeReaper(); // Tree-kill so any subprocess WP-CLI spawned dies with it, not just the php.exe itself. @@ -196,6 +210,41 @@ async function runNativeWpCliCommand( }; } +async function runNativeWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions & { stdio: 'inherit' } +): Promise< DisposableExitCode >; +async function runNativeWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions +): Promise< DisposableWpCliResponse >; +async function runNativeWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions = {} +): Promise< DisposableWpCliResponse | DisposableExitCode > { + const spawned = await spawnNativeWpCli( site, args, options ); + + if ( options.stdio === 'inherit' ) { + return { + exitCode: spawned.exitCode, + [ Symbol.dispose ]: spawned[ Symbol.dispose ], + }; + } + + return { + response: new WpCliResponse( + // Non-null: the 'pipe' stdio mode always provides stdout/stderr streams. + drainToMemory( spawned.child.stdout! ), + drainToMemory( spawned.child.stderr! ), + spawned.exitCode + ), + [ Symbol.dispose ]: spawned[ Symbol.dispose ], + }; +} + /** * Creates a no-op spawn handler that immediately exits with code 1. * This allows process spawning functions (proc_open, exec, etc.) to be called @@ -214,20 +263,38 @@ function createNoopSpawnHandler() { } ); } -// Run a WP-CLI command in a PHP-WASM instance. This function can be used even if the targeted -// Studio site is already running, but it is typically faster to use the `sendWpCliCommand` -// function in that case. +// Run a WP-CLI command with the appropriate PHP runtime. For Playground runtime +// sites, this function will always instantiate a new PHP-WASM instance. This +// strategy works regardless of whether the site is running, but +// `runWpCliCommandWithMessaging` is faster if the site is running. +// +// Passing `stdio: 'inherit'` connects the child to the parent's terminal fds for +// piped/interactive stdin, live streaming output and TTY detection (colors), and +// returns only the exit code. This is native-only β€” the Playground runtime has no +// way to attach to the terminal, so requesting it for a Playground site throws. +export async function runWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions & { stdio: 'inherit' } +): Promise< DisposableExitCode >; +export async function runWpCliCommand( + site: SiteData, + args: string[], + options?: RunWpCliCommandOptions +): Promise< DisposableWpCliResponse >; export async function runWpCliCommand( site: SiteData, args: string[], options: RunWpCliCommandOptions = {} -): Promise< DisposableWpCliResponse > { - const siteFolder = site.path; - +): Promise< DisposableWpCliResponse | DisposableExitCode > { if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { return runNativeWpCliCommand( site, args, options ); } + if ( options.stdio === 'inherit' ) { + throw new Error( 'stdio: "inherit" is only supported for the native PHP runtime.' ); + } + const phpVersion = options.phpVersion ?? validatePhpVersion( site.phpVersion ); const id = await loadNodeRuntime( phpVersion, { @@ -248,7 +315,7 @@ export async function runWpCliCommand( php.defineConstant( 'DB_NAME', 'wordpress' ); php.mkdir( '/wordpress' ); - await php.mount( '/wordpress', createNodeFsMountHandler( siteFolder ) ); + await php.mount( '/wordpress', createNodeFsMountHandler( site.path ) ); php.chdir( '/wordpress' ); // Setup SSL certificates @@ -261,7 +328,7 @@ export async function runWpCliCommand( await php.setSpawnHandler( createNoopSpawnHandler() ); - await cleanupLegacyMuPlugins( siteFolder ); + await cleanupLegacyMuPlugins( site.path ); // Mount mu-plugins const [ studioMuPluginsHostPath, loaderMuPluginHostPath ] = await getMuPlugins( { @@ -303,3 +370,48 @@ export async function runWpCliCommand( throw new Error( __( 'An error occurred while running the WP-CLI command.' ) ); } } + +// Similarly to `runWpCliCommand`, this function executes a WP-CLI command with +// the appropriate PHP runtime. The difference is that for Playground runtimes, +// this function will check if the server is running and send the WP-CLI command +// over IPC only if it is. This is faster than instantiating a new PHP-WASM. +// Remember that you need to be connected to the process daemon before running +// this function. +export async function runWpCliCommandWithMessaging( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions = {} +): Promise< DisposableWpCliResponse > { + const useCustomPhpVersion = options.phpVersion && options.phpVersion !== site.phpVersion; + + if ( getSiteRuntime( site ) === SITE_RUNTIME_PLAYGROUND && ! useCustomPhpVersion ) { + try { + const runningProcess = await isServerRunning( site.id ); + if ( runningProcess ) { + const response = await sendWpCliCommand( site.id, args ); + + // `Readable.from( [ str ] )` emits the whole string as one chunk + return { + response: new WpCliResponse( + Readable.from( [ response.stdout ] ), + Readable.from( [ response.stderr ] ), + Promise.resolve( response.exitCode ) + ), + [ Symbol.dispose ]() { + // Output is already buffered in memory, so there's nothing to tear down. + }, + }; + } + } catch ( error ) { + // The server is running but the command couldn't be sent over IPC (e.g. the + // process predates WP-CLI messaging support, or messaging failed). Fall back to a + // fresh PHP-WASM instance below rather than surfacing the error to the caller. + console.warn( + 'Failed to run WP-CLI command via the running server; falling back to a new PHP-WASM instance:', + error + ); + } + } + + return runWpCliCommand( site, args, options ); +} From 21a6c0fa5299fdf3c9d2255b223af268b36d93c0 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 16 Jun 2026 16:01:02 +0200 Subject: [PATCH 09/16] Transform stream to strip out shebang --- apps/cli/commands/wp.ts | 9 +- apps/cli/lib/run-wp-cli-command.ts | 100 +++++++++------------- apps/cli/lib/tests/wp-cli-shebang.test.ts | 81 ++++++++++++++++++ apps/cli/lib/wp-cli-shebang.ts | 74 ++++++++++++++++ 4 files changed, 199 insertions(+), 65 deletions(-) create mode 100644 apps/cli/lib/tests/wp-cli-shebang.test.ts create mode 100644 apps/cli/lib/wp-cli-shebang.ts diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index e4482c5e75..cb521e4736 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -15,9 +15,9 @@ import { GlobalOptions } from 'cli/types'; const logger = new Logger< '' >(); +// `response.stdout` is already shebang-stripped by `runWpCliCommand` / +// `runWpCliCommandWithMessaging`, so this just forwards the streams verbatim. async function pipePHPResponse( response: WpCliResponse ) { - const decoder = new TextDecoder(); - const stderrPipe = async () => { for await ( const chunk of response.stderr ) { process.stderr.write( chunk ); @@ -26,10 +26,7 @@ async function pipePHPResponse( response: WpCliResponse ) { const stdoutPipe = async () => { for await ( const chunk of response.stdout ) { - const text = decoder.decode( chunk, { stream: true } ); - if ( ! text.startsWith( '#!/usr/bin/env' ) ) { - process.stdout.write( chunk ); - } + process.stdout.write( chunk ); } }; diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 6c3056e8d6..b65d4634ca 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -39,6 +39,7 @@ import { reapPhpTreeOnInterrupt, } from './native-php/php-process'; import { isServerRunning, sendWpCliCommand } from './wordpress-server-manager'; +import { stripLeadingShebang } from './wp-cli-shebang'; import type { SiteData } from 'cli/lib/cli-config/core'; import type { ReadableStream as WebReadableStream } from 'node:stream/web'; @@ -54,6 +55,9 @@ const PLAYGROUND_INTERNAL_SHARED_FOLDER = '/internal/shared'; * memory; the native runtime pre-drains its OS pipes via `drainToMemory`), so * the text getters are safe to read in any order relative to `exitCode`. * + * For Playground-produced stdout the leading shebang line is already stripped at + * construction (see `stripLeadingShebang`), so consumers get clean output. + * * The text getters consume the same underlying stream as `stdout`/`stderr` β€” * use one or the other, not both. */ @@ -114,14 +118,6 @@ type RunWpCliCommandOptions = { stdio?: 'inherit' | 'pipe'; }; -type DisposableWpCliResponse = Disposable & { - response: WpCliResponse; -}; - -type DisposableExitCode = Disposable & { - exitCode: Promise< number >; -}; - const WASM_SQLITE_COMMAND_PATH = '/tmp/sqlite-command/command.php'; function applyWpCliCommandOptions( @@ -162,16 +158,29 @@ async function ensureChildSpawned( child: ChildProcess ): Promise< void > { } ); } -// Spawn the native PHP WP-CLI child for a site and wire up the interrupt reaper. -// `stdio` controls how the child's streams are connected: `'pipe'` captures -// stdout/stderr (callers read them via `WpCliResponse`), while `'inherit'` hands -// the child the parent's terminal fds so it gets stdin, live streaming and TTY -// detection (colors) β€” used for interactive terminal passthrough. -async function spawnNativeWpCli( +type DisposableWpCliResponse = Disposable & { + response: WpCliResponse; +}; + +type DisposableExitCode = Disposable & { + exitCode: Promise< number >; +}; + +async function runNativeWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions & { stdio: 'inherit' } +): Promise< DisposableExitCode >; +async function runNativeWpCliCommand( site: SiteData, args: string[], options: RunWpCliCommandOptions -): Promise< Disposable & { child: ChildProcess; exitCode: Promise< number > } > { +): Promise< DisposableWpCliResponse >; +async function runNativeWpCliCommand( + site: SiteData, + args: string[], + options: RunWpCliCommandOptions = {} +): Promise< DisposableWpCliResponse | DisposableExitCode > { const nativeArgs = applyWpCliCommandOptions( 'native', args, options ); const phpVersion = resolveNativePhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating ); @@ -195,53 +204,29 @@ async function spawnNativeWpCli( child.once( 'exit', ( code ) => resolve( code ?? 1 ) ); } ); - return { - child, - exitCode, - // Tear down the child: stop reaping interrupts and tree-kill so any subprocess - // WP-CLI spawned dies with it, not just the php.exe itself. - [ Symbol.dispose ]() { - removeReaper(); - // Tree-kill so any subprocess WP-CLI spawned dies with it, not just the php.exe itself. - if ( child.exitCode === null && child.signalCode === null && ! child.killed ) { - killPhpProcessTree( child, 'SIGKILL' ); - } - }, + const dispose = () => { + removeReaper(); + // Tree-kill so any subprocess WP-CLI spawned dies with it, not just the php.exe itself. + if ( child.exitCode === null && child.signalCode === null && ! child.killed ) { + killPhpProcessTree( child, 'SIGKILL' ); + } }; -} - -async function runNativeWpCliCommand( - site: SiteData, - args: string[], - options: RunWpCliCommandOptions & { stdio: 'inherit' } -): Promise< DisposableExitCode >; -async function runNativeWpCliCommand( - site: SiteData, - args: string[], - options: RunWpCliCommandOptions -): Promise< DisposableWpCliResponse >; -async function runNativeWpCliCommand( - site: SiteData, - args: string[], - options: RunWpCliCommandOptions = {} -): Promise< DisposableWpCliResponse | DisposableExitCode > { - const spawned = await spawnNativeWpCli( site, args, options ); if ( options.stdio === 'inherit' ) { return { - exitCode: spawned.exitCode, - [ Symbol.dispose ]: spawned[ Symbol.dispose ], + exitCode: exitCode, + [ Symbol.dispose ]: dispose, }; } return { response: new WpCliResponse( // Non-null: the 'pipe' stdio mode always provides stdout/stderr streams. - drainToMemory( spawned.child.stdout! ), - drainToMemory( spawned.child.stderr! ), - spawned.exitCode + drainToMemory( child.stdout! ), + drainToMemory( child.stderr! ), + exitCode ), - [ Symbol.dispose ]: spawned[ Symbol.dispose ], + [ Symbol.dispose ]: dispose, }; } @@ -357,7 +342,7 @@ export async function runWpCliCommand( return { response: new WpCliResponse( - Readable.fromWeb( streamedResponse.stdout as WebReadableStream ), + stripLeadingShebang( Readable.fromWeb( streamedResponse.stdout as WebReadableStream ) ), Readable.fromWeb( streamedResponse.stderr as WebReadableStream ), streamedResponse.exitCode ), @@ -390,11 +375,12 @@ export async function runWpCliCommandWithMessaging( if ( runningProcess ) { const response = await sendWpCliCommand( site.id, args ); - // `Readable.from( [ str ] )` emits the whole string as one chunk + // `Readable.from( [ Buffer ] )` emits all of the contents as one chunk; + // `stripLeadingShebang` removes the Playground shebang line if present. return { response: new WpCliResponse( - Readable.from( [ response.stdout ] ), - Readable.from( [ response.stderr ] ), + stripLeadingShebang( Readable.from( [ Buffer.from( response.stdout ) ] ) ), + Readable.from( [ Buffer.from( response.stderr ) ] ), Promise.resolve( response.exitCode ) ), [ Symbol.dispose ]() { @@ -406,10 +392,6 @@ export async function runWpCliCommandWithMessaging( // The server is running but the command couldn't be sent over IPC (e.g. the // process predates WP-CLI messaging support, or messaging failed). Fall back to a // fresh PHP-WASM instance below rather than surfacing the error to the caller. - console.warn( - 'Failed to run WP-CLI command via the running server; falling back to a new PHP-WASM instance:', - error - ); } } diff --git a/apps/cli/lib/tests/wp-cli-shebang.test.ts b/apps/cli/lib/tests/wp-cli-shebang.test.ts new file mode 100644 index 0000000000..d13fe3810d --- /dev/null +++ b/apps/cli/lib/tests/wp-cli-shebang.test.ts @@ -0,0 +1,81 @@ +import { Readable } from 'node:stream'; +import { text } from 'node:stream/consumers'; +import { describe, expect, it } from 'vitest'; +import { PLAYGROUND_WP_CLI_SHEBANG_PREFIX, stripLeadingShebang } from 'cli/lib/wp-cli-shebang'; + +const SHEBANG_LINE = `${ PLAYGROUND_WP_CLI_SHEBANG_PREFIX } php\n`; + +// Build a Readable that emits the given pieces as discrete chunks, so we can +// exercise how `stripLeadingShebang` copes with different chunk boundaries. +function streamOf( ...chunks: Array< string | Buffer > ): Readable { + return Readable.from( chunks.map( ( chunk ) => Buffer.from( chunk ) ) ); +} + +describe( 'stripLeadingShebang', () => { + it( 'drops the shebang when it arrives as its own chunk (streaming runtime)', async () => { + const result = await text( + stripLeadingShebang( streamOf( SHEBANG_LINE, 'blogname value\n' ) ) + ); + expect( result ).toBe( 'blogname value\n' ); + } ); + + it( 'drops the shebang glued to the first line of output in one chunk (messaging runtime)', async () => { + // This is the exact shape the messaging path builds: the whole buffered + // response as a single chunk starting with the shebang. The earlier + // regression left a stray `php` line here. + const result = await text( stripLeadingShebang( streamOf( `${ SHEBANG_LINE }123\n` ) ) ); + expect( result ).toBe( '123\n' ); + expect( result ).not.toContain( 'php' ); + } ); + + it( 'drops the shebang when it is split across chunks', async () => { + const result = await text( + stripLeadingShebang( streamOf( '#!/usr/b', 'in/env php\nSuccess: done.\n' ) ) + ); + expect( result ).toBe( 'Success: done.\n' ); + } ); + + it( 'preserves meaningful leading and trailing whitespace in real output', async () => { + const result = await text( + stripLeadingShebang( streamOf( `${ SHEBANG_LINE } spaced value \n` ) ) + ); + expect( result ).toBe( ' spaced value \n' ); + } ); + + it( 'passes output through untouched when there is no shebang', async () => { + const result = await text( + stripLeadingShebang( streamOf( 'no shebang here\nsecond line\n' ) ) + ); + expect( result ).toBe( 'no shebang here\nsecond line\n' ); + } ); + + it( 'emits nothing when the shebang line is the entire output', async () => { + const result = await text( stripLeadingShebang( streamOf( SHEBANG_LINE ) ) ); + expect( result ).toBe( '' ); + } ); + + it( 'forwards a partial prefix that never completes into a shebang', async () => { + const result = await text( stripLeadingShebang( streamOf( '#!/' ) ) ); + expect( result ).toBe( '#!/' ); + } ); + + it( 'yields Buffer chunks so byte consumers (process.stdout.write) work', async () => { + const stream = stripLeadingShebang( streamOf( `${ SHEBANG_LINE }123\n` ) ); + const chunks: unknown[] = []; + for await ( const chunk of stream ) { + chunks.push( chunk ); + } + expect( chunks.length ).toBeGreaterThan( 0 ); + expect( chunks.every( ( chunk ) => Buffer.isBuffer( chunk ) ) ).toBe( true ); + expect( Buffer.concat( chunks as Buffer[] ).toString() ).toBe( '123\n' ); + } ); + + it( 'propagates source errors to the consumer', async () => { + const source = new Readable( { + read() { + this.destroy( new Error( 'boom' ) ); + }, + } ); + await expect( text( stripLeadingShebang( source ) ) ).rejects.toThrow( 'boom' ); + } ); +} ); diff --git a/apps/cli/lib/wp-cli-shebang.ts b/apps/cli/lib/wp-cli-shebang.ts new file mode 100644 index 0000000000..a631cd1b25 --- /dev/null +++ b/apps/cli/lib/wp-cli-shebang.ts @@ -0,0 +1,74 @@ +import { Readable, Transform } from 'node:stream'; + +export const PLAYGROUND_WP_CLI_SHEBANG_PREFIX = '#!/usr/bin/env'; + +/** + * When the Playground runtime runs WP-CLI via the `php.cli()` method, it echoes + * the `#!/usr/bin/env php` shebang line as the first line of stdout. Native PHP + * doesn't do this. This Transform stream strips out the shebang. Since the + * shebang is always on the first line, this stream buffers the initial contents + * until it: + * + * 1. Has received a chunk where any byte doesn't match the shebang prefix. + * 2. Has received bytes that start with the shebang prefix and contain a + * newline, in which case it strips the shebang, the newline and everything + * in between, and forwards the remainder. + * + * Once either case is settled, the stream switches to pass-through mode and + * forwards all chunks verbatim. + */ +export function stripLeadingShebang( source: Readable ): Readable { + const prefix = Buffer.from( PLAYGROUND_WP_CLI_SHEBANG_PREFIX ); + // `null` once we've decided what to do and switched to pass-through. + let buffered: Buffer | null = Buffer.alloc( 0 ); + + const transform = new Transform( { + transform( chunk: Buffer | string, _encoding, callback ) { + if ( buffered === null ) { + callback( null, chunk ); + return; + } + + buffered = Buffer.concat( [ buffered, Buffer.from( chunk ) ] ); + + // Compare against as much of the prefix as we've received so far. + const comparable = Math.min( buffered.length, prefix.length ); + if ( ! buffered.subarray( 0, comparable ).equals( prefix.subarray( 0, comparable ) ) ) { + // Doesn't start with the shebang β€” forward everything, stop buffering. + const passthrough = buffered; + buffered = null; + callback( null, passthrough ); + return; + } + + // Still matching the prefix but haven't confirmed the whole line yet. + if ( buffered.length < prefix.length ) { + callback(); + return; + } + + // No newline yet. Wait for more chunks. + const newlineIndex = buffered.indexOf( 0x0a /* \n */ ); + if ( newlineIndex === -1 ) { + callback(); + return; + } + + // Drop the shebang line including its newline; forward the remainder. + const remainder = buffered.subarray( newlineIndex + 1 ); + buffered = null; + callback( null, remainder.length > 0 ? remainder : undefined ); + }, + flush( callback ) { + // Stream ended while still buffering (e.g. a shebang with no trailing + // newline, or output shorter than the prefix): forward what we held back. + const remaining = buffered; + buffered = null; + callback( null, remaining && remaining.length > 0 ? remaining : undefined ); + }, + } ); + + // `pipe()` doesn't forward source errors, so propagate them to the consumer. + source.on( 'error', ( error ) => transform.destroy( error ) ); + return source.pipe( transform ); +} From d1833e7663c8310695fc3818930aa7ba9c8807c1 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Tue, 16 Jun 2026 16:10:51 +0200 Subject: [PATCH 10/16] Address review comments --- apps/cli/ai/tools/wp-cli.ts | 2 +- apps/cli/commands/wp.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/cli/ai/tools/wp-cli.ts b/apps/cli/ai/tools/wp-cli.ts index 0e72de1a08..a77fd24934 100644 --- a/apps/cli/ai/tools/wp-cli.ts +++ b/apps/cli/ai/tools/wp-cli.ts @@ -171,7 +171,7 @@ function getWpCliArtifacts( export const runWpCliTool = defineTool( 'wp_cli', - 'Runs a WP-CLI command on a specific WordPress site. The site must be running. ' + + 'Runs a WP-CLI command on a specific WordPress site. ' + 'Examples: "plugin install woocommerce --activate", "option get blogname", "user list".', { nameOrPath: Type.String( { description: 'The site name or file system path to the site' } ), diff --git a/apps/cli/commands/wp.ts b/apps/cli/commands/wp.ts index cb521e4736..72f0169f66 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -53,8 +53,12 @@ export async function runCommand( // Playground sites run in the daemon (when running) or a fresh in-process PHP-WASM // instance (when stopped), so their output can only be streamed, not inherited. - process.on( 'SIGINT', disconnectFromDaemon ); - process.on( 'SIGTERM', disconnectFromDaemon ); + const onSignal = async () => { + await disconnectFromDaemon(); + process.exit( 1 ); + }; + process.on( 'SIGINT', onSignal ); + process.on( 'SIGTERM', onSignal ); try { await connectToDaemon(); From bf8c304b2e34aa50108234f3c0b87aac4716eade Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Wed, 17 Jun 2026 08:48:52 +0200 Subject: [PATCH 11/16] `ensurePhpBinaryAvailable` --- apps/cli/lib/run-wp-cli-command.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index b65d4634ca..bcf78975bc 100644 --- a/apps/cli/lib/run-wp-cli-command.ts +++ b/apps/cli/lib/run-wp-cli-command.ts @@ -32,6 +32,7 @@ import { getWpCliPharPath, } from 'cli/lib/dependency-management/paths'; import { validatePhpVersion } from 'cli/lib/utils'; +import { ensurePhpBinaryAvailable } from './dependency-management/php-binary'; import { getDefaultPhpArgs } from './native-php/config'; import { DETACH_FOR_GROUP_KILL, @@ -181,11 +182,13 @@ async function runNativeWpCliCommand( args: string[], options: RunWpCliCommandOptions = {} ): Promise< DisposableWpCliResponse | DisposableExitCode > { - const nativeArgs = applyWpCliCommandOptions( 'native', args, options ); const phpVersion = resolveNativePhpVersion( options.phpVersion ?? DEFAULT_PHP_VERSION ); + await ensurePhpBinaryAvailable( phpVersion ); await writeStudioMuPluginsForNativePhpRuntime( site.path, site.isWpAutoUpdating ); + // Don't apply open_basedir or disable_functions to the WP-CLI process const defaultArgs = getDefaultPhpArgs( phpVersion ); + const nativeArgs = applyWpCliCommandOptions( 'native', args, options ); const child = spawn( getPhpBinaryPath( phpVersion ), [ ...defaultArgs, getWpCliPharPath(), `--path=${ site.path }`, ...nativeArgs ], From f3f6dd2fd2a6b623f1a2388cc393d39cd85508a8 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 17 Jun 2026 16:29:25 +0100 Subject: [PATCH 12/16] Default existing sites to native PHP and apply runtime/file-access review feedback --- apps/cli/ai/tools/create-site.ts | 4 ++ apps/cli/commands/blueprint/use.ts | 12 +++-- apps/cli/commands/site/create.ts | 38 +++++++------- apps/cli/commands/site/tests/create.test.ts | 5 ++ apps/cli/commands/site/tests/set.test.ts | 8 +-- apps/cli/commands/site/tests/status.test.ts | 6 +-- .../tests/wordpress-server-manager.test.ts | 11 +++-- .../src/components/content-tab-settings.tsx | 49 +++++++++++++++---- apps/studio/src/ipc-handlers.ts | 17 ++++++- apps/studio/src/lib/site-runtime-copy.ts | 9 ++++ apps/studio/src/lib/tests/bump-stats.test.ts | 4 +- .../site-settings/edit-site-details.tsx | 6 +-- apps/studio/src/site-server.ts | 3 +- tools/common/lib/site-file-access.ts | 4 +- tools/common/lib/site-runtime.ts | 6 +-- 15 files changed, 121 insertions(+), 61 deletions(-) diff --git a/apps/cli/ai/tools/create-site.ts b/apps/cli/ai/tools/create-site.ts index 03a238bc07..39252bddb8 100644 --- a/apps/cli/ai/tools/create-site.ts +++ b/apps/cli/ai/tools/create-site.ts @@ -1,5 +1,7 @@ import path from 'path'; import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; +import { SITE_FILE_ACCESS_SITE_DIRECTORY } from '@studio/common/lib/site-file-access'; +import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; import { Type } from 'typebox'; import { emitLocalSiteSelected } from 'cli/ai/site-selection'; import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create'; @@ -29,6 +31,8 @@ export const createSiteTool = defineTool( name: args.name, wpVersion: 'latest', phpVersion: DEFAULT_PHP_VERSION, + runtime: SITE_RUNTIME_NATIVE_PHP, + fileAccess: SITE_FILE_ACCESS_SITE_DIRECTORY, enableHttps: false, noStart: false, skipBrowser: true, diff --git a/apps/cli/commands/blueprint/use.ts b/apps/cli/commands/blueprint/use.ts index 5184909319..f5b04e8bd1 100644 --- a/apps/cli/commands/blueprint/use.ts +++ b/apps/cli/commands/blueprint/use.ts @@ -9,9 +9,11 @@ import { } from '@studio/common/lib/blueprint-bundle'; import { isOnline } from '@studio/common/lib/network-utils'; import { readSharedConfig } from '@studio/common/lib/shared-config'; +import { SITE_FILE_ACCESS_SITE_DIRECTORY } from '@studio/common/lib/site-file-access'; +import { SITE_RUNTIME_NATIVE_PHP } from '@studio/common/lib/site-runtime'; import { fetchStudioBlueprints, type Blueprint } from '@studio/common/lib/studio-blueprints-api'; import { BlueprintCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions'; -import { SupportedPHPVersions } from '@studio/common/types/php-versions'; +import { SupportedPHPVersions, type SupportedPHPVersion } from '@studio/common/types/php-versions'; import { __, _n, sprintf } from '@wordpress/i18n'; import { runCommand as runCreateSiteCommand } from 'cli/commands/site/create'; import { getDefaultSitePath } from 'cli/lib/site-paths'; @@ -19,8 +21,6 @@ import { untildify } from 'cli/lib/utils'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; -const ALLOWED_PHP_VERSIONS = [ ...SupportedPHPVersions ]; - const logger = new Logger< LoggerAction >(); async function resolveBlueprint( blueprint: Blueprint ): Promise< { @@ -52,7 +52,7 @@ export async function runCommand( options: { name?: string; wpVersion?: string; - phpVersion?: string; + phpVersion?: SupportedPHPVersion; customDomain?: string; enableHttps: boolean; adminUsername?: string; @@ -124,6 +124,8 @@ export async function runCommand( name: options.name, wpVersion: options.wpVersion ?? DEFAULT_WORDPRESS_VERSION, phpVersion: options.phpVersion ?? DEFAULT_PHP_VERSION, + runtime: SITE_RUNTIME_NATIVE_PHP, + fileAccess: SITE_FILE_ACCESS_SITE_DIRECTORY, customDomain: options.customDomain, enableHttps: options.enableHttps, blueprint: { @@ -166,7 +168,7 @@ export const registerCommand = ( yargs: StudioArgv ) => { .option( 'php', { type: 'string', describe: __( 'PHP version' ), - choices: ALLOWED_PHP_VERSIONS, + choices: SupportedPHPVersions, defaultDescription: DEFAULT_PHP_VERSION, } ) .option( 'domain', { diff --git a/apps/cli/commands/site/create.ts b/apps/cli/commands/site/create.ts index f6e9deea63..abcc964a32 100644 --- a/apps/cli/commands/site/create.ts +++ b/apps/cli/commands/site/create.ts @@ -32,17 +32,14 @@ import { import { readSharedConfig } from '@studio/common/lib/shared-config'; import { isFileAccessAllowedForRuntime, - siteFileAccessSchema, SITE_FILE_ACCESS_ALL_FILES, SITE_FILE_ACCESS_SITE_DIRECTORY, type SiteFileAccess, } from '@studio/common/lib/site-file-access'; import { - siteModeSchema, SITE_MODE_NATIVE, SITE_MODE_SANDBOX, SITE_RUNTIME_NATIVE_PHP, - SITE_RUNTIME_PLAYGROUND, siteRuntimeFromMode, type SiteRuntime, } from '@studio/common/lib/site-runtime'; @@ -54,7 +51,11 @@ 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 { RecommendedPHPVersion, SupportedPHPVersions } from '@studio/common/types/php-versions'; +import { + RecommendedPHPVersion, + SupportedPHPVersions, + 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'; @@ -97,9 +98,9 @@ export type CreateCommandOptions = { name?: string; siteId?: string; wpVersion: string; - phpVersion: string; - runtime?: SiteRuntime; - fileAccess?: SiteFileAccess; + phpVersion: SupportedPHPVersion; + runtime: SiteRuntime; + fileAccess: SiteFileAccess; customDomain?: string; enableHttps: boolean; blueprint?: { @@ -118,8 +119,8 @@ export async function runCommand( sitePath: string, options: CreateCommandOptions ): Promise< void > { - const siteRuntime = options.runtime ?? SITE_RUNTIME_NATIVE_PHP; - if ( options.fileAccess && ! isFileAccessAllowedForRuntime( siteRuntime, options.fileAccess ) ) { + const siteRuntime = options.runtime; + if ( ! isFileAccessAllowedForRuntime( siteRuntime, options.fileAccess ) ) { throw new LoggerError( __( 'File access "all-files" requires the native PHP runtime. The sandbox only has access to the site directory.' @@ -547,16 +548,16 @@ export const registerCommand = ( yargs: StudioArgv ) => { describe: __( 'Run the site with native PHP ("native") or in the Playground sandbox ("sandbox")' ), - choices: [ SITE_MODE_NATIVE, SITE_MODE_SANDBOX ], - defaultDescription: SITE_MODE_SANDBOX, + choices: [ SITE_MODE_NATIVE, SITE_MODE_SANDBOX ] as const, + default: SITE_MODE_NATIVE, } ) .option( 'file-access', { type: 'string', describe: __( 'Which files PHP can access with the native PHP runtime: the site directory only, or all files' ), - choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ], - defaultDescription: SITE_FILE_ACCESS_SITE_DIRECTORY, + choices: [ SITE_FILE_ACCESS_SITE_DIRECTORY, SITE_FILE_ACCESS_ALL_FILES ] as const, + default: SITE_FILE_ACCESS_SITE_DIRECTORY, } ) .option( 'domain', { type: 'string', @@ -615,14 +616,9 @@ export const registerCommand = ( yargs: StudioArgv ) => { let adminUsername = argv.adminUsername; let adminPassword = argv.adminPassword; let adminEmail = argv.adminEmail; - const runtime = argv.runtime - ? siteRuntimeFromMode( siteModeSchema.parse( argv.runtime ) ) - : undefined; - const fileAccess = siteFileAccessSchema.optional().parse( argv.fileAccess ); - if ( - fileAccess && - ! isFileAccessAllowedForRuntime( runtime ?? SITE_RUNTIME_PLAYGROUND, fileAccess ) - ) { + const runtime = siteRuntimeFromMode( argv.runtime ); + const fileAccess = argv.fileAccess; + if ( ! isFileAccessAllowedForRuntime( runtime, fileAccess ) ) { logger.reportError( new LoggerError( __( diff --git a/apps/cli/commands/site/tests/create.test.ts b/apps/cli/commands/site/tests/create.test.ts index 45c803d875..0425244d2d 100644 --- a/apps/cli/commands/site/tests/create.test.ts +++ b/apps/cli/commands/site/tests/create.test.ts @@ -11,6 +11,10 @@ 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_FILE_ACCESS_SITE_DIRECTORY, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; import { SITE_RUNTIME_NATIVE_PHP, SITE_RUNTIME_PLAYGROUND, @@ -97,6 +101,7 @@ describe( 'CLI: studio site create', () => { wpVersion: 'latest', phpVersion: '8.3' as const, runtime: SITE_RUNTIME_PLAYGROUND as SiteRuntime, + fileAccess: SITE_FILE_ACCESS_SITE_DIRECTORY as SiteFileAccess, enableHttps: false, noStart: false, skipBrowser: false, diff --git a/apps/cli/commands/site/tests/set.test.ts b/apps/cli/commands/site/tests/set.test.ts index d591318881..80b2229f25 100644 --- a/apps/cli/commands/site/tests/set.test.ts +++ b/apps/cli/commands/site/tests/set.test.ts @@ -345,12 +345,12 @@ describe( 'CLI: studio site set', () => { describe( 'Runtime and file access changes', () => { it( 'should update the stored runtime when it changes', async () => { - await runCommand( testSitePath, { runtime: SITE_MODE_NATIVE } ); + await runCommand( testSitePath, { runtime: SITE_MODE_SANDBOX } ); expect( saveCliConfig ).toHaveBeenCalledWith( expect.objectContaining( { sites: expect.arrayContaining( [ - expect.objectContaining( { runtime: SITE_RUNTIME_NATIVE_PHP } ), + expect.objectContaining( { runtime: SITE_RUNTIME_PLAYGROUND } ), ] ), } ) ); @@ -359,7 +359,7 @@ describe( 'CLI: studio site set', () => { it( 'should restart a running site when the runtime changes', async () => { vi.mocked( isServerRunning ).mockResolvedValue( testProcessDescription ); - await runCommand( testSitePath, { runtime: SITE_MODE_NATIVE } ); + await runCommand( testSitePath, { runtime: SITE_MODE_SANDBOX } ); expect( stopWordPressServer ).toHaveBeenCalledWith( 'site-1' ); expect( startWordPressServer ).toHaveBeenCalled(); @@ -383,7 +383,7 @@ describe( 'CLI: studio site set', () => { } ); it( 'should report no changes when the runtime matches the current one', async () => { - await expect( runCommand( testSitePath, { runtime: SITE_MODE_SANDBOX } ) ).rejects.toThrow( + await expect( runCommand( testSitePath, { runtime: SITE_MODE_NATIVE } ) ).rejects.toThrow( 'No changes to apply. The site already has the specified settings.' ); } ); diff --git a/apps/cli/commands/site/tests/status.test.ts b/apps/cli/commands/site/tests/status.test.ts index 479b06489c..995eeed3c7 100644 --- a/apps/cli/commands/site/tests/status.test.ts +++ b/apps/cli/commands/site/tests/status.test.ts @@ -84,7 +84,7 @@ describe( 'CLI: studio site status', () => { status: 'πŸ”΄ Offline', isOnline: false, phpVersion: '8.0', - runtime: 'Sandbox', + runtime: 'Native', fileAccess: 'Site directory', wpVersion: '6.4', xdebug: 'Disabled', @@ -121,7 +121,7 @@ describe( 'CLI: studio site status', () => { status: '🟒 Online', isOnline: true, phpVersion: '8.0', - runtime: 'Sandbox', + runtime: 'Native', fileAccess: 'Site directory', wpVersion: '6.4', xdebug: 'Disabled', @@ -183,7 +183,7 @@ describe( 'CLI: studio site status', () => { sitePath: '/path/to/site', status: 'πŸ”΄ Offline', isOnline: false, - runtime: 'Sandbox', + runtime: 'Native', fileAccess: 'Site directory', wpVersion: '6.4', xdebug: 'Disabled', diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index f5f53830e0..9a8a746104 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -138,7 +138,10 @@ describe( 'WordPress Server Manager', () => { it( 'should start WordPress server with basic configuration', async () => { setupIpcMocks(); - const result = await startWordPressServer( mockSiteData, mockLogger ); + const result = await startWordPressServer( + { ...mockSiteData, runtime: SITE_RUNTIME_PLAYGROUND }, + mockLogger + ); expect( vi.mocked( daemonClient.startProcess ) ).toHaveBeenCalledWith( 'studio-site-test-site-id', @@ -187,15 +190,15 @@ describe( 'WordPress Server Manager', () => { ); } ); - it( 'should use the playground child script when the site has no runtime set', async () => { + it( 'should default to the native-php child script when the site has no runtime set', async () => { setupIpcMocks(); await startWordPressServer( mockSiteData, mockLogger ); expect( vi.mocked( daemonClient.startProcess ) ).toHaveBeenCalledWith( 'studio-site-test-site-id', - expect.stringMatching( /playground-server-child\.mjs$/ ), - { runtime: SITE_RUNTIME_PLAYGROUND } + expect.stringMatching( /php-server-child\.mjs$/ ), + { runtime: SITE_RUNTIME_NATIVE_PHP } ); } ); diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index 65f84a31ad..79719204b3 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -10,7 +10,7 @@ import { __experimentalHeading as Heading, } from '@wordpress/components'; import { sprintf } from '@wordpress/i18n'; -import { cautionFilled, moreVertical } from '@wordpress/icons'; +import { cautionFilled, info, moreVertical } from '@wordpress/icons'; import { useI18n } from '@wordpress/react-i18n'; import { PropsWithChildren, useCallback, useEffect, useState } from 'react'; import StudioButton from 'src/components/button'; @@ -22,6 +22,7 @@ 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 { getFileAccessDescription, getRuntimeDescription } from 'src/lib/site-runtime-copy'; import EditSiteDetails from 'src/modules/site-settings/edit-site-details'; import { useAppDispatch } from 'src/stores'; import { @@ -49,6 +50,12 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) const { __ } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); const isNativePhpRuntime = getSiteRuntime( selectedSite ) === SITE_RUNTIME_NATIVE_PHP; + const runtimeDescription = getRuntimeDescription( __, getSiteRuntime( selectedSite ) ); + const fileAccessDescription = getFileAccessDescription( + __, + getSiteRuntime( selectedSite ), + getSiteFileAccess( selectedSite ) + ); const username = selectedSite.adminUsername || 'admin'; // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); @@ -203,16 +210,40 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) - { /* translators: value for the PHP runtime setting on the site settings screen */ } - { isNativePhpRuntime ? __( 'Native' ) : __( 'Sandbox' ) } +
+ { /* translators: value for the PHP runtime setting on the site settings screen */ } + { isNativePhpRuntime ? __( 'Native' ) : __( 'Sandbox' ) } + + + + + +
- { /* translators: value for the File access setting on the site settings screen */ } - - { getSiteFileAccess( selectedSite ) === SITE_FILE_ACCESS_ALL_FILES - ? __( 'All files' ) - : __( 'Site directory' ) } - +
+ { /* translators: value for the File access setting on the site settings screen */ } + + { getSiteFileAccess( selectedSite ) === SITE_FILE_ACCESS_ALL_FILES + ? __( 'All files' ) + : __( 'Site directory' ) } + + + + + + +
diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index a97cfd9aee..fac19def6e 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -122,6 +122,7 @@ import { setSentryWpcomUserIdMain } from 'src/lib/main-sentry-utils'; import * as oauthClient from 'src/lib/oauth'; import { getAiInstructionsPath } from 'src/lib/server-files-paths'; import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper'; +import { recordSiteRuntimeUsage } from 'src/lib/site-runtime-stats'; import { updateSiteUrl } from 'src/lib/update-site-url'; import * as windowsHelpers from 'src/lib/windows-helpers'; import { getLogsFilePath, writeLogToFile, type LogLevel } from 'src/logging'; @@ -983,11 +984,13 @@ export async function updateSite( options.wp = isWordPressDevVersion( wpVersion ) ? 'nightly' : wpVersion; } - if ( getSiteRuntime( updatedSite ) !== getSiteRuntime( currentSite ) ) { + const runtimeChanged = getSiteRuntime( updatedSite ) !== getSiteRuntime( currentSite ); + if ( runtimeChanged ) { options.runtime = siteModeFromRuntime( getSiteRuntime( updatedSite ) ); } - if ( getSiteFileAccess( updatedSite ) !== getSiteFileAccess( currentSite ) ) { + const fileAccessChanged = getSiteFileAccess( updatedSite ) !== getSiteFileAccess( currentSite ); + if ( fileAccessChanged ) { options.fileAccess = getSiteFileAccess( updatedSite ); } @@ -1023,6 +1026,16 @@ export async function updateSite( if ( hasCliChanges ) { await editSiteViaCli( options ); + + // Capture a runtime/file-access switch right away, rather than waiting for + // the next start or day boundary, so the daily stat reflects the change. + if ( runtimeChanged || fileAccessChanged ) { + await recordSiteRuntimeUsage( { + id: updatedSite.id, + runtime: getSiteRuntime( updatedSite ), + fileAccess: getSiteFileAccess( updatedSite ), + } ); + } } } diff --git a/apps/studio/src/lib/site-runtime-copy.ts b/apps/studio/src/lib/site-runtime-copy.ts index bee8614613..4579f406f3 100644 --- a/apps/studio/src/lib/site-runtime-copy.ts +++ b/apps/studio/src/lib/site-runtime-copy.ts @@ -22,3 +22,12 @@ export function getFileAccessDescription( } return __( "Restricts the site's file access to the site directory." ); } + +// Explainer copy shown under the PHP runtime control in the create/edit site +// forms and in the read-only site settings. +export function getRuntimeDescription( __: Translate, runtime: SiteRuntime ): string { + if ( runtime === SITE_RUNTIME_PLAYGROUND ) { + return __( 'Runs the site in an isolated WordPress Playground sandbox.' ); + } + return __( 'Runs the site with native PHP for the best performance.' ); +} diff --git a/apps/studio/src/lib/tests/bump-stats.test.ts b/apps/studio/src/lib/tests/bump-stats.test.ts index 5b5dbeed22..025a4213c2 100644 --- a/apps/studio/src/lib/tests/bump-stats.test.ts +++ b/apps/studio/src/lib/tests/bump-stats.test.ts @@ -257,8 +257,8 @@ describe( 'getSiteRuntimeStat', () => { ); } ); - test( 'defaults an unset runtime to sandbox', () => { - expect( getSiteRuntimeStat( {} ) ).toBe( StatsMetric.RUNTIME_SANDBOX ); + test( 'defaults an unset runtime to native', () => { + expect( getSiteRuntimeStat( {} ) ).toBe( StatsMetric.RUNTIME_NATIVE_SITE_DIR ); } ); } ); 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 0339f8cd31..8f98341e5a 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -46,7 +46,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 { getFileAccessDescription } from 'src/lib/site-runtime-copy'; +import { getFileAccessDescription, getRuntimeDescription } from 'src/lib/site-runtime-copy'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; type EditSiteDetailsProps = { @@ -480,9 +480,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = __nextHasNoMarginBottom /> - { selectedRuntime === SITE_RUNTIME_NATIVE_PHP - ? __( 'Runs the site with native PHP for the best performance.' ) - : __( 'Runs the site in an isolated WordPress Playground sandbox.' ) } + { getRuntimeDescription( __, selectedRuntime ) } diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index d90bf58aae..b96b815584 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -181,8 +181,7 @@ export class SiteServer { }; const server = SiteServer.register( placeholderDetails, meta ); - // New sites default to the native PHP runtime; existing sites keep - // whatever they have (sandbox when unset, via getSiteRuntime). + // Default to the native PHP runtime when the caller doesn't specify one. const runtime = options.runtime ?? SITE_RUNTIME_NATIVE_PHP; const result = await createSiteViaCli( { ...options, runtime, siteId } ); server.details.runtime = runtime; diff --git a/tools/common/lib/site-file-access.ts b/tools/common/lib/site-file-access.ts index ed43a13ce9..5ddc519bed 100644 --- a/tools/common/lib/site-file-access.ts +++ b/tools/common/lib/site-file-access.ts @@ -1,8 +1,8 @@ import { z } from 'zod'; import { SITE_RUNTIME_NATIVE_PHP, type SiteRuntime } from '@studio/common/lib/site-runtime'; -export const SITE_FILE_ACCESS_SITE_DIRECTORY = 'site-directory'; -export const SITE_FILE_ACCESS_ALL_FILES = 'all-files'; +export const SITE_FILE_ACCESS_SITE_DIRECTORY = 'site-directory' as const; +export const SITE_FILE_ACCESS_ALL_FILES = 'all-files' as const; export const siteFileAccessSchema = z.enum( [ SITE_FILE_ACCESS_SITE_DIRECTORY, diff --git a/tools/common/lib/site-runtime.ts b/tools/common/lib/site-runtime.ts index 2d38dd2b22..f73551e2c2 100644 --- a/tools/common/lib/site-runtime.ts +++ b/tools/common/lib/site-runtime.ts @@ -7,12 +7,12 @@ export const siteRuntimeSchema = z.enum( [ SITE_RUNTIME_PLAYGROUND, SITE_RUNTIME export type SiteRuntime = z.infer< typeof siteRuntimeSchema >; export function getSiteRuntime( site: { runtime?: SiteRuntime } ): SiteRuntime { - return site.runtime ?? SITE_RUNTIME_PLAYGROUND; + return site.runtime ?? SITE_RUNTIME_NATIVE_PHP; } // User-facing short names for the runtimes, used by the CLI (--runtime) and the app UI. -export const SITE_MODE_NATIVE = 'native'; -export const SITE_MODE_SANDBOX = 'sandbox'; +export const SITE_MODE_NATIVE = 'native' as const; +export const SITE_MODE_SANDBOX = 'sandbox' as const; export const siteModeSchema = z.enum( [ SITE_MODE_NATIVE, SITE_MODE_SANDBOX ] ); export type SiteMode = z.infer< typeof siteModeSchema >; From 6ea951398b4ca4e5457177f211a0f310922bb299 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 17 Jun 2026 21:02:44 +0100 Subject: [PATCH 13/16] Move runtime adoption tracking into the CLI start path --- apps/cli/lib/cli-config/core.ts | 4 + apps/cli/lib/site-runtime-stats.ts | 84 ++++++++++ apps/cli/lib/tests/site-runtime-stats.test.ts | 143 ++++++++++++++++++ .../tests/wordpress-server-manager.test.ts | 24 +++ apps/cli/lib/types/bump-stats.ts | 6 + apps/cli/lib/wordpress-server-manager.ts | 3 + apps/cli/vitest.setup.ts | 5 + apps/studio/src/ipc-handlers.ts | 17 +-- apps/studio/src/lib/bump-stats.ts | 32 ---- apps/studio/src/lib/site-runtime-stats.ts | 49 ------ apps/studio/src/lib/tests/bump-stats.test.ts | 122 +-------------- apps/studio/src/site-server.ts | 6 - 12 files changed, 272 insertions(+), 223 deletions(-) create mode 100644 apps/cli/lib/site-runtime-stats.ts create mode 100644 apps/cli/lib/tests/site-runtime-stats.test.ts delete mode 100644 apps/studio/src/lib/site-runtime-stats.ts diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index 7ba2ab2114..94d6291a9e 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -45,6 +45,10 @@ const cliConfigSchema = z.object( { lastBumpStats: z .record( z.string(), z.partialRecord( z.enum( StatsMetric ), z.number() ) ) .optional(), + // Per-site daily dedup markers for the runtime adoption stat (RSM-3958). + siteRuntimeStats: z + .record( z.string(), z.object( { bumpedAt: z.number(), stat: z.string() } ) ) + .optional(), lastDependencyCheckTime: z.number().optional(), updateCheck: updateCheckSchema.optional(), // Unix ms timestamp of when the one-time ToS/Privacy notice was displayed. diff --git a/apps/cli/lib/site-runtime-stats.ts b/apps/cli/lib/site-runtime-stats.ts new file mode 100644 index 0000000000..11eb87f7e4 --- /dev/null +++ b/apps/cli/lib/site-runtime-stats.ts @@ -0,0 +1,84 @@ +import { + getSiteFileAccess, + SITE_FILE_ACCESS_ALL_FILES, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; +import { + getSiteRuntime, + SITE_RUNTIME_NATIVE_PHP, + type SiteRuntime, +} from '@studio/common/lib/site-runtime'; +import { isSameDay } from 'date-fns'; +import { bumpStat } from 'cli/lib/bump-stat'; +import { + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, +} from 'cli/lib/cli-config/core'; +import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats'; + +// Composite runtime + file-access metric for the daily active-sites stat. +// Sandbox is always confined to the site directory, so it has no file-access split. +export function getSiteRuntimeStat( site: { + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; +} ): StatsMetric { + if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { + return getSiteFileAccess( site ) === SITE_FILE_ACCESS_ALL_FILES + ? StatsMetric.RUNTIME_NATIVE_ALL_FILES + : StatsMetric.RUNTIME_NATIVE_SITE_DIR; + } + return StatsMetric.RUNTIME_SANDBOX; +} + +/** + * Counts a site toward the daily active-sites-by-runtime stat, deduped per site + * per day so restarts don't inflate the numbers (re-counted when the day rolls + * over or the runtime/file-access choice changes). Tracked here β€” the one funnel + * every site start passes through β€” so it covers all actions (start, + * edit-restart, create, import, pull). + * + * Intentionally bumped even when the CLI is spawned by the app (i.e. ignoring + * `--avoid-telemetry`): that flag only distinguishes app-backed runs from direct + * terminal use, not a user telemetry opt-out. `bumpStat` still no-ops in dev/E2E. + */ +export async function recordSiteRuntimeUsage( site: { + id: string; + runtime?: SiteRuntime; + fileAccess?: SiteFileAccess; +} ): Promise< void > { + if ( ! __ENABLE_CLI_TELEMETRY__ ) { + return; + } + + const now = Date.now(); + const stat = getSiteRuntimeStat( site ); + + try { + let shouldBump = false; + try { + await lockCliConfig(); + const config = await readCliConfig(); + const marker = config.siteRuntimeStats?.[ site.id ]; + const countedTodayForSameRuntime = + marker !== undefined && isSameDay( marker.bumpedAt, now ) && marker.stat === stat; + if ( ! countedTodayForSameRuntime ) { + config.siteRuntimeStats = { + ...config.siteRuntimeStats, + [ site.id ]: { bumpedAt: now, stat }, + }; + await saveCliConfig( config ); + shouldBump = true; + } + } finally { + await unlockCliConfig(); + } + + if ( shouldBump ) { + bumpStat( StatsGroup.STUDIO_CLI_RUNTIME_DAILY, stat ); + } + } catch { + // Best-effort telemetry β€” never block or fail a site start. + } +} diff --git a/apps/cli/lib/tests/site-runtime-stats.test.ts b/apps/cli/lib/tests/site-runtime-stats.test.ts new file mode 100644 index 0000000000..def73de39e --- /dev/null +++ b/apps/cli/lib/tests/site-runtime-stats.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { bumpStat } from 'cli/lib/bump-stat'; +import { + lockCliConfig, + readCliConfig, + saveCliConfig, + unlockCliConfig, +} from 'cli/lib/cli-config/core'; +import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats'; +import { getSiteRuntimeStat, recordSiteRuntimeUsage } from '../site-runtime-stats'; + +vi.mock( 'cli/lib/bump-stat', () => ( { bumpStat: vi.fn() } ) ); +vi.mock( 'cli/lib/cli-config/core', () => ( { + lockCliConfig: vi.fn(), + unlockCliConfig: vi.fn(), + readCliConfig: vi.fn(), + saveCliConfig: vi.fn(), +} ) ); + +const nativeAllFilesSite = { + id: 'site-1', + runtime: 'native-php', + fileAccess: 'all-files', +} as const; + +let mockConfig: Awaited< ReturnType< typeof readCliConfig > >; + +beforeEach( () => { + vi.stubGlobal( '__ENABLE_CLI_TELEMETRY__', true ); + mockConfig = { version: 1, sites: [], snapshots: [] }; + vi.mocked( lockCliConfig ).mockResolvedValue( undefined ); + vi.mocked( unlockCliConfig ).mockResolvedValue( undefined ); + vi.mocked( readCliConfig ).mockImplementation( async () => structuredClone( mockConfig ) ); + vi.mocked( saveCliConfig ).mockImplementation( async ( config ) => { + mockConfig = structuredClone( config ); + } ); +} ); + +afterEach( () => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +} ); + +describe( 'getSiteRuntimeStat', () => { + it( 'maps native + all files to the all-files metric', () => { + expect( getSiteRuntimeStat( { runtime: 'native-php', fileAccess: 'all-files' } ) ).toBe( + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + } ); + + it( 'maps native + site directory to the site-dir metric', () => { + expect( getSiteRuntimeStat( { runtime: 'native-php', fileAccess: 'site-directory' } ) ).toBe( + StatsMetric.RUNTIME_NATIVE_SITE_DIR + ); + } ); + + it( 'defaults native without file access to the site-dir metric', () => { + expect( getSiteRuntimeStat( { runtime: 'native-php' } ) ).toBe( + StatsMetric.RUNTIME_NATIVE_SITE_DIR + ); + } ); + + it( 'maps sandbox to the sandbox metric regardless of file access', () => { + expect( getSiteRuntimeStat( { runtime: 'playground', fileAccess: 'all-files' } ) ).toBe( + StatsMetric.RUNTIME_SANDBOX + ); + } ); + + it( 'defaults an unset runtime to native', () => { + expect( getSiteRuntimeStat( {} ) ).toBe( StatsMetric.RUNTIME_NATIVE_SITE_DIR ); + } ); +} ); + +describe( 'recordSiteRuntimeUsage', () => { + it( 'bumps the daily runtime stat and records the marker on first start', async () => { + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( bumpStat ).toHaveBeenCalledWith( + StatsGroup.STUDIO_CLI_RUNTIME_DAILY, + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + expect( mockConfig.siteRuntimeStats?.[ 'site-1' ]?.bumpedAt ).toBeTypeOf( 'number' ); + expect( mockConfig.siteRuntimeStats?.[ 'site-1' ]?.stat ).toBe( + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + } ); + + it( 'does not bump again the same day for the same runtime', async () => { + const today = Date.UTC( 2024, 1, 6 ); + vi.spyOn( Date, 'now' ).mockReturnValue( today ); + mockConfig.siteRuntimeStats = { + 'site-1': { bumpedAt: today, stat: StatsMetric.RUNTIME_NATIVE_ALL_FILES }, + }; + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( saveCliConfig ).not.toHaveBeenCalled(); + expect( bumpStat ).not.toHaveBeenCalled(); + } ); + + it( 'bumps again once a new day has started', async () => { + vi.spyOn( Date, 'now' ).mockReturnValue( Date.UTC( 2024, 1, 7 ) ); + mockConfig.siteRuntimeStats = { + 'site-1': { bumpedAt: Date.UTC( 2024, 1, 6 ), stat: StatsMetric.RUNTIME_NATIVE_ALL_FILES }, + }; + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( saveCliConfig ).toHaveBeenCalled(); + expect( bumpStat ).toHaveBeenCalledWith( + StatsGroup.STUDIO_CLI_RUNTIME_DAILY, + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + } ); + + it( 'bumps again the same day when the runtime changed', async () => { + const today = Date.UTC( 2024, 1, 6 ); + vi.spyOn( Date, 'now' ).mockReturnValue( today ); + // Counted earlier today as sandbox; the site is now native + all files. + mockConfig.siteRuntimeStats = { + 'site-1': { bumpedAt: today, stat: StatsMetric.RUNTIME_SANDBOX }, + }; + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( bumpStat ).toHaveBeenCalledWith( + StatsGroup.STUDIO_CLI_RUNTIME_DAILY, + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + expect( mockConfig.siteRuntimeStats?.[ 'site-1' ]?.stat ).toBe( + StatsMetric.RUNTIME_NATIVE_ALL_FILES + ); + } ); + + it( 'does nothing when CLI telemetry is disabled', async () => { + vi.stubGlobal( '__ENABLE_CLI_TELEMETRY__', false ); + + await recordSiteRuntimeUsage( nativeAllFilesSite ); + + expect( lockCliConfig ).not.toHaveBeenCalled(); + expect( bumpStat ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index 9a8a746104..7212d8b414 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -5,6 +5,7 @@ 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 { recordSiteRuntimeUsage } from 'cli/lib/site-runtime-stats'; import { isServerRunning, sendWpCliCommand, @@ -17,6 +18,9 @@ vi.mock( 'cli/lib/daemon-client' ); vi.mock( 'cli/lib/dependency-management/php-binary', () => ( { ensurePhpBinaryAvailable: vi.fn().mockResolvedValue( undefined ), } ) ); +vi.mock( 'cli/lib/site-runtime-stats', () => ( { + recordSiteRuntimeUsage: vi.fn(), +} ) ); describe( 'WordPress Server Manager', () => { const mockLogger = { @@ -221,6 +225,26 @@ describe( 'WordPress Server Manager', () => { ); } ); + it( 'records site runtime usage after a successful start', async () => { + setupIpcMocks(); + + await startWordPressServer( { ...mockSiteData, runtime: SITE_RUNTIME_NATIVE_PHP }, mockLogger ); + + expect( vi.mocked( recordSiteRuntimeUsage ) ).toHaveBeenCalledWith( + expect.objectContaining( { id: mockSiteData.id, runtime: SITE_RUNTIME_NATIVE_PHP } ) + ); + } ); + + it( 'does not record runtime usage when the start fails', async () => { + vi.mocked( daemonClient.startProcess ).mockRejectedValue( + new Error( 'Failed to start process' ) + ); + + await expect( startWordPressServer( mockSiteData, mockLogger ) ).rejects.toThrow(); + + expect( vi.mocked( recordSiteRuntimeUsage ) ).not.toHaveBeenCalled(); + } ); + it( 'should handle start process failure', async () => { vi.mocked( daemonClient.startProcess ).mockRejectedValue( new Error( 'Failed to start process' ) diff --git a/apps/cli/lib/types/bump-stats.ts b/apps/cli/lib/types/bump-stats.ts index d9435e7e35..73b8fe7b8a 100644 --- a/apps/cli/lib/types/bump-stats.ts +++ b/apps/cli/lib/types/bump-stats.ts @@ -10,6 +10,8 @@ export enum StatsGroup { STUDIO_CLI_TOTAL_LAUNCHES_APP = 'studio-cli-lch-tot-app', STUDIO_CLI_SITE_CREATE_NPM = 'studio-cli-site-crt-npm', STUDIO_CLI_SITE_CREATE_APP = 'studio-cli-site-crt-app', + // Daily active sites by PHP runtime + file access β€” see RSM-3958. + STUDIO_CLI_RUNTIME_DAILY = 'studio-cli-runtime-day', // Dolly remote-session (Telegram bot bridge) β€” see STU-1739. STUDIO_CLI_DOLLY_START = 'studio-cli-dolly-start', STUDIO_CLI_DOLLY_ATTACH = 'studio-cli-dolly-attach', @@ -27,6 +29,10 @@ export enum StatsMetric { LINUX = 'linux', WINDOWS = 'win32', UNKNOWN_PLATFORM = 'unknown-platform', + // Per-site daily active-runtime adoption β€” see RSM-3958. + RUNTIME_NATIVE_SITE_DIR = 'native-site-dir', + RUNTIME_NATIVE_ALL_FILES = 'native-all-files', + RUNTIME_SANDBOX = 'sandbox', // Dolly turn outcomes β€” mirror `TurnOutcomeStatus` from turn-runner.ts, plus an `aborted` // bucket for detach-mid-turn (signalled via the abort controller, not the status field). TURN_ERROR = 'error', diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 63a312b991..dc1174bafc 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -31,6 +31,7 @@ import { sendMessageToProcess, } from 'cli/lib/daemon-client'; import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; +import { recordSiteRuntimeUsage } from 'cli/lib/site-runtime-stats'; import { ProcessDescription } from 'cli/lib/types/process-manager-ipc'; import { ServerConfig, ManagerMessagePayload } from 'cli/lib/types/wordpress-server-ipc'; import { Logger } from 'cli/logger'; @@ -249,6 +250,8 @@ export async function startWordPressServer( { logger } ); + await recordSiteRuntimeUsage( site ); + return withSiteRuntime( processDesc ); } finally { readyOrExit.dispose(); diff --git a/apps/cli/vitest.setup.ts b/apps/cli/vitest.setup.ts index 69c35b4399..3e52ce5f15 100644 --- a/apps/cli/vitest.setup.ts +++ b/apps/cli/vitest.setup.ts @@ -3,6 +3,11 @@ import { vi, beforeEach, afterEach, afterAll } from 'vitest'; ( global as typeof global & { COMMIT_HASH: string } ).COMMIT_HASH = 'mock-hash'; +// CLI telemetry is gated on this build-time flag. Default it off in tests; specs +// that assert telemetry opt in via vi.stubGlobal( '__ENABLE_CLI_TELEMETRY__', true ). +( global as typeof global & { __ENABLE_CLI_TELEMETRY__: boolean } ).__ENABLE_CLI_TELEMETRY__ = + false; + const originalConsoleLog = console.log; beforeEach( () => { diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index fac19def6e..a97cfd9aee 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -122,7 +122,6 @@ import { setSentryWpcomUserIdMain } from 'src/lib/main-sentry-utils'; import * as oauthClient from 'src/lib/oauth'; import { getAiInstructionsPath } from 'src/lib/server-files-paths'; import { shellOpenExternalWrapper } from 'src/lib/shell-open-external-wrapper'; -import { recordSiteRuntimeUsage } from 'src/lib/site-runtime-stats'; import { updateSiteUrl } from 'src/lib/update-site-url'; import * as windowsHelpers from 'src/lib/windows-helpers'; import { getLogsFilePath, writeLogToFile, type LogLevel } from 'src/logging'; @@ -984,13 +983,11 @@ export async function updateSite( options.wp = isWordPressDevVersion( wpVersion ) ? 'nightly' : wpVersion; } - const runtimeChanged = getSiteRuntime( updatedSite ) !== getSiteRuntime( currentSite ); - if ( runtimeChanged ) { + if ( getSiteRuntime( updatedSite ) !== getSiteRuntime( currentSite ) ) { options.runtime = siteModeFromRuntime( getSiteRuntime( updatedSite ) ); } - const fileAccessChanged = getSiteFileAccess( updatedSite ) !== getSiteFileAccess( currentSite ); - if ( fileAccessChanged ) { + if ( getSiteFileAccess( updatedSite ) !== getSiteFileAccess( currentSite ) ) { options.fileAccess = getSiteFileAccess( updatedSite ); } @@ -1026,16 +1023,6 @@ export async function updateSite( if ( hasCliChanges ) { await editSiteViaCli( options ); - - // Capture a runtime/file-access switch right away, rather than waiting for - // the next start or day boundary, so the daily stat reflects the change. - if ( runtimeChanged || fileAccessChanged ) { - await recordSiteRuntimeUsage( { - id: updatedSite.id, - runtime: getSiteRuntime( updatedSite ), - fileAccess: getSiteFileAccess( updatedSite ), - } ); - } } } diff --git a/apps/studio/src/lib/bump-stats.ts b/apps/studio/src/lib/bump-stats.ts index c90666c4ec..ce3ccc3223 100644 --- a/apps/studio/src/lib/bump-stats.ts +++ b/apps/studio/src/lib/bump-stats.ts @@ -4,16 +4,6 @@ import { LastBumpStatsProvider, AggregateInterval, } from '@studio/common/lib/bump-stat'; -import { - getSiteFileAccess, - SITE_FILE_ACCESS_ALL_FILES, - type SiteFileAccess, -} from '@studio/common/lib/site-file-access'; -import { - getSiteRuntime, - SITE_RUNTIME_NATIVE_PHP, - type SiteRuntime, -} from '@studio/common/lib/site-runtime'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; import type { ImporterType } from '@studio/common/lib/import-export-events'; @@ -41,10 +31,6 @@ export enum StatsGroup { STUDIO_CODE_UI_RUN = 'studio-code-ui-run', STUDIO_CODE_UI_WKLY_UNQ = 'studio-code-ui-wk-unq', STUDIO_CODE_UI_MON_UNQ = 'studio-code-ui-mon-unq', - // Daily count of active sites by runtime + file access. Bumped on site start, - // deduped to once per site per day (re-counted when the runtime changes) so - // restarts don't inflate it. - STUDIO_SITE_RUNTIME_DAILY = 'studio-app-runtime-day', } export enum StatsMetric { @@ -70,10 +56,6 @@ export enum StatsMetric { REMOTE_BLUEPRINT = 'remote-blueprint', FILE_BLUEPRINT = 'file-blueprint', NO_BLUEPRINT = 'no-blueprint', - // Site runtime (composite of runtime + file access) - RUNTIME_NATIVE_SITE_DIR = 'native-site-dir', - RUNTIME_NATIVE_ALL_FILES = 'native-all-files', - RUNTIME_SANDBOX = 'sandbox', } const lastBumpStatsProvider: LastBumpStatsProvider = { @@ -135,20 +117,6 @@ export function getImporterMetric( importer?: ImporterType ): StatsMetric { } } -// Composite runtime + file-access metric for the weekly active-sites stat. -// Sandbox is always confined to the site directory, so it has no file-access split. -export function getSiteRuntimeStat( site: { - runtime?: SiteRuntime; - fileAccess?: SiteFileAccess; -} ): StatsMetric { - if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { - return getSiteFileAccess( site ) === SITE_FILE_ACCESS_ALL_FILES - ? StatsMetric.RUNTIME_NATIVE_ALL_FILES - : StatsMetric.RUNTIME_NATIVE_SITE_DIR; - } - return StatsMetric.RUNTIME_SANDBOX; -} - export function getBlueprintMetric( blueprintSlug: string | undefined ): string { if ( ! blueprintSlug ) { return StatsMetric.NO_BLUEPRINT; diff --git a/apps/studio/src/lib/site-runtime-stats.ts b/apps/studio/src/lib/site-runtime-stats.ts deleted file mode 100644 index e4069fd360..0000000000 --- a/apps/studio/src/lib/site-runtime-stats.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { type SiteFileAccess } from '@studio/common/lib/site-file-access'; -import { type SiteRuntime } from '@studio/common/lib/site-runtime'; -import { isSameDay } from 'date-fns'; -import { bumpStat, getSiteRuntimeStat, StatsGroup } from 'src/lib/bump-stats'; -import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; - -interface SiteRuntimeUsage { - id: string; - runtime?: SiteRuntime; - fileAccess?: SiteFileAccess; -} - -/** - * Counts a site toward the daily active-sites-by-runtime stat. Deduped per site - * per day so successive restarts don't inflate the numbers, but re-counted when - * the day rolls over or the runtime/file-access choice changes (so a switch is - * captured on the next start rather than at the next day boundary). The per-site - * marker lives in `app.json`'s site metadata. - */ -export async function recordSiteRuntimeUsage( site: SiteRuntimeUsage ): Promise< void > { - const now = Date.now(); - const stat = getSiteRuntimeStat( site ); - let shouldBump = false; - - try { - await lockAppdata(); - const userData = await loadUserData(); - const metadata = userData.siteMetadata[ site.id ]; - const countedTodayForSameRuntime = - metadata?.runtimeStatBumpedAt !== undefined && - isSameDay( metadata.runtimeStatBumpedAt, now ) && - metadata.runtimeStat === stat; - if ( ! countedTodayForSameRuntime ) { - userData.siteMetadata[ site.id ] = { - ...metadata, - runtimeStatBumpedAt: now, - runtimeStat: stat, - }; - await saveUserData( userData ); - shouldBump = true; - } - } finally { - await unlockAppdata(); - } - - if ( shouldBump ) { - bumpStat( StatsGroup.STUDIO_SITE_RUNTIME_DAILY, stat ); - } -} diff --git a/apps/studio/src/lib/tests/bump-stats.test.ts b/apps/studio/src/lib/tests/bump-stats.test.ts index 025a4213c2..6a3e41ef69 100644 --- a/apps/studio/src/lib/tests/bump-stats.test.ts +++ b/apps/studio/src/lib/tests/bump-stats.test.ts @@ -3,14 +3,7 @@ import { waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import { EMPTY_USER_DATA } from 'src/storage/storage-types'; import { loadUserData, lockAppdata, saveUserData, unlockAppdata } from 'src/storage/user-data'; -import { - bumpStat, - bumpAggregatedUniqueStat, - getSiteRuntimeStat, - StatsGroup, - StatsMetric, -} from '../bump-stats'; -import { recordSiteRuntimeUsage } from '../site-runtime-stats'; +import { bumpStat, bumpAggregatedUniqueStat, StatsGroup, StatsMetric } from '../bump-stats'; vi.mock( 'src/storage/user-data', () => ( { loadUserData: vi.fn(), @@ -231,116 +224,3 @@ describe( 'bumpAggregatedUniqueStat', () => { } ); } ); - -describe( 'getSiteRuntimeStat', () => { - test( 'maps native + all files to the all-files metric', () => { - expect( getSiteRuntimeStat( { runtime: 'native-php', fileAccess: 'all-files' } ) ).toBe( - StatsMetric.RUNTIME_NATIVE_ALL_FILES - ); - } ); - - test( 'maps native + site directory to the site-dir metric', () => { - expect( getSiteRuntimeStat( { runtime: 'native-php', fileAccess: 'site-directory' } ) ).toBe( - StatsMetric.RUNTIME_NATIVE_SITE_DIR - ); - } ); - - test( 'defaults native without file access to the site-dir metric', () => { - expect( getSiteRuntimeStat( { runtime: 'native-php' } ) ).toBe( - StatsMetric.RUNTIME_NATIVE_SITE_DIR - ); - } ); - - test( 'maps sandbox to the sandbox metric regardless of file access', () => { - expect( getSiteRuntimeStat( { runtime: 'playground', fileAccess: 'all-files' } ) ).toBe( - StatsMetric.RUNTIME_SANDBOX - ); - } ); - - test( 'defaults an unset runtime to native', () => { - expect( getSiteRuntimeStat( {} ) ).toBe( StatsMetric.RUNTIME_NATIVE_SITE_DIR ); - } ); -} ); - -describe( 'recordSiteRuntimeUsage', () => { - const nativeAllFilesSite = { - id: 'site-1', - runtime: 'native-php', - fileAccess: 'all-files', - } as const; - - test( 'bumps the daily runtime stat and records the marker on first start', async () => { - const mockRequest = mockBumpStatRequest( - StatsGroup.STUDIO_SITE_RUNTIME_DAILY, - StatsMetric.RUNTIME_NATIVE_ALL_FILES - ); - - await recordSiteRuntimeUsage( nativeAllFilesSite ); - - await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); - expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStatBumpedAt ).toBeTypeOf( 'number' ); - expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStat ).toBe( - StatsMetric.RUNTIME_NATIVE_ALL_FILES - ); - } ); - - test( 'does not bump again the same day for the same runtime', async () => { - const today = Date.UTC( 2024, 1, 6 ); - mockCurrentTime( today ); - mockUserData.siteMetadata[ 'site-1' ] = { - runtimeStatBumpedAt: today, - runtimeStat: StatsMetric.RUNTIME_NATIVE_ALL_FILES, - }; - - await recordSiteRuntimeUsage( nativeAllFilesSite ); - - expect( saveUserData ).not.toHaveBeenCalled(); - } ); - - test( 'bumps again once a new day has started', async () => { - mockCurrentTime( Date.UTC( 2024, 1, 7 ) ); - mockUserData.siteMetadata[ 'site-1' ] = { - runtimeStatBumpedAt: Date.UTC( 2024, 1, 6 ), - runtimeStat: StatsMetric.RUNTIME_NATIVE_ALL_FILES, - }; - const mockRequest = mockBumpStatRequest( - StatsGroup.STUDIO_SITE_RUNTIME_DAILY, - StatsMetric.RUNTIME_NATIVE_ALL_FILES - ); - - await recordSiteRuntimeUsage( nativeAllFilesSite ); - - await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); - expect( saveUserData ).toHaveBeenCalled(); - } ); - - test( 'bumps again the same day when the runtime changed', async () => { - const today = Date.UTC( 2024, 1, 6 ); - mockCurrentTime( today ); - // Counted earlier today as sandbox; the site is now native + all files. - mockUserData.siteMetadata[ 'site-1' ] = { - runtimeStatBumpedAt: today, - runtimeStat: StatsMetric.RUNTIME_SANDBOX, - }; - const mockRequest = mockBumpStatRequest( - StatsGroup.STUDIO_SITE_RUNTIME_DAILY, - StatsMetric.RUNTIME_NATIVE_ALL_FILES - ); - - await recordSiteRuntimeUsage( nativeAllFilesSite ); - - await waitFor( () => expect( mockRequest.isDone() ).toBe( true ) ); - expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStat ).toBe( - StatsMetric.RUNTIME_NATIVE_ALL_FILES - ); - } ); - - test( 'preserves existing site metadata when recording the marker', async () => { - mockUserData.siteMetadata[ 'site-1' ] = { sortOrder: 3 }; - - await recordSiteRuntimeUsage( nativeAllFilesSite ); - - expect( mockUserData.siteMetadata[ 'site-1' ]?.sortOrder ).toBe( 3 ); - expect( mockUserData.siteMetadata[ 'site-1' ]?.runtimeStatBumpedAt ).toBeTypeOf( 'number' ); - } ); -} ); diff --git a/apps/studio/src/site-server.ts b/apps/studio/src/site-server.ts index b96b815584..9c75537381 100644 --- a/apps/studio/src/site-server.ts +++ b/apps/studio/src/site-server.ts @@ -12,7 +12,6 @@ import { WP_CLI_DEFAULT_RESPONSE_TIMEOUT, WP_CLI_IMPORT_EXPORT_RESPONSE_TIMEOUT, } from 'src/constants'; -import { recordSiteRuntimeUsage } from 'src/lib/site-runtime-stats'; import { CliServerProcess } from 'src/modules/cli/lib/cli-server-process'; import { createSiteViaCli, type CreateSiteOptions } from 'src/modules/cli/lib/cli-site-creator'; import { executeCliCommand } from 'src/modules/cli/lib/execute-command'; @@ -234,11 +233,6 @@ export class SiteServer { console.log( `Starting server for '${ this.details.name }'` ); await this.server.start(); - - // Fire-and-forget: telemetry must never block or fail a site start. - recordSiteRuntimeUsage( this.details ).catch( ( error ) => { - console.error( 'Failed to record site runtime usage stat:', error ); - } ); } updateSiteDetails( site: SiteDetails ) { From 3678052b16c7920155871e22c826c958b4216f10 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Wed, 17 Jun 2026 21:18:00 +0100 Subject: [PATCH 14/16] Fix prettier formatting in the server-manager wiring test --- apps/cli/lib/tests/wordpress-server-manager.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/cli/lib/tests/wordpress-server-manager.test.ts b/apps/cli/lib/tests/wordpress-server-manager.test.ts index 7212d8b414..b58851ee2d 100644 --- a/apps/cli/lib/tests/wordpress-server-manager.test.ts +++ b/apps/cli/lib/tests/wordpress-server-manager.test.ts @@ -228,7 +228,10 @@ describe( 'WordPress Server Manager', () => { it( 'records site runtime usage after a successful start', async () => { setupIpcMocks(); - await startWordPressServer( { ...mockSiteData, runtime: SITE_RUNTIME_NATIVE_PHP }, mockLogger ); + await startWordPressServer( + { ...mockSiteData, runtime: SITE_RUNTIME_NATIVE_PHP }, + mockLogger + ); expect( vi.mocked( recordSiteRuntimeUsage ) ).toHaveBeenCalledWith( expect.objectContaining( { id: mockSiteData.id, runtime: SITE_RUNTIME_NATIVE_PHP } ) From 6f34ea1d4a69847634b94aa4cf408d682786d0f8 Mon Sep 17 00:00:00 2001 From: Fredrik Rombach Ekelund Date: Thu, 18 Jun 2026 10:57:59 +0200 Subject: [PATCH 15/16] No custom types --- apps/cli/lib/site-runtime-stats.ts | 7 ++----- apps/cli/lib/tests/site-runtime-stats.test.ts | 8 +++++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/cli/lib/site-runtime-stats.ts b/apps/cli/lib/site-runtime-stats.ts index 11eb87f7e4..af65a929a7 100644 --- a/apps/cli/lib/site-runtime-stats.ts +++ b/apps/cli/lib/site-runtime-stats.ts @@ -14,6 +14,7 @@ import { lockCliConfig, readCliConfig, saveCliConfig, + SiteData, unlockCliConfig, } from 'cli/lib/cli-config/core'; import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats'; @@ -43,11 +44,7 @@ export function getSiteRuntimeStat( site: { * `--avoid-telemetry`): that flag only distinguishes app-backed runs from direct * terminal use, not a user telemetry opt-out. `bumpStat` still no-ops in dev/E2E. */ -export async function recordSiteRuntimeUsage( site: { - id: string; - runtime?: SiteRuntime; - fileAccess?: SiteFileAccess; -} ): Promise< void > { +export async function recordSiteRuntimeUsage( site: SiteData ): Promise< void > { if ( ! __ENABLE_CLI_TELEMETRY__ ) { return; } diff --git a/apps/cli/lib/tests/site-runtime-stats.test.ts b/apps/cli/lib/tests/site-runtime-stats.test.ts index def73de39e..829655f9de 100644 --- a/apps/cli/lib/tests/site-runtime-stats.test.ts +++ b/apps/cli/lib/tests/site-runtime-stats.test.ts @@ -1,9 +1,11 @@ +import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { bumpStat } from 'cli/lib/bump-stat'; import { lockCliConfig, readCliConfig, saveCliConfig, + SiteData, unlockCliConfig, } from 'cli/lib/cli-config/core'; import { StatsGroup, StatsMetric } from 'cli/lib/types/bump-stats'; @@ -17,10 +19,14 @@ vi.mock( 'cli/lib/cli-config/core', () => ( { saveCliConfig: vi.fn(), } ) ); -const nativeAllFilesSite = { +const nativeAllFilesSite: SiteData = { id: 'site-1', + name: 'My WordPress Site', + path: '/home/user/Studio/my-wordpress-site', + port: 8888, runtime: 'native-php', fileAccess: 'all-files', + phpVersion: DEFAULT_PHP_VERSION, } as const; let mockConfig: Awaited< ReturnType< typeof readCliConfig > >; From 179150db6063944937d62c01b562ccb7f6c5f3d0 Mon Sep 17 00:00:00 2001 From: bcotrim Date: Thu, 18 Jun 2026 12:13:12 +0100 Subject: [PATCH 16/16] Use a loose CLI config schema and render runtime copy as components --- apps/cli/lib/cli-config/core.ts | 2 +- .../src/components/content-tab-settings.tsx | 23 ++++++----- apps/studio/src/lib/site-runtime-copy.ts | 33 ---------------- apps/studio/src/lib/site-runtime-copy.tsx | 38 +++++++++++++++++++ .../add-site/components/create-site-form.tsx | 8 ++-- .../site-settings/edit-site-details.tsx | 10 +++-- 6 files changed, 64 insertions(+), 50 deletions(-) delete mode 100644 apps/studio/src/lib/site-runtime-copy.ts create mode 100644 apps/studio/src/lib/site-runtime-copy.tsx diff --git a/apps/cli/lib/cli-config/core.ts b/apps/cli/lib/cli-config/core.ts index 94d6291a9e..792ffb61a4 100644 --- a/apps/cli/lib/cli-config/core.ts +++ b/apps/cli/lib/cli-config/core.ts @@ -36,7 +36,7 @@ export const updateCheckSchema = z.object( { latestVersion: z.string(), } ); -const cliConfigSchema = z.object( { +const cliConfigSchema = z.looseObject( { version: z.literal( CLI_CONFIG_VERSION ), sites: z.array( siteSchema ).default( () => [] ), snapshots: z.array( snapshotSchema ).default( () => [] ), diff --git a/apps/studio/src/components/content-tab-settings.tsx b/apps/studio/src/components/content-tab-settings.tsx index 79719204b3..c6c1cff8b5 100644 --- a/apps/studio/src/components/content-tab-settings.tsx +++ b/apps/studio/src/components/content-tab-settings.tsx @@ -22,7 +22,7 @@ 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 { getFileAccessDescription, getRuntimeDescription } from 'src/lib/site-runtime-copy'; +import { FileAccessDescription, RuntimeDescription } from 'src/lib/site-runtime-copy'; import EditSiteDetails from 'src/modules/site-settings/edit-site-details'; import { useAppDispatch } from 'src/stores'; import { @@ -50,12 +50,6 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps ) const { __ } = useI18n(); const { data: isCertificateTrusted } = useCheckCertificateTrustQuery(); const isNativePhpRuntime = getSiteRuntime( selectedSite ) === SITE_RUNTIME_NATIVE_PHP; - const runtimeDescription = getRuntimeDescription( __, getSiteRuntime( selectedSite ) ); - const fileAccessDescription = getFileAccessDescription( - __, - getSiteRuntime( selectedSite ), - getSiteFileAccess( selectedSite ) - ); const username = selectedSite.adminUsername || 'admin'; // Empty strings account for legacy sites lacking a stored password. const storedPassword = decodePassword( selectedSite.adminPassword ?? '' ); @@ -213,7 +207,10 @@ export function ContentTabSettings( { selectedSite }: ContentTabSettingsProps )
{ /* translators: value for the PHP runtime setting on the site settings screen */ } { isNativePhpRuntime ? __( 'Native' ) : __( 'Sandbox' ) } - + } + placement="top-start" + > - + + } + placement="top-start" + > string; - -// Explainer copy shown under the File access control in the create/edit site -// forms. Takes the component's translate fn so the strings resolve against the -// active locale (and stay extractable for translation). -export function getFileAccessDescription( - __: Translate, - runtime: SiteRuntime, - fileAccess: SiteFileAccess -): string { - if ( runtime === SITE_RUNTIME_PLAYGROUND ) { - return __( 'The sandbox can only access the site directory.' ); - } - if ( fileAccess === SITE_FILE_ACCESS_ALL_FILES ) { - return __( 'PHP can access any file on your system.' ); - } - return __( "Restricts the site's file access to the site directory." ); -} - -// Explainer copy shown under the PHP runtime control in the create/edit site -// forms and in the read-only site settings. -export function getRuntimeDescription( __: Translate, runtime: SiteRuntime ): string { - if ( runtime === SITE_RUNTIME_PLAYGROUND ) { - return __( 'Runs the site in an isolated WordPress Playground sandbox.' ); - } - return __( 'Runs the site with native PHP for the best performance.' ); -} diff --git a/apps/studio/src/lib/site-runtime-copy.tsx b/apps/studio/src/lib/site-runtime-copy.tsx new file mode 100644 index 0000000000..5988a48343 --- /dev/null +++ b/apps/studio/src/lib/site-runtime-copy.tsx @@ -0,0 +1,38 @@ +import { + SITE_FILE_ACCESS_ALL_FILES, + type SiteFileAccess, +} from '@studio/common/lib/site-file-access'; +import { SITE_RUNTIME_PLAYGROUND, type SiteRuntime } from '@studio/common/lib/site-runtime'; +import { useI18n } from '@wordpress/react-i18n'; + +// Explainer copy shown under the PHP runtime control in the create/edit site +// forms and in the read-only site settings. +export function RuntimeDescription( { runtime }: { runtime: SiteRuntime } ) { + const { __ } = useI18n(); + return ( + <> + { runtime === SITE_RUNTIME_PLAYGROUND + ? __( 'Runs the site in an isolated WordPress Playground sandbox.' ) + : __( 'Runs the site with native PHP for the best performance.' ) } + + ); +} + +// Explainer copy shown under the File access control in the create/edit site +// forms and in the read-only site settings. +export function FileAccessDescription( { + runtime, + fileAccess, +}: { + runtime: SiteRuntime; + fileAccess: SiteFileAccess; +} ) { + const { __ } = useI18n(); + if ( runtime === SITE_RUNTIME_PLAYGROUND ) { + return <>{ __( 'The sandbox can only access the site directory.' ) }; + } + if ( fileAccess === SITE_FILE_ACCESS_ALL_FILES ) { + return <>{ __( 'PHP can access any file on your system.' ) }; + } + return <>{ __( "Restricts the site's file access to the site directory." ) }; +} 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 979afb6941..dbaceed64a 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 @@ -37,7 +37,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 { getFileAccessDescription } from 'src/lib/site-runtime-copy'; +import { FileAccessDescription } from 'src/lib/site-runtime-copy'; 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'; @@ -112,7 +112,6 @@ export const CreateSiteForm = ( { selectedRuntime === SITE_RUNTIME_PLAYGROUND ? SITE_FILE_ACCESS_SITE_DIRECTORY : selectedFileAccess; - const fileAccessDescription = getFileAccessDescription( __, selectedRuntime, usedFileAccess ); const [ useCustomDomain, setUseCustomDomain ] = useState( false ); const [ customDomain, setCustomDomain ] = useState< string | null >( null ); const [ enableHttps, setEnableHttps ] = useState( false ); @@ -570,7 +569,10 @@ export const CreateSiteForm = ( { __nextHasNoMarginBottom /> - { fileAccessDescription } +
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 8f98341e5a..78c20a22f6 100644 --- a/apps/studio/src/modules/site-settings/edit-site-details.tsx +++ b/apps/studio/src/modules/site-settings/edit-site-details.tsx @@ -46,7 +46,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 { getFileAccessDescription, getRuntimeDescription } from 'src/lib/site-runtime-copy'; +import { FileAccessDescription, RuntimeDescription } from 'src/lib/site-runtime-copy'; import { useCheckCertificateTrustQuery } from 'src/stores/certificate-trust-api'; type EditSiteDetailsProps = { @@ -99,7 +99,6 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = selectedRuntime === SITE_RUNTIME_PLAYGROUND ? SITE_FILE_ACCESS_SITE_DIRECTORY : selectedFileAccess; - const fileAccessDescription = getFileAccessDescription( __, selectedRuntime, usedFileAccess ); const selectedSitePhpVersion = selectedSite?.phpVersion; const resolvedSitePhpVersion = resolvePhpVersion( selectedSitePhpVersion ); const phpVersionWarning = @@ -480,7 +479,7 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = __nextHasNoMarginBottom /> - { getRuntimeDescription( __, selectedRuntime ) } + @@ -507,7 +506,10 @@ const EditSiteDetails = ( { currentWpVersion, onSave }: EditSiteDetailsProps ) = __nextHasNoMarginBottom /> - { fileAccessDescription } +