Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions apps/cli/commands/wp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ enum Mode {
SITE = 'site',
}

/**
* Drain `process.stdin` to a Buffer when this Studio CLI invocation
* was piped into (e.g. `echo foo | studio wp eval ...`). Returns
* `undefined` when stdin is a TTY (interactive shell) so we never
* block reading from a terminal.
*
* Reads bytes synchronously into memory before the WP-CLI command
* runs. The buffer is then forwarded to the PHP runtime via the
* `stdin` option on `php.cli()` (Playground PR adding that option
* is the upstream half of this fix). Without this draining step,
* host stdin would never reach PHP — the daemon path runs in a
* separate process from `studio wp` and the in-proc path runs in a
* worker thread, neither of which has the user's pipe attached.
*/
async function drainHostStdin(): Promise< Buffer | undefined > {
if ( process.stdin.isTTY ) {
return undefined;
}
const chunks: Buffer[] = [];
for await ( const chunk of process.stdin ) {
chunks.push( chunk as Buffer );
}
return chunks.length > 0 ? Buffer.concat( chunks ) : undefined;
}

export async function runCommand(
mode: Mode,
siteFolder: string,
Expand All @@ -48,7 +73,8 @@ export async function runCommand(
): 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 );
const stdin = await drainHostStdin();
await using command = await runGlobalWpCliCommand( args, { stdin } );

