diff --git a/apps/cli/commands/pull-reprint.ts b/apps/cli/commands/pull-reprint.ts index 472828099c..4e4ee6dce5 100644 --- a/apps/cli/commands/pull-reprint.ts +++ b/apps/cli/commands/pull-reprint.ts @@ -17,7 +17,12 @@ 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 { + SITE_RUNTIME_NATIVE_PHP, + SITE_RUNTIME_PLAYGROUND, + SiteRuntime, + getSiteRuntime, +} 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'; @@ -55,13 +60,18 @@ import { import { ensureImportedSiteSqliteReady, loadImportedRuntimeStartOptions, + loadImportedRuntimeStartOptionsNative, } from 'cli/lib/pull/runtime-start-options'; import { getDefaultSitePath } from 'cli/lib/site-paths'; import { buildAutoLoginUrl } from 'cli/lib/site-utils'; import { fetchSyncableSites } from 'cli/lib/sync-api'; import { pickSyncSite } from 'cli/lib/sync-site-picker'; import { getPrettyPath } from 'cli/lib/utils'; -import { getProcessName, startWordPressServer } from 'cli/lib/wordpress-server-manager'; +import { + startWordPressServer, + StartServerOptions, + getProcessName, +} from 'cli/lib/wordpress-server-manager'; import { Logger, LoggerError } from 'cli/logger'; import { StudioArgv } from 'cli/types'; import type { SyncSite } from '@studio/common/types/sync'; @@ -390,7 +400,13 @@ export async function runCommand( let preflight; try { - preflight = await runPreflight( studioMetadata, apiUrl, secret, verbose ); + preflight = await runPreflight( + SITE_RUNTIME_NATIVE_PHP, + studioMetadata, + apiUrl, + secret, + verbose + ); } catch ( preflightError ) { // Preflight against ?reprint-api can fail for two reasons we can // recover from on WP.com: the stored secret expired, or the @@ -434,7 +450,13 @@ export async function runCommand( sourceSite.wpComToken.accessToken, verbose ); - preflight = await runPreflight( studioMetadata, apiUrl, secret, verbose ); + preflight = await runPreflight( + SITE_RUNTIME_NATIVE_PHP, + studioMetadata, + apiUrl, + secret, + verbose + ); } studioMetadata.remoteSiteUrl = preflight.siteurl || studioMetadata.normalizedUrl; studioMetadata.tablePrefix = preflight.table_prefix || undefined; @@ -452,7 +474,7 @@ export async function runCommand( // a delta re-pull, resets its own sub-command state via // prepare_repull(). if ( ! hasPullCompletedStage( studioMetadata, 'pulled' ) ) { - await runFullPull( studioMetadata, apiUrl, secret, verbose ); + await runFullPull( SITE_RUNTIME_NATIVE_PHP, studioMetadata, apiUrl, secret, verbose ); } let createdSiteRecord = false; @@ -494,10 +516,18 @@ export async function runCommand( } if ( ! hasPullCompletedStage( studioMetadata, 'site-started' ) ) { - await ensureImportedSiteSqliteReady( studioMetadata.runtimeBlueprintPath ); - const runtimeStartOptions = await loadImportedRuntimeStartOptions( - studioMetadata.runtimeBlueprintPath - ); + let runtimeStartOptions: StartServerOptions; + if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) { + runtimeStartOptions = loadImportedRuntimeStartOptionsNative( + studioMetadata.technicalSiteDirectory, + studioMetadata.runtimeDirectory + ); + } else { + await ensureImportedSiteSqliteReady( studioMetadata.runtimeBlueprintPath ); + runtimeStartOptions = await loadImportedRuntimeStartOptions( + studioMetadata.runtimeBlueprintPath + ); + } // Persist the computed start options so `studio site start` and // the daemon can re-read them without recomputing (which spins @@ -566,7 +596,13 @@ export async function runCommand( if ( ! hasPullCompletedStage( studioMetadata, 'completed' ) ) { if ( hasSkippedFiles( studioMetadata.stateDirectory ) ) { - await downloadSkippedFiles( studioMetadata, apiUrl, secret, verbose ); + await downloadSkippedFiles( + getSiteRuntime( site ), + studioMetadata, + apiUrl, + secret, + verbose + ); } recordCompletedStage( studioMetadata, 'completed' ); @@ -606,6 +642,7 @@ export async function runCommand( * metadata before the download stages begin. */ async function runPreflight( + runtime: SiteRuntime, sessionMetadata: PullSessionMetadata, sourceSiteApiUrl: string, sourceSiteSecret: string, @@ -640,6 +677,7 @@ async function runPreflight( undefined, { verboseCommands: verbose, + runtime, } ); } catch ( preflightError ) { @@ -810,7 +848,7 @@ function readPullMetadata( metadataPath: string ): PullSessionMetadata | null { /** * Run reprint's composite `pull` command: the whole site-clone * pipeline (preflight → files-pull → db-pull → db-apply → - * flat-docroot → apply-runtime) in a single PHP-WASM fork, with + * flat-docroot → apply-runtime) in a single child process, with * reprint owning the stage ordering and, when the prior pull already * completed, resetting its own sub-command state for a delta re-pull * via prepare_repull(). @@ -830,6 +868,7 @@ function readPullMetadata( metadataPath: string ): PullSessionMetadata | null { * Advances the pull stage to 'pulled'. */ export async function runFullPull( + runtime: SiteRuntime, metadata: PullSessionMetadata, apiUrl: string, secret: string, @@ -839,6 +878,7 @@ export async function runFullPull( const sqlitePath = contentDir ? `${ metadata.rawDirectory }${ contentDir }/database/.ht.sqlite` : `${ metadata.sitePath }/wp-content/database/.ht.sqlite`; + const reprintRuntime = runtime === SITE_RUNTIME_NATIVE_PHP ? 'nginx-fpm' : 'playground-cli'; logger.reportStart( LoggerAction.DOWNLOAD_FILES, __( 'Pulling site…' ) ); await runReprintCommandUntilComplete( @@ -853,7 +893,7 @@ export async function runFullPull( `--target-sqlite-path=${ sqlitePath }`, `--new-site-url=${ metadata.localUrl! }`, `--flatten-to=${ metadata.sitePath }`, - '--runtime=playground-cli', + `--runtime=${ reprintRuntime }`, '--start-runtime=none', `--output-dir=${ metadata.runtimeDirectory }`, '--no-adaptive', @@ -868,6 +908,7 @@ export async function runFullPull( { hostPath: metadata.runtimeDirectory, vfsPath: metadata.runtimeDirectory }, ], verboseCommands: verbose, + runtime, } ); logger.reportSuccess( __( 'Site pulled' ) ); @@ -886,6 +927,7 @@ export async function runFullPull( * internal validation, so we leave the state alone in that case. */ export async function downloadSkippedFiles( + runtime: SiteRuntime, metadata: PullSessionMetadata, apiUrl: string, secret: string, @@ -939,6 +981,7 @@ export async function downloadSkippedFiles( { progressLabel: __( 'Remaining files' ), verboseCommands: verbose, + runtime, } ); logger.reportSuccess( __( 'Remaining files downloaded' ) ); diff --git a/apps/cli/commands/tests/pull-reprint.test.ts b/apps/cli/commands/tests/pull-reprint.test.ts index 09eba3f6f5..afc3035bf0 100644 --- a/apps/cli/commands/tests/pull-reprint.test.ts +++ b/apps/cli/commands/tests/pull-reprint.test.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { readAuthToken } from '@studio/common/lib/shared-config'; +import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { enableReprintExporter, rotateReprintSecret } from 'cli/lib/api'; import * as migrationClient from 'cli/lib/pull/migration-client'; @@ -153,6 +154,7 @@ describe( 'CLI: studio pull-reprint helpers', () => { } ); await downloadSkippedFiles( + SITE_RUNTIME_PLAYGROUND, { normalizedUrl: 'https://example.com/', stateDirectory, @@ -235,7 +237,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => { remoteSiteUrl: 'https://example.com', } as never; - await runFullPull( metadata, 'https://example.com/?reprint-api', 'hmac-secret', false ); + await runFullPull( + SITE_RUNTIME_PLAYGROUND, + metadata, + 'https://example.com/?reprint-api', + 'hmac-secret', + false + ); expect( reprint ).toHaveBeenCalledTimes( 1 ); const [ passedState, passedRaw, passedArgs, , passedOptions ] = reprint.mock.calls[ 0 ]; @@ -309,7 +317,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => { remoteSiteUrl: 'https://example.com', } as never; - await runFullPull( metadata, 'https://example.com/?reprint-api', 'hmac-secret', false ); + await runFullPull( + SITE_RUNTIME_PLAYGROUND, + metadata, + 'https://example.com/?reprint-api', + 'hmac-secret', + false + ); const [ , , passedArgs, , passedOptions ] = reprint.mock.calls[ 0 ]; // With no content dir from preflight, the sqlite target falls back to @@ -362,7 +376,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => { }; await expect( - runFullPull( metadata as never, 'https://example.com/?reprint-api', 'hmac-secret', false ) + runFullPull( + SITE_RUNTIME_PLAYGROUND, + metadata as never, + 'https://example.com/?reprint-api', + 'hmac-secret', + false + ) ).rejects.toThrow( 'reprint exited with code 1' ); // Stage must NOT advance to 'pulled' — otherwise a resume would skip diff --git a/apps/cli/lib/native-php/php-process.ts b/apps/cli/lib/native-php/php-process.ts index 7cba7385b6..8cd7f5da01 100644 --- a/apps/cli/lib/native-php/php-process.ts +++ b/apps/cli/lib/native-php/php-process.ts @@ -14,17 +14,23 @@ export const DETACH_FOR_GROUP_KILL = process.platform !== 'win32'; // in-flight children (mid-startup workers, install/blueprint subprocesses) callers don't track. const livePhpProcesses = new Set< ChildProcess >(); -export type SpawnPhpProcessOptions = { +export type BasePhpOptions = { + autoPrependFile?: string; detached?: boolean; disallowRiskyFunctions?: boolean; env?: NodeJS.ProcessEnv; - mode?: 'pipe' | 'capture-stdout'; enableXdebug?: boolean; onlyPathsThatPhpCanAccess?: string[]; phpVersion: NativePhpSupportedVersion; siteFolder?: string; signal?: AbortSignal; }; +type SpawnPhpProcessOptions = BasePhpOptions & { + mode?: 'pipe' | 'no-pipe'; +}; +type RunPhpCommandOptions = BasePhpOptions & { + mode?: 'pipe' | 'no-pipe' | 'capture'; +}; export function spawnPhpProcess( args: string[], @@ -38,6 +44,7 @@ export function spawnPhpProcess( enableXdebug = false, onlyPathsThatPhpCanAccess = [], disallowRiskyFunctions = false, + autoPrependFile, }: SpawnPhpProcessOptions ): ChildProcess { const defaultArgs = getDefaultPhpArgs( @@ -46,7 +53,11 @@ export function spawnPhpProcess( disallowRiskyFunctions, enableXdebug ); - const phpArgs = [ ...defaultArgs, ...args ]; + // Run a PHP file before the main script on every request — used to inject + // reprint's generated runtime.php (constants, SQLite loader, upload proxy) + // into imported sites without modifying their wp-config.php. + const prependArgs = autoPrependFile ? [ '-d', `auto_prepend_file=${ autoPrependFile }` ] : []; + const phpArgs = [ ...defaultArgs, ...prependArgs, ...args ]; const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), phpArgs, { cwd: siteFolder, env: env ? { ...process.env, ...env } : process.env, @@ -62,9 +73,6 @@ export function spawnPhpProcess( if ( mode === 'pipe' ) { phpScriptProcess.stdout?.pipe( process.stdout, { end: false } ); - } - - if ( mode === 'pipe' || mode === 'capture-stdout' ) { phpScriptProcess.stderr?.pipe( process.stderr, { end: false } ); } @@ -150,31 +158,39 @@ export function reapPhpTreeOnInterrupt( child: ChildProcess ): () => void { }; } -type RunPhpCommandOptions = SpawnPhpProcessOptions; - export async function runPhpCommand( args: string[], options: RunPhpCommandOptions -): Promise< { stdout: string } > { - return await new Promise< { stdout: string } >( ( resolve, reject ) => { - const phpScriptProcess = spawnPhpProcess( args, options ); +): Promise< { stdout: string; stderr: string } > { + return await new Promise< { stdout: string; stderr: string } >( ( resolve, reject ) => { + const phpScriptProcess = spawnPhpProcess( args, { + ...options, + mode: options.mode === 'capture' ? 'no-pipe' : options.mode, + } ); + const reportActivity = () => process.send?.( { topic: 'activity' } ); let stdout = ''; - const reportActivity = () => process.send?.( { topic: 'activity' } ); phpScriptProcess.stdout?.on( 'data', ( chunk ) => { reportActivity(); - if ( options.mode === 'capture-stdout' ) { + if ( options.mode === 'capture' ) { stdout += chunk.toString(); } } ); - phpScriptProcess.stderr?.on( 'data', reportActivity ); + + let stderr = ''; + phpScriptProcess.stderr?.on( 'data', ( chunk ) => { + reportActivity(); + if ( options.mode === 'capture' ) { + stderr += chunk.toString(); + } + } ); phpScriptProcess.once( 'error', ( error: Error ) => { reject( error ); } ); phpScriptProcess.once( 'close', ( code ) => { if ( code === 0 ) { - resolve( { stdout } ); + resolve( { stdout, stderr } ); return; } diff --git a/apps/cli/lib/native-php/site-setup.ts b/apps/cli/lib/native-php/site-setup.ts index 0b266a67c1..8b4988eb96 100644 --- a/apps/cli/lib/native-php/site-setup.ts +++ b/apps/cli/lib/native-php/site-setup.ts @@ -89,7 +89,7 @@ echo is_blog_installed() ? '1' : '0'; phpVersion, siteFolder, signal, - mode: 'capture-stdout', + mode: 'capture', } ); stdout = result.stdout; } catch ( error ) { diff --git a/apps/cli/lib/pull/migration-client.ts b/apps/cli/lib/pull/migration-client.ts index 13fc01dc1e..44e9625ae3 100644 --- a/apps/cli/lib/pull/migration-client.ts +++ b/apps/cli/lib/pull/migration-client.ts @@ -1,14 +1,24 @@ /** - * Runs reprint.phar in a PHP WASM child process. - * - * Re-runs the command while they exit with code 2 (partial) until - * they exit with either code 0 (success) or code 1 (error). + * Runs reprint.phar with the PHP runtime selected by `STUDIO_RUNTIME`. * + * reprint.phar is plain PHP, so any PHP runtime can execute it. The + * `playground` runtime runs it inside a PHP WASM child process; the + * `native-php` runtime spawns the bundled native `php` binary directly. + * Either way the command is re-run while it exits with code 2 (partial) + * until it exits with code 0 (success) or code 1 (error). */ import { ChildProcess, fork } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { DEFAULT_PHP_VERSION } from '@studio/common/constants'; +import { + resolveNativePhpVersion, + type NativePhpSupportedVersion, +} from '@studio/common/lib/php-binary-metadata'; +import { SITE_RUNTIME_NATIVE_PHP, SiteRuntime } from '@studio/common/lib/site-runtime'; import { getReprintPharPath } from 'cli/lib/dependency-management/paths'; +import { ensurePhpBinaryAvailable } from 'cli/lib/dependency-management/php-binary'; +import { reapPhpTreeOnInterrupt, spawnPhpProcess } from 'cli/lib/native-php/php-process'; export interface ReprintProcessResult { stdout: string; @@ -26,13 +36,14 @@ function getBundledReprintPhar(): string { } /** - * Runs a reprint.phar command in a PHP WASM child process, automatically - * retrying on partial completion. + * Runs a reprint.phar command with the runtime selected by `STUDIO_RUNTIME`, + * automatically retrying on partial completion. * - * Reprint commands exit with code 2 when they've made progress but need - * another pass (e.g., large file downloads that stream in chunks). This - * function loops until the command exits with 0 (success) or throws on - * exit code 1 (error). + * The `playground` runtime runs reprint inside a PHP WASM child process; the + * `native-php` runtime spawns the bundled native `php` binary. Reprint + * commands exit with code 2 when they've made progress but need another pass + * (e.g., large file downloads that stream in chunks). This function loops + * until the command exits with 0 (success) or throws on exit code 1 (error). */ export async function runReprintCommandUntilComplete( stateDir: string, @@ -43,12 +54,23 @@ export async function runReprintCommandUntilComplete( mounts?: Array< { hostPath: string; vfsPath: string } >; progressLabel?: string; verboseCommands?: boolean; + runtime?: SiteRuntime; } = {} ): Promise< ReprintProcessResult > { const pharPath = getBundledReprintPhar(); const tmpDir = path.join( path.dirname( stateDir ), 'tmp' ); fs.mkdirSync( tmpDir, { recursive: true } ); + // The native runtime spawns the bundled `php` binary, so make sure it's + // downloaded before the first invocation. reprint.phar is PHP-version + // agnostic, so any supported native version works. + const runtime = options.runtime ?? SITE_RUNTIME_NATIVE_PHP; + let nativePhpVersion: NativePhpSupportedVersion | undefined; + if ( runtime === SITE_RUNTIME_NATIVE_PHP ) { + nativePhpVersion = resolveNativePhpVersion( DEFAULT_PHP_VERSION ); + await ensurePhpBinaryAvailable( nativePhpVersion ); + } + const label = options.progressLabel ?? args[ 0 ] ?? 'Working'; const startTime = Date.now(); @@ -58,15 +80,18 @@ export async function runReprintCommandUntilComplete( try { do { - lastResult = await runReprintCommand( - pharPath, - stateDir, - fsRoot, - tmpDir, - args, - options, - progress - ); + lastResult = + runtime === SITE_RUNTIME_NATIVE_PHP + ? await runReprintCommandNative( pharPath, nativePhpVersion!, args, options, progress ) + : await runReprintCommandWasm( + pharPath, + stateDir, + fsRoot, + tmpDir, + args, + options, + progress + ); if ( lastResult.exitCode === 1 ) { const details = [ lastResult.stderr, lastResult.stdout ].filter( Boolean ).join( '\n' ); @@ -94,7 +119,7 @@ export async function runReprintCommandUntilComplete( * or `error` message. SIGINT is forwarded to the child so Ctrl-C * terminates cleanly. */ -async function runReprintCommand( +async function runReprintCommandWasm( pharPath: string, stateDir: string, fsRoot: string, @@ -209,6 +234,96 @@ async function runReprintCommand( } ); } +/** + * Executes a single reprint.phar invocation with the bundled native `php` + * binary. + * + * Unlike the WASM path, native PHP has direct access to the host filesystem, + * so the `--state-dir`, `--fs-root`, and mount paths reprint receives are real + * paths it can read and write without any VFS mounting — the `mounts` option + * is therefore unused here. The CA bundle, memory_limit, and proxy settings + * come from the native `php.ini`/`process.env`, matching how Studio runs the + * native site server. + * + * reprint emits thousands of JSON-L progress lines on stdout and can emit + * megabytes of PHP warnings on stderr, so the child is spawned in `capture` + * mode (neither stream is forwarded to this process's stdout/stderr). We feed + * stdout into the shared progress reporter and keep only the last stdout line + * (the result envelope) plus the stderr tail for diagnostics. + */ +async function runReprintCommandNative( + pharPath: string, + phpVersion: NativePhpSupportedVersion, + args: string[], + options: { verboseCommands?: boolean }, + progress: ProgressReporter +): Promise< ReprintProcessResult > { + if ( options.verboseCommands ) { + console.error( + `[reprint] php reprint.phar ${ args.join( ' ' ) } (native-php ${ phpVersion })` + ); + } + + return await new Promise< ReprintProcessResult >( ( resolve, reject ) => { + const child = spawnPhpProcess( [ pharPath, ...args ], { + phpVersion, + mode: 'no-pipe', + } ); + + let settled = false; + let lastStdoutLine = ''; + let stdoutRemainder = ''; + const STDERR_TAIL_BYTES = 256 * 1024; + let stderrTail = ''; + + const removeInterruptHandlers = reapPhpTreeOnInterrupt( child ); + + const finish = ( fn: () => void ) => { + if ( settled ) { + return; + } + settled = true; + removeInterruptHandlers(); + progress.flush(); + fn(); + }; + + child.stdout?.on( 'data', ( chunk: Buffer ) => { + const text = chunk.toString(); + progress.pushStdoutChunk( text ); + + const combined = stdoutRemainder + text; + const lines = combined.split( '\n' ); + stdoutRemainder = lines.pop() ?? ''; + for ( let i = lines.length - 1; i >= 0; i-- ) { + if ( lines[ i ].trim() ) { + lastStdoutLine = lines[ i ]; + break; + } + } + } ); + + child.stderr?.on( 'data', ( chunk: Buffer ) => { + stderrTail += chunk.toString(); + if ( stderrTail.length > STDERR_TAIL_BYTES ) { + stderrTail = stderrTail.slice( stderrTail.length - STDERR_TAIL_BYTES ); + } + } ); + + child.once( 'error', ( err ) => finish( () => reject( err ) ) ); + + child.once( 'close', ( code ) => + finish( () => + resolve( { + stdout: stdoutRemainder.trim() || lastStdoutLine, + stderr: stderrTail, + exitCode: code ?? 1, + } ) + ) + ); + } ); +} + // reprint writes JSON-L to stdout: { phase, files_done, bytes_received, … }. // The progress reporter splits the stream into lines, parses each into a // snapshot, and formats a one-line spinner update like diff --git a/apps/cli/lib/pull/runtime-start-options.ts b/apps/cli/lib/pull/runtime-start-options.ts index 58e903abbd..14616fac7a 100644 --- a/apps/cli/lib/pull/runtime-start-options.ts +++ b/apps/cli/lib/pull/runtime-start-options.ts @@ -174,6 +174,23 @@ export async function ensureImportedSiteSqliteReady( return sqlitePath; } +export function loadImportedRuntimeStartOptionsNative( + technicalSiteDirectory: string, + runtimeDirectory: string +): StartServerOptions { + const runtimePhpPath = path.join( runtimeDirectory, 'runtime.php' ); + if ( ! fs.existsSync( runtimePhpPath ) ) { + throw new LoggerError( + `Missing runtime.php at ${ runtimePhpPath }. Re-run \`studio pull-reprint\` to regenerate the runtime configuration.` + ); + } + + return { + autoPrependFile: runtimePhpPath, + openBasedirAllowList: [ technicalSiteDirectory ], + }; +} + export function loadRuntimeBlueprint( runtimeBlueprintPath: string ): Blueprint { if ( ! fs.existsSync( runtimeBlueprintPath ) ) { throw new LoggerError( `Runtime Blueprint not found: ${ runtimeBlueprintPath }` ); diff --git a/apps/cli/lib/types/wordpress-server-ipc.ts b/apps/cli/lib/types/wordpress-server-ipc.ts index 445fa273ee..6d1b0692f5 100644 --- a/apps/cli/lib/types/wordpress-server-ipc.ts +++ b/apps/cli/lib/types/wordpress-server-ipc.ts @@ -1,5 +1,6 @@ import { siteFileAccessSchema } from '@studio/common/lib/site-file-access'; import { z } from 'zod'; +import type { WordPressInstallMode } from '@wp-playground/wordpress'; // Zod schemas for validating IPC messages from wordpress-server-manager const mountSchema = z.object( { @@ -7,14 +8,14 @@ const mountSchema = z.object( { vfsPath: z.string(), } ); -const wordpressInstallModeSchema = z.enum( [ +const wordpressInstallModeSchema: z.ZodType< WordPressInstallMode > = z.enum( [ 'download-and-install', 'install-from-existing-files', 'install-from-existing-files-if-needed', 'do-not-attempt-installing', ] ); -const serverConfig = z.object( { +export const serverConfigSchema = z.object( { siteId: z.string(), sitePath: z.string(), port: z.number(), @@ -42,9 +43,11 @@ const serverConfig = z.object( { wordpressInstallMode: wordpressInstallModeSchema.optional(), skipSqliteSetup: z.boolean().optional(), useExactMountLayout: z.boolean().optional(), + autoPrependFile: z.string().optional(), + openBasedirAllowList: z.array( z.string() ).optional(), } ); -export type ServerConfig = z.infer< typeof serverConfig >; +export type ServerConfig = z.infer< typeof serverConfigSchema >; const managerMessageAbort = z.object( { topic: z.literal( 'abort' ), @@ -54,14 +57,14 @@ const managerMessageAbort = z.object( { const managerMessageStartServer = z.object( { topic: z.literal( 'start-server' ), data: z.object( { - config: serverConfig, + config: serverConfigSchema, } ), } ); const managerMessageRunBlueprint = z.object( { topic: z.literal( 'run-blueprint' ), data: z.object( { - config: serverConfig, + config: serverConfigSchema, } ), } ); diff --git a/apps/cli/lib/wordpress-server-manager.ts b/apps/cli/lib/wordpress-server-manager.ts index 4ee59af760..81909a0011 100644 --- a/apps/cli/lib/wordpress-server-manager.ts +++ b/apps/cli/lib/wordpress-server-manager.ts @@ -35,9 +35,12 @@ import { 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 { + ServerConfig, + ManagerMessagePayload, + serverConfigSchema, +} from 'cli/lib/types/wordpress-server-ipc'; import { Logger } from 'cli/logger'; -import type { WordPressInstallMode } from '@wp-playground/wordpress'; export const SITE_PROCESS_PREFIX = 'studio-site-'; @@ -78,24 +81,24 @@ function canReuseProcessForWpCli( processDescription: ProcessDescription ): bool return processDescription.runtime === SITE_RUNTIME_PLAYGROUND; } -/** - * Start a WordPress server for a site via process manager daemon - * 1. Start the process (via the process manager daemon) - * 2. Wait for 'ready' message - * 3. Send 'start-server' message with config - * 4. Wait for response before resolving - */ -export interface StartServerOptions { - wpVersion?: string; - blueprint?: unknown; - blueprintUri?: string; - siteLanguage?: string; - mounts?: ServerConfig[ 'mounts' ]; - mountsBeforeInstall?: ServerConfig[ 'mountsBeforeInstall' ]; - wordpressInstallMode?: WordPressInstallMode; - skipSqliteSetup?: boolean; - useExactMountLayout?: boolean; -} +const startServerOptionsSchema = serverConfigSchema + .pick( { + wpVersion: true, + siteLanguage: true, + mounts: true, + mountsBeforeInstall: true, + wordpressInstallMode: true, + skipSqliteSetup: true, + useExactMountLayout: true, + autoPrependFile: true, + openBasedirAllowList: true, + } ) + .extend( { + blueprint: z.unknown().optional(), + blueprintUri: z.string().optional(), + } ); + +export type StartServerOptions = z.infer< typeof startServerOptionsSchema >; function buildServerConfig( site: SiteData, @@ -169,6 +172,14 @@ function buildServerConfig( serverConfig.useExactMountLayout = true; } + if ( options?.autoPrependFile ) { + serverConfig.autoPrependFile = options.autoPrependFile; + } + + if ( options?.openBasedirAllowList ) { + serverConfig.openBasedirAllowList = options.openBasedirAllowList; + } + if ( site.fileAccess ) { serverConfig.fileAccess = site.fileAccess; } @@ -233,6 +244,13 @@ function dropStaleReprintStateMounts( options: StartServerOptions ): StartServer }; } +/** + * Start a WordPress server for a site via process manager daemon + * 1. Start the process (via the process manager daemon) + * 2. Wait for 'ready' message + * 3. Send 'start-server' message with config + * 4. Wait for response before resolving + */ export async function startWordPressServer( site: SiteData, logger: Logger< string >, @@ -248,7 +266,9 @@ export async function startWordPressServer( 'start-options.json' ); if ( fs.existsSync( optionsPath ) ) { - options = JSON.parse( fs.readFileSync( optionsPath, 'utf-8' ) ) as StartServerOptions; + options = startServerOptionsSchema.parse( + JSON.parse( fs.readFileSync( optionsPath, 'utf-8' ) ) + ); options = dropStaleReprintStateMounts( options ); } } diff --git a/apps/cli/php-server-child.ts b/apps/cli/php-server-child.ts index 5bdf91e44d..1e7963b5a2 100644 --- a/apps/cli/php-server-child.ts +++ b/apps/cli/php-server-child.ts @@ -449,33 +449,48 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise startupAbortController = new AbortController(); const stopSignal = AbortSignal.any( [ signal, startupAbortController.signal ] ); + // Sites imported by `studio pull-reprint` arrive with WordPress already + // installed and a database already in place; reprint's auto_prepend_file + // owns their constants and SQLite wiring. So we skip wp-config rewriting, + // the WordPress installer, and Blueprint execution — running any of them + // against the imported database would be wrong — and just write Studio's + // mu-plugins before starting the workers. + const isImportedSite = Boolean( config.autoPrependFile ); + try { stopSignal.throwIfAborted(); - await ensureWpConfig( - config.sitePath, - phpVersion, - stopSignal, - WP_CONFIG_TRANSFORMER_PATH, - config - ); - stopSignal.throwIfAborted(); + + if ( ! isImportedSite ) { + await ensureWpConfig( + config.sitePath, + phpVersion, + stopSignal, + WP_CONFIG_TRANSFORMER_PATH, + config + ); + stopSignal.throwIfAborted(); + } + const muPluginsPath = await writeStudioMuPluginsForNativePhpRuntime( config.sitePath, config.isWpAutoUpdating ); stopSignal.throwIfAborted(); - await installWordPress( - config, - phpVersion, - stopSignal, - SET_DEFAULT_PERMALINKS_PATH, - logToConsole - ); - stopSignal.throwIfAborted(); - if ( config.blueprint ) { - await runBlueprint( config, config.blueprint, phpVersion, stopSignal ); + if ( ! isImportedSite ) { + await installWordPress( + config, + phpVersion, + stopSignal, + SET_DEFAULT_PERMALINKS_PATH, + logToConsole + ); stopSignal.throwIfAborted(); + + if ( config.blueprint ) { + await runBlueprint( config, config.blueprint, phpVersion, stopSignal ); + stopSignal.throwIfAborted(); + } } // With "all files" access the allowlist stays empty, which disables @@ -495,6 +510,7 @@ async function startServer( config: ServerConfig, signal: AbortSignal ): Promise currentOpenBasedirAllowlist.add( muPluginsPath ); currentOpenBasedirAllowlist.add( os.tmpdir() ); symlinkAllowlistEntries.forEach( ( entry ) => currentOpenBasedirAllowlist.add( entry ) ); + config.openBasedirAllowList?.forEach( ( entry ) => currentOpenBasedirAllowlist.add( entry ) ); } runningConfig = config; @@ -563,6 +579,7 @@ async function doStartServer( onlyPathsThatPhpCanAccess: Array.from( openBasedirAllowlist ), disallowRiskyFunctions: isFileAccessRestricted( config ), enableXdebug: config.enableXdebug, + autoPrependFile: config.autoPrependFile, } ); spawnedChildren.push( serverChild ); diff --git a/apps/cli/php/router.php b/apps/cli/php/router.php index c931721dbe..d048e84f58 100644 --- a/apps/cli/php/router.php +++ b/apps/cli/php/router.php @@ -100,9 +100,20 @@ return true; } -// Existing file (static asset or PHP script): let the built-in server handle it. +// Existing file: require PHP scripts ourselves, serve static assets via the +// server. Returning false for PHP would run it as a second script, firing +// auto_prepend_file (runtime.php) twice and redeclaring its classes. if ( '/' !== $path && is_file( $file ) ) { - return false; + if ( 'php' !== strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ) ) { + return false; + } + + $_SERVER['SCRIPT_NAME'] = $path; + $_SERVER['PHP_SELF'] = $path; + $_SERVER['SCRIPT_FILENAME'] = $file; + chdir( dirname( $file ) ); + require $file; + return true; } // Existing directory with an index.php: dispatch to that script. diff --git a/apps/cli/playground-server-child.ts b/apps/cli/playground-server-child.ts index 9b196c4336..9866fa7d64 100644 --- a/apps/cli/playground-server-child.ts +++ b/apps/cli/playground-server-child.ts @@ -222,28 +222,6 @@ async function getBaseRunCLIArgs( command: RunCLIArgs[ 'command' ], config: ServerConfig ): Promise< RunCLIArgs > { - // For sites imported via `studio pull-reprint`, the pull command - // persists the computed start options to start-options.json so the - // daemon doesn't need to recompute them (which would spin up PHP - // WASM to extract runtime.php constants from the imported site). - if ( ! config.useExactMountLayout && config.blueprint?.uri ) { - try { - const optionsPath = path.join( path.dirname( config.blueprint.uri ), 'start-options.json' ); - if ( fs.existsSync( optionsPath ) ) { - const saved = JSON.parse( fs.readFileSync( optionsPath, 'utf-8' ) ); - if ( saved.useExactMountLayout ) { - config.mountsBeforeInstall = saved.mountsBeforeInstall; - config.mounts = saved.mounts; - config.wordpressInstallMode = saved.wordpressInstallMode ?? config.wordpressInstallMode; - config.useExactMountLayout = true; - logToConsole( `Loaded persisted start options from ${ optionsPath } before startup` ); - } - } - } catch { - // Ignore missing or invalid start options and continue with the provided config. - } - } - const wordpressInstallMode = config.wordpressInstallMode ?? ( await getWordPressInstallMode( config.sitePath ) ); const useExactMountLayout = config.useExactMountLayout ?? false; diff --git a/scripts/download-wp-server-files.ts b/scripts/download-wp-server-files.ts index 829ab57bc2..fa9a8ec7fb 100644 --- a/scripts/download-wp-server-files.ts +++ b/scripts/download-wp-server-files.ts @@ -77,9 +77,6 @@ type FileToDownload = { destinationPath?: string; }; -const REPRINT_VERSION = 'v0.8.2'; -const REPRINT_PHAR_URL = `https://github.com/WordPress/reprint/releases/download/${ REPRINT_VERSION }/reprint.phar`; - const FILES_TO_DOWNLOAD: FileToDownload[] = [ { name: 'wordpress', @@ -121,8 +118,15 @@ const FILES_TO_DOWNLOAD: FileToDownload[] = [ }, { name: 'reprint', - description: `reprint.phar (${ REPRINT_VERSION })`, - getUrl: () => REPRINT_PHAR_URL, + description: `reprint.phar`, + getUrl: async () => { + const release = await fetchLatestGithubRelease( 'WordPress/reprint' ); + const asset = release.assets.find( ( a ) => a.name === 'reprint.phar' ); + if ( ! asset ) { + throw new Error( `No asset found in latest reprint release ${ release.tag_name }` ); + } + return asset.browser_download_url; + }, destinationPath: path.join( WP_SERVER_FILES_PATH, 'reprint' ), }, ];