Skip to content

Commit de57937

Browse files
Make the pull-reprint command work with native PHP (#3881)
## Related issues <!-- Link a related issue to this PR. If the PR does not immediately resolve the issue, for example, it requires a separate deployment to production, avoid using the "Fixes" keyword and use "Related to" instead. --> - Fixes STU-1814 ## How AI was used in this PR <!-- Help reviewers understand what to look for and verify that you've reviewed the code yourself. --> I used Claude to help me with the initial implementation. Then I had to slow down to understand the `pull-reprint` architecture and the changes that Claude made. I went through a few rounds of iterations by hand and with Claude before reaching the final state. ## Proposed Changes <!-- Explain the intent of this PR: - What problem does it solve, or what need does it address? - How does it affect the user (new capability, fix, performance, accessibility, etc.)? - Note any user-visible behavior changes or trade-offs. Focus on the "why" and the user impact. Avoid listing modified files or describing implementation mechanics — the diff already shows that. --> The `pull-reprint` command comes with more assumptions about the PHP runtime than most other Studio features do. The prime example is how it uses PHP-WASM-based parsing to extract constants from `runtime.php`. Studio now has two PHP runtimes, and native PHP is slated to become the new default shortly. This PR makes the `pull-reprint` command work with native PHP. This implementation does not cover all bases, but it works when pulling a typical WordPress.com site. We'll keep iterating to refine the experience. ## Testing Instructions <!-- Add as many details as possible to help others reproduce the issue and test the fix. "Before / After" screenshots can also be very helpful when the change is visual. --> 1. Run `npm run cli:build` 2. Run `STUDIO_ENABLE_PULL_REPRINT=1 STUDIO_RUNTIME=native-php node apps/cli/dist/cli/main.mjs pull-reprint` 3. Ensure that you can successfully complete a pull, that the site starts and works ## Pre-merge Checklist <!-- Complete applicable items on this checklist **before** merging into trunk. Inapplicable items can be left unchecked. Both the PR author and reviewer are responsible for ensuring the checklist is completed. --> - [ ] Have you checked for TypeScript, React or other console errors?
1 parent 9250419 commit de57937

12 files changed

Lines changed: 368 additions & 124 deletions

apps/cli/commands/pull-reprint.ts

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import { generateNumberedName } from '@studio/common/lib/generate-site-name';
1717
import { encodePassword } from '@studio/common/lib/passwords';
1818
import { portFinder } from '@studio/common/lib/port-finder';
1919
import { readAuthToken, type StoredAuthToken } from '@studio/common/lib/shared-config';
20-
import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
20+
import {
21+
SITE_RUNTIME_NATIVE_PHP,
22+
SITE_RUNTIME_PLAYGROUND,
23+
SiteRuntime,
24+
getSiteRuntime,
25+
} from '@studio/common/lib/site-runtime';
2126
import { sortSites } from '@studio/common/lib/sort-sites';
2227
import { PullReprintCommandLoggerAction as LoggerAction } from '@studio/common/logger-actions';
2328
import { __, sprintf } from '@wordpress/i18n';
@@ -55,13 +60,18 @@ import {
5560
import {
5661
ensureImportedSiteSqliteReady,
5762
loadImportedRuntimeStartOptions,
63+
loadImportedRuntimeStartOptionsNative,
5864
} from 'cli/lib/pull/runtime-start-options';
5965
import { getDefaultSitePath } from 'cli/lib/site-paths';
6066
import { buildAutoLoginUrl } from 'cli/lib/site-utils';
6167
import { fetchSyncableSites } from 'cli/lib/sync-api';
6268
import { pickSyncSite } from 'cli/lib/sync-site-picker';
6369
import { getPrettyPath } from 'cli/lib/utils';
64-
import { getProcessName, startWordPressServer } from 'cli/lib/wordpress-server-manager';
70+
import {
71+
startWordPressServer,
72+
StartServerOptions,
73+
getProcessName,
74+
} from 'cli/lib/wordpress-server-manager';
6575
import { Logger, LoggerError } from 'cli/logger';
6676
import { StudioArgv } from 'cli/types';
6777
import type { SyncSite } from '@studio/common/types/sync';
@@ -390,7 +400,13 @@ export async function runCommand(
390400

391401
let preflight;
392402
try {
393-
preflight = await runPreflight( studioMetadata, apiUrl, secret, verbose );
403+
preflight = await runPreflight(
404+
SITE_RUNTIME_NATIVE_PHP,
405+
studioMetadata,
406+
apiUrl,
407+
secret,
408+
verbose
409+
);
394410
} catch ( preflightError ) {
395411
// Preflight against ?reprint-api can fail for two reasons we can
396412
// recover from on WP.com: the stored secret expired, or the
@@ -434,7 +450,13 @@ export async function runCommand(
434450
sourceSite.wpComToken.accessToken,
435451
verbose
436452
);
437-
preflight = await runPreflight( studioMetadata, apiUrl, secret, verbose );
453+
preflight = await runPreflight(
454+
SITE_RUNTIME_NATIVE_PHP,
455+
studioMetadata,
456+
apiUrl,
457+
secret,
458+
verbose
459+
);
438460
}
439461
studioMetadata.remoteSiteUrl = preflight.siteurl || studioMetadata.normalizedUrl;
440462
studioMetadata.tablePrefix = preflight.table_prefix || undefined;
@@ -452,7 +474,7 @@ export async function runCommand(
452474
// a delta re-pull, resets its own sub-command state via
453475
// prepare_repull().
454476
if ( ! hasPullCompletedStage( studioMetadata, 'pulled' ) ) {
455-
await runFullPull( studioMetadata, apiUrl, secret, verbose );
477+
await runFullPull( SITE_RUNTIME_NATIVE_PHP, studioMetadata, apiUrl, secret, verbose );
456478
}
457479

458480
let createdSiteRecord = false;
@@ -494,10 +516,18 @@ export async function runCommand(
494516
}
495517

496518
if ( ! hasPullCompletedStage( studioMetadata, 'site-started' ) ) {
497-
await ensureImportedSiteSqliteReady( studioMetadata.runtimeBlueprintPath );
498-
const runtimeStartOptions = await loadImportedRuntimeStartOptions(
499-
studioMetadata.runtimeBlueprintPath
500-
);
519+
let runtimeStartOptions: StartServerOptions;
520+
if ( getSiteRuntime( site ) === SITE_RUNTIME_NATIVE_PHP ) {
521+
runtimeStartOptions = loadImportedRuntimeStartOptionsNative(
522+
studioMetadata.technicalSiteDirectory,
523+
studioMetadata.runtimeDirectory
524+
);
525+
} else {
526+
await ensureImportedSiteSqliteReady( studioMetadata.runtimeBlueprintPath );
527+
runtimeStartOptions = await loadImportedRuntimeStartOptions(
528+
studioMetadata.runtimeBlueprintPath
529+
);
530+
}
501531

502532
// Persist the computed start options so `studio site start` and
503533
// the daemon can re-read them without recomputing (which spins
@@ -566,7 +596,13 @@ export async function runCommand(
566596

567597
if ( ! hasPullCompletedStage( studioMetadata, 'completed' ) ) {
568598
if ( hasSkippedFiles( studioMetadata.stateDirectory ) ) {
569-
await downloadSkippedFiles( studioMetadata, apiUrl, secret, verbose );
599+
await downloadSkippedFiles(
600+
getSiteRuntime( site ),
601+
studioMetadata,
602+
apiUrl,
603+
secret,
604+
verbose
605+
);
570606
}
571607

572608
recordCompletedStage( studioMetadata, 'completed' );
@@ -606,6 +642,7 @@ export async function runCommand(
606642
* metadata before the download stages begin.
607643
*/
608644
async function runPreflight(
645+
runtime: SiteRuntime,
609646
sessionMetadata: PullSessionMetadata,
610647
sourceSiteApiUrl: string,
611648
sourceSiteSecret: string,
@@ -640,6 +677,7 @@ async function runPreflight(
640677
undefined,
641678
{
642679
verboseCommands: verbose,
680+
runtime,
643681
}
644682
);
645683
} catch ( preflightError ) {
@@ -810,7 +848,7 @@ function readPullMetadata( metadataPath: string ): PullSessionMetadata | null {
810848
/**
811849
* Run reprint's composite `pull` command: the whole site-clone
812850
* pipeline (preflight → files-pull → db-pull → db-apply →
813-
* flat-docroot → apply-runtime) in a single PHP-WASM fork, with
851+
* flat-docroot → apply-runtime) in a single child process, with
814852
* reprint owning the stage ordering and, when the prior pull already
815853
* completed, resetting its own sub-command state for a delta re-pull
816854
* via prepare_repull().
@@ -830,6 +868,7 @@ function readPullMetadata( metadataPath: string ): PullSessionMetadata | null {
830868
* Advances the pull stage to 'pulled'.
831869
*/
832870
export async function runFullPull(
871+
runtime: SiteRuntime,
833872
metadata: PullSessionMetadata,
834873
apiUrl: string,
835874
secret: string,
@@ -839,6 +878,7 @@ export async function runFullPull(
839878
const sqlitePath = contentDir
840879
? `${ metadata.rawDirectory }${ contentDir }/database/.ht.sqlite`
841880
: `${ metadata.sitePath }/wp-content/database/.ht.sqlite`;
881+
const reprintRuntime = runtime === SITE_RUNTIME_NATIVE_PHP ? 'nginx-fpm' : 'playground-cli';
842882

843883
logger.reportStart( LoggerAction.DOWNLOAD_FILES, __( 'Pulling site…' ) );
844884
await runReprintCommandUntilComplete(
@@ -853,7 +893,7 @@ export async function runFullPull(
853893
`--target-sqlite-path=${ sqlitePath }`,
854894
`--new-site-url=${ metadata.localUrl! }`,
855895
`--flatten-to=${ metadata.sitePath }`,
856-
'--runtime=playground-cli',
896+
`--runtime=${ reprintRuntime }`,
857897
'--start-runtime=none',
858898
`--output-dir=${ metadata.runtimeDirectory }`,
859899
'--no-adaptive',
@@ -868,6 +908,7 @@ export async function runFullPull(
868908
{ hostPath: metadata.runtimeDirectory, vfsPath: metadata.runtimeDirectory },
869909
],
870910
verboseCommands: verbose,
911+
runtime,
871912
}
872913
);
873914
logger.reportSuccess( __( 'Site pulled' ) );
@@ -886,6 +927,7 @@ export async function runFullPull(
886927
* internal validation, so we leave the state alone in that case.
887928
*/
888929
export async function downloadSkippedFiles(
930+
runtime: SiteRuntime,
889931
metadata: PullSessionMetadata,
890932
apiUrl: string,
891933
secret: string,
@@ -939,6 +981,7 @@ export async function downloadSkippedFiles(
939981
{
940982
progressLabel: __( 'Remaining files' ),
941983
verboseCommands: verbose,
984+
runtime,
942985
}
943986
);
944987
logger.reportSuccess( __( 'Remaining files downloaded' ) );

apps/cli/commands/tests/pull-reprint.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import os from 'node:os';
33
import path from 'node:path';
44
import { readAuthToken } from '@studio/common/lib/shared-config';
5+
import { SITE_RUNTIME_PLAYGROUND } from '@studio/common/lib/site-runtime';
56
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
67
import { enableReprintExporter, rotateReprintSecret } from 'cli/lib/api';
78
import * as migrationClient from 'cli/lib/pull/migration-client';
@@ -153,6 +154,7 @@ describe( 'CLI: studio pull-reprint helpers', () => {
153154
} );
154155

155156
await downloadSkippedFiles(
157+
SITE_RUNTIME_PLAYGROUND,
156158
{
157159
normalizedUrl: 'https://example.com/',
158160
stateDirectory,
@@ -235,7 +237,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => {
235237
remoteSiteUrl: 'https://example.com',
236238
} as never;
237239

238-
await runFullPull( metadata, 'https://example.com/?reprint-api', 'hmac-secret', false );
240+
await runFullPull(
241+
SITE_RUNTIME_PLAYGROUND,
242+
metadata,
243+
'https://example.com/?reprint-api',
244+
'hmac-secret',
245+
false
246+
);
239247

240248
expect( reprint ).toHaveBeenCalledTimes( 1 );
241249
const [ passedState, passedRaw, passedArgs, , passedOptions ] = reprint.mock.calls[ 0 ];
@@ -309,7 +317,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => {
309317
remoteSiteUrl: 'https://example.com',
310318
} as never;
311319

312-
await runFullPull( metadata, 'https://example.com/?reprint-api', 'hmac-secret', false );
320+
await runFullPull(
321+
SITE_RUNTIME_PLAYGROUND,
322+
metadata,
323+
'https://example.com/?reprint-api',
324+
'hmac-secret',
325+
false
326+
);
313327

314328
const [ , , passedArgs, , passedOptions ] = reprint.mock.calls[ 0 ];
315329
// With no content dir from preflight, the sqlite target falls back to
@@ -362,7 +376,13 @@ describe( 'CLI: studio pull-reprint single pull phase', () => {
362376
};
363377

364378
await expect(
365-
runFullPull( metadata as never, 'https://example.com/?reprint-api', 'hmac-secret', false )
379+
runFullPull(
380+
SITE_RUNTIME_PLAYGROUND,
381+
metadata as never,
382+
'https://example.com/?reprint-api',
383+
'hmac-secret',
384+
false
385+
)
366386
).rejects.toThrow( 'reprint exited with code 1' );
367387

368388
// Stage must NOT advance to 'pulled' — otherwise a resume would skip

apps/cli/lib/native-php/php-process.ts

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,23 @@ export const DETACH_FOR_GROUP_KILL = process.platform !== 'win32';
1414
// in-flight children (mid-startup workers, install/blueprint subprocesses) callers don't track.
1515
const livePhpProcesses = new Set< ChildProcess >();
1616

17-
export type SpawnPhpProcessOptions = {
17+
export type BasePhpOptions = {
18+
autoPrependFile?: string;
1819
detached?: boolean;
1920
disallowRiskyFunctions?: boolean;
2021
env?: NodeJS.ProcessEnv;
21-
mode?: 'pipe' | 'capture-stdout';
2222
enableXdebug?: boolean;
2323
onlyPathsThatPhpCanAccess?: string[];
2424
phpVersion: NativePhpSupportedVersion;
2525
siteFolder?: string;
2626
signal?: AbortSignal;
2727
};
28+
type SpawnPhpProcessOptions = BasePhpOptions & {
29+
mode?: 'pipe' | 'no-pipe';
30+
};
31+
type RunPhpCommandOptions = BasePhpOptions & {
32+
mode?: 'pipe' | 'no-pipe' | 'capture';
33+
};
2834

2935
export function spawnPhpProcess(
3036
args: string[],
@@ -38,6 +44,7 @@ export function spawnPhpProcess(
3844
enableXdebug = false,
3945
onlyPathsThatPhpCanAccess = [],
4046
disallowRiskyFunctions = false,
47+
autoPrependFile,
4148
}: SpawnPhpProcessOptions
4249
): ChildProcess {
4350
const defaultArgs = getDefaultPhpArgs(
@@ -46,7 +53,11 @@ export function spawnPhpProcess(
4653
disallowRiskyFunctions,
4754
enableXdebug
4855
);
49-
const phpArgs = [ ...defaultArgs, ...args ];
56+
// Run a PHP file before the main script on every request — used to inject
57+
// reprint's generated runtime.php (constants, SQLite loader, upload proxy)
58+
// into imported sites without modifying their wp-config.php.
59+
const prependArgs = autoPrependFile ? [ '-d', `auto_prepend_file=${ autoPrependFile }` ] : [];
60+
const phpArgs = [ ...defaultArgs, ...prependArgs, ...args ];
5061
const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), phpArgs, {
5162
cwd: siteFolder,
5263
env: env ? { ...process.env, ...env } : process.env,
@@ -62,9 +73,6 @@ export function spawnPhpProcess(
6273

6374
if ( mode === 'pipe' ) {
6475
phpScriptProcess.stdout?.pipe( process.stdout, { end: false } );
65-
}
66-
67-
if ( mode === 'pipe' || mode === 'capture-stdout' ) {
6876
phpScriptProcess.stderr?.pipe( process.stderr, { end: false } );
6977
}
7078

@@ -150,31 +158,39 @@ export function reapPhpTreeOnInterrupt( child: ChildProcess ): () => void {
150158
};
151159
}
152160

153-
type RunPhpCommandOptions = SpawnPhpProcessOptions;
154-
155161
export async function runPhpCommand(
156162
args: string[],
157163
options: RunPhpCommandOptions
158-
): Promise< { stdout: string } > {
159-
return await new Promise< { stdout: string } >( ( resolve, reject ) => {
160-
const phpScriptProcess = spawnPhpProcess( args, options );
164+
): Promise< { stdout: string; stderr: string } > {
165+
return await new Promise< { stdout: string; stderr: string } >( ( resolve, reject ) => {
166+
const phpScriptProcess = spawnPhpProcess( args, {
167+
...options,
168+
mode: options.mode === 'capture' ? 'no-pipe' : options.mode,
169+
} );
170+
const reportActivity = () => process.send?.( { topic: 'activity' } );
161171

162172
let stdout = '';
163-
const reportActivity = () => process.send?.( { topic: 'activity' } );
164173
phpScriptProcess.stdout?.on( 'data', ( chunk ) => {
165174
reportActivity();
166-
if ( options.mode === 'capture-stdout' ) {
175+
if ( options.mode === 'capture' ) {
167176
stdout += chunk.toString();
168177
}
169178
} );
170-
phpScriptProcess.stderr?.on( 'data', reportActivity );
179+
180+
let stderr = '';
181+
phpScriptProcess.stderr?.on( 'data', ( chunk ) => {
182+
reportActivity();
183+
if ( options.mode === 'capture' ) {
184+
stderr += chunk.toString();
185+
}
186+
} );
171187

172188
phpScriptProcess.once( 'error', ( error: Error ) => {
173189
reject( error );
174190
} );
175191
phpScriptProcess.once( 'close', ( code ) => {
176192
if ( code === 0 ) {
177-
resolve( { stdout } );
193+
resolve( { stdout, stderr } );
178194
return;
179195
}
180196

apps/cli/lib/native-php/site-setup.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ echo is_blog_installed() ? '1' : '0';
8989
phpVersion,
9090
siteFolder,
9191
signal,
92-
mode: 'capture-stdout',
92+
mode: 'capture',
9393
} );
9494
stdout = result.stdout;
9595
} catch ( error ) {

0 commit comments

Comments
 (0)