await pipePHPResponse( command.response );
process.exitCode = await command.response.exitCode;
Expand All @@ -59,6 +85,11 @@ export async function runCommand(
const site = await getSiteByFolder( siteFolder );
const phpVersion = validatePhpVersion( options.phpVersion ?? site.phpVersion );

// Drain piped stdin (if any) up-front so we can forward it to whichever
// WP-CLI execution path we end up on. Both the daemon IPC and in-proc
// paths route the bytes through `php.cli({ stdin })`.
const stdin = await drainHostStdin();

// 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;
Expand All @@ -71,7 +102,7 @@ export async function runCommand(
await connectToDaemon();

if ( await isServerRunning( site.id ) ) {
const result = await sendWpCliCommand( site.id, args );
const result = await sendWpCliCommand( site.id, args, stdin );
process.stdout.write( result.stdout );
process.stderr.write( result.stderr );
process.exit( result.exitCode );
Expand All @@ -85,7 +116,9 @@ export async function runCommand(
process.on( 'SIGTERM', () => process.exit( 1 ) );

// …If not, run the command in a new PHP-WASM instance
await using command = await runWpCliCommand( siteFolder, phpVersion, args );
await using command = await runWpCliCommand( siteFolder, phpVersion, args, {
stdin,
} );

await pipePHPResponse( command.response );
process.exitCode = await command.response.exitCode;
Expand Down
18 changes: 14 additions & 4 deletions apps/cli/lib/run-wp-cli-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ interface DisposableWpCliResponse extends Disposable {
export async function runWpCliCommand(
siteFolder: string,
phpVersion: SupportedPHPVersion,
args: string[]
args: string[],
options: { stdin?: Buffer | Uint8Array } = {}
): Promise< DisposableWpCliResponse > {
const id = await loadNodeRuntime( phpVersion, {
followSymlinks: true,
Expand Down Expand Up @@ -100,7 +101,10 @@ export async function runWpCliCommand(

await setupPlatformLevelMuPlugins( php );

const response = await php.cli( [ 'php', '/tmp/wp-cli.phar', '--path=/wordpress', ...args ] );
const response = await php.cli(
[ 'php', '/tmp/wp-cli.phar', '--path=/wordpress', ...args ],
options.stdin ? { stdin: options.stdin } : {}
);

return {
response,
Expand All @@ -118,7 +122,10 @@ export async function runWpCliCommand(
* 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[] ): Promise< DisposableWpCliResponse > {
export async function runGlobalWpCliCommand(
args: string[],
options: { stdin?: Buffer | Uint8Array } = {}
): Promise< DisposableWpCliResponse > {
const id = await loadNodeRuntime( LatestSupportedPHPVersion, {
followSymlinks: true,
withRedis: false,
Expand All @@ -143,7 +150,10 @@ export async function runGlobalWpCliCommand( args: string[] ): Promise< Disposab

await php.mount( '/tmp/wp-cli.phar', createNodeFsMountHandler( getWpCliPharPath() ) );

const response = await php.cli( [ 'php', '/tmp/wp-cli.phar', ...args ] );
const response = await php.cli(
[ 'php', '/tmp/wp-cli.phar', ...args ],
options.stdin ? { stdin: options.stdin } : {}
);

return {
response,
Expand Down
8 changes: 8 additions & 0 deletions apps/cli/lib/types/wordpress-server-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ const managerMessageWpCliCommand = z.object( {
topic: z.literal( 'wp-cli-command' ),
data: z.object( {
args: z.array( z.string() ),
// Optional base64-encoded stdin bytes to forward to PHP.
// Set when `studio wp` is invoked with a non-TTY stdin (e.g. a
// pipe or redirected file). The daemon child decodes and hands
// the bytes to `php.cli({ stdin })` so reads from `php://stdin`
// inside PHP observe them. Base64 is used because Node's
// child_process IPC serializes payloads as JSON and we need to
// preserve binary bytes (gzipped SQL dumps, etc.) untouched.
stdinBase64: z.string().optional(),
} ),
} );

Expand Down
10 changes: 8 additions & 2 deletions apps/cli/lib/wordpress-server-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,8 @@ const wpCliResultSchema = z.object( {

export async function sendWpCliCommand(
siteId: string,
args: string[]
args: string[],
stdin?: Buffer
): Promise< z.infer< typeof wpCliResultSchema > > {
const processName = getProcessName( siteId );
const runningProcess = await isProcessRunning( processName );
Expand All @@ -510,7 +511,12 @@ export async function sendWpCliCommand(

const result = await sendMessage( runningProcess.pmId, processName, {
topic: 'wp-cli-command',
data: { args },
data: {
args,
...( stdin && stdin.length > 0
? { stdinBase64: stdin.toString( 'base64' ) }
: {} ),
},
} );

return wpCliResultSchema.parse( result );
Expand Down
40 changes: 31 additions & 9 deletions apps/cli/wordpress-server-child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,8 @@ async function runBlueprint( config: ServerConfig, signal: AbortSignal ): Promis
const runWpCliCommand = sequential(
async (
args: string[],
signal: AbortSignal
signal: AbortSignal,
stdin?: Uint8Array
): Promise< { stdout: string; stderr: string; exitCode: number } > => {
await Promise.allSettled( [ startingPromise ] );

Expand All @@ -431,20 +432,34 @@ const runWpCliCommand = sequential(

const rewrittenArgs = await rewriteWpCliPostContentToFile( args, server.playground.writeFile );

const response = await server.playground.cli( [
'php',
'/tmp/wp-cli.phar',
`--path=${ await server.playground.documentRoot }`,
...rewrittenArgs,
] );
const response = await server.playground.cli(
[
'php',
'/tmp/wp-cli.phar',
`--path=${ await server.playground.documentRoot }`,
...rewrittenArgs,
],
stdin ? { stdin } : {}
);

return {
stdout: await response.stdoutText,
stderr: await response.stderrText,
exitCode: await response.exitCode,
};
},
{ concurrent: 3, max: 100, deduplicateKey: ( args ) => args.join( ' ' ) }
{
concurrent: 3,
max: 100,
// Skip dedup entirely when stdin bytes are present: piped stdin is
// non-idempotent (the user may be piping streaming or one-shot
// content, and two callers with coincidentally equal byte lengths
// must not collapse into one execution). When there's no stdin,
// the command is idempotent from the dedup layer's perspective and
// we keep the args-based dedup that callers already rely on.
deduplicateKey: ( args, _signal, stdin ) =>
stdin ? undefined : args.join( ' ' ),
}
);

function parsePhpError( error: unknown ): string {
Expand Down Expand Up @@ -541,7 +556,14 @@ async function ipcMessageHandler( packet: unknown ) {
break;
case 'wp-cli-command':
try {
result = await runWpCliCommand( validMessage.data.args, abortController.signal );
const stdin = validMessage.data.stdinBase64
? Buffer.from( validMessage.data.stdinBase64, 'base64' )
: undefined;
result = await runWpCliCommand(
validMessage.data.args,
abortController.signal,
stdin
);
} catch ( wpCliError ) {
errorToConsole( `WP-CLI error:`, wpCliError );
await sendErrorMessage( validMessage.messageId, wpCliError );
Expand Down
Loading