Skip to content

Commit 445d9a1

Browse files
committed
Forward host stdin to wp-cli through the server daemon
`echo foo | studio wp eval 'echo file_get_contents("php://stdin");'` silently dropped input when the site was running (daemon path). Both paths now drain process.stdin up-front and forward the bytes to `php.cli({ stdin })`, which — paired with the Playground-side stdin option — surfaces them to PHP via `php://stdin`. - commands/wp.ts: `drainHostStdin()` reads a non-TTY process.stdin into a Buffer and passes it to both the daemon (`sendWpCliCommand`) and in-proc (`runWpCliCommand`/`runGlobalWpCliCommand`) paths. - lib/types/wordpress-server-ipc.ts: wp-cli-command IPC payload gains an optional `stdinBase64` field. Base64 is used because Node's child_process IPC is JSON-serialized; we must preserve binary bytes (e.g. gzipped SQL dumps) byte-for-byte. - lib/wordpress-server-manager.ts: `sendWpCliCommand` accepts an optional Buffer and base64-encodes it into the payload. - lib/run-wp-cli-command.ts: in-proc `runWpCliCommand` / `runGlobalWpCliCommand` accept an optional `{ stdin }` and forward it to `php.cli()`. - wordpress-server-child.ts: decode the base64 on the daemon side and hand the bytes to `server.playground.cli(args, { stdin })`. Also disable the sequential() dedup key whenever stdin is present — piped stdin is inherently non-idempotent and two callers with coincidentally equal byte lengths must not collapse into one execution. The previous key included byte length only, which was not enough. No behavior change for commands invoked without piped stdin.
1 parent 214a5bf commit 445d9a1

5 files changed

Lines changed: 97 additions & 18 deletions

File tree

apps/cli/commands/wp.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ enum Mode {
4040
SITE = 'site',
4141
}
4242

43+
/**
44+
* Drain `process.stdin` to a Buffer when this Studio CLI invocation
45+
* was piped into (e.g. `echo foo | studio wp eval ...`). Returns
46+
* `undefined` when stdin is a TTY (interactive shell) so we never
47+
* block reading from a terminal.
48+
*
49+
* Reads bytes synchronously into memory before the WP-CLI command
50+
* runs. The buffer is then forwarded to the PHP runtime via the
51+
* `stdin` option on `php.cli()` (Playground PR adding that option
52+
* is the upstream half of this fix). Without this draining step,
53+
* host stdin would never reach PHP — the daemon path runs in a
54+
* separate process from `studio wp` and the in-proc path runs in a
55+
* worker thread, neither of which has the user's pipe attached.
56+
*/
57+
async function drainHostStdin(): Promise< Buffer | undefined > {
58+
if ( process.stdin.isTTY ) {
59+
return undefined;
60+
}
61+
const chunks: Buffer[] = [];
62+
for await ( const chunk of process.stdin ) {
63+
chunks.push( chunk as Buffer );
64+
}
65+
return chunks.length > 0 ? Buffer.concat( chunks ) : undefined;
66+
}
67+
4368
export async function runCommand(
4469
mode: Mode,
4570
siteFolder: string,
@@ -48,7 +73,8 @@ export async function runCommand(
4873
): Promise< void > {
4974
// Handle global WP-CLI commands that don't require a site path (--studio-no-path)
5075
if ( mode === Mode.GLOBAL ) {
51-
await using command = await runGlobalWpCliCommand( args );
76+
const stdin = await drainHostStdin();
77+
await using command = await runGlobalWpCliCommand( args, { stdin } );
5278

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

88+
// Drain piped stdin (if any) up-front so we can forward it to whichever
89+
// WP-CLI execution path we end up on. Both the daemon IPC and in-proc
90+
// paths route the bytes through `php.cli({ stdin })`.
91+
const stdin = await drainHostStdin();
92+
6293
// If there's already a running Playground instance for this site AND we're not requesting
6394
// a different PHP version, pass the command to it…
6495
const useCustomPhpVersion = options.phpVersion && options.phpVersion !== site.phpVersion;
@@ -71,7 +102,7 @@ export async function runCommand(
71102
await connectToDaemon();
72103

73104
if ( await isServerRunning( site.id ) ) {
74-
const result = await sendWpCliCommand( site.id, args );
105+
const result = await sendWpCliCommand( site.id, args, stdin );
75106
process.stdout.write( result.stdout );
76107
process.stderr.write( result.stderr );
77108
process.exit( result.exitCode );
@@ -85,7 +116,9 @@ export async function runCommand(
85116
process.on( 'SIGTERM', () => process.exit( 1 ) );
86117

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

90123
await pipePHPResponse( command.response );
91124
process.exitCode = await command.response.exitCode;

apps/cli/lib/run-wp-cli-command.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ interface DisposableWpCliResponse extends Disposable {
5050
export async function runWpCliCommand(
5151
siteFolder: string,
5252
phpVersion: SupportedPHPVersion,
53-
args: string[]
53+
args: string[],
54+
options: { stdin?: Buffer | Uint8Array } = {}
5455
): Promise< DisposableWpCliResponse > {
5556
const id = await loadNodeRuntime( phpVersion, {
5657
followSymlinks: true,
@@ -100,7 +101,10 @@ export async function runWpCliCommand(
100101

101102
await setupPlatformLevelMuPlugins( php );
102103

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

105109
return {
106110
response,
@@ -118,7 +122,10 @@ export async function runWpCliCommand(
118122
* Run a global WP-CLI command without requiring a site.
119123
* Useful for commands like --version that don't need a WordPress installation.
120124
*/
121-
export async function runGlobalWpCliCommand( args: string[] ): Promise< DisposableWpCliResponse > {
125+
export async function runGlobalWpCliCommand(
126+
args: string[],
127+
options: { stdin?: Buffer | Uint8Array } = {}
128+
): Promise< DisposableWpCliResponse > {
122129
const id = await loadNodeRuntime( LatestSupportedPHPVersion, {
123130
followSymlinks: true,
124131
withRedis: false,
@@ -143,7 +150,10 @@ export async function runGlobalWpCliCommand( args: string[] ): Promise< Disposab
143150

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

146-
const response = await php.cli( [ 'php', '/tmp/wp-cli.phar', ...args ] );
153+
const response = await php.cli(
154+
[ 'php', '/tmp/wp-cli.phar', ...args ],
155+
options.stdin ? { stdin: options.stdin } : {}
156+
);
147157

148158
return {
149159
response,

apps/cli/lib/types/wordpress-server-ipc.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ const managerMessageWpCliCommand = z.object( {
5555
topic: z.literal( 'wp-cli-command' ),
5656
data: z.object( {
5757
args: z.array( z.string() ),
58+
// Optional base64-encoded stdin bytes to forward to PHP.
59+
// Set when `studio wp` is invoked with a non-TTY stdin (e.g. a
60+
// pipe or redirected file). The daemon child decodes and hands
61+
// the bytes to `php.cli({ stdin })` so reads from `php://stdin`
62+
// inside PHP observe them. Base64 is used because Node's
63+
// child_process IPC serializes payloads as JSON and we need to
64+
// preserve binary bytes (gzipped SQL dumps, etc.) untouched.
65+
stdinBase64: z.string().optional(),
5866
} ),
5967
} );
6068

apps/cli/lib/wordpress-server-manager.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,8 @@ const wpCliResultSchema = z.object( {
499499

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

511512
const result = await sendMessage( runningProcess.pmId, processName, {
512513
topic: 'wp-cli-command',
513-
data: { args },
514+
data: {
515+
args,
516+
...( stdin && stdin.length > 0
517+
? { stdinBase64: stdin.toString( 'base64' ) }
518+
: {} ),
519+
},
514520
} );
515521

516522
return wpCliResultSchema.parse( result );

apps/cli/wordpress-server-child.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,8 @@ async function runBlueprint( config: ServerConfig, signal: AbortSignal ): Promis
411411
const runWpCliCommand = sequential(
412412
async (
413413
args: string[],
414-
signal: AbortSignal
414+
signal: AbortSignal,
415+
stdin?: Uint8Array
415416
): Promise< { stdout: string; stderr: string; exitCode: number } > => {
416417
await Promise.allSettled( [ startingPromise ] );
417418

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

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

434-
const response = await server.playground.cli( [
435-
'php',
436-
'/tmp/wp-cli.phar',
437-
`--path=${ await server.playground.documentRoot }`,
438-
...rewrittenArgs,
439-
] );
435+
const response = await server.playground.cli(
436+
[
437+
'php',
438+
'/tmp/wp-cli.phar',
439+
`--path=${ await server.playground.documentRoot }`,
440+
...rewrittenArgs,
441+
],
442+
stdin ? { stdin } : {}
443+
);
440444

441445
return {
442446
stdout: await response.stdoutText,
443447
stderr: await response.stderrText,
444448
exitCode: await response.exitCode,
445449
};
446450
},
447-
{ concurrent: 3, max: 100, deduplicateKey: ( args ) => args.join( ' ' ) }
451+
{
452+
concurrent: 3,
453+
max: 100,
454+
// Skip dedup entirely when stdin bytes are present: piped stdin is
455+
// non-idempotent (the user may be piping streaming or one-shot
456+
// content, and two callers with coincidentally equal byte lengths
457+
// must not collapse into one execution). When there's no stdin,
458+
// the command is idempotent from the dedup layer's perspective and
459+
// we keep the args-based dedup that callers already rely on.
460+
deduplicateKey: ( args, _signal, stdin ) =>
461+
stdin ? undefined : args.join( ' ' ),
462+
}
448463
);
449464

450465
function parsePhpError( error: unknown ): string {
@@ -541,7 +556,14 @@ async function ipcMessageHandler( packet: unknown ) {
541556
break;
542557
case 'wp-cli-command':
543558
try {
544-
result = await runWpCliCommand( validMessage.data.args, abortController.signal );
559+
const stdin = validMessage.data.stdinBase64
560+
? Buffer.from( validMessage.data.stdinBase64, 'base64' )
561+
: undefined;
562+
result = await runWpCliCommand(
563+
validMessage.data.args,
564+
abortController.signal,
565+
stdin
566+
);
545567
} catch ( wpCliError ) {
546568
errorToConsole( `WP-CLI error:`, wpCliError );
547569
await sendErrorMessage( validMessage.messageId, wpCliError );

0 commit comments

Comments
 (0)