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..a77fd24934 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,10 +169,9 @@ 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. ' + + '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' } ), @@ -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..72f0169f66 100644 --- a/apps/cli/commands/wp.ts +++ b/apps/cli/commands/wp.ts @@ -1,32 +1,23 @@ -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'; 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 ); @@ -35,104 +26,49 @@ 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 ); } }; 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 ); - - // 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(); - - 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(); - } - } - - process.on( 'SIGINT', () => process.exit( 1 ) ); - process.on( 'SIGTERM', () => process.exit( 1 ) ); + // 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. + const onSignal = async () => { + await disconnectFromDaemon(); + process.exit( 1 ); + }; + process.on( 'SIGINT', onSignal ); + process.on( 'SIGTERM', onSignal ); - // …If not, run the command in a new runtime instance (PHP-WASM or native PHP) - await using command = await runWpCliCommand( site, args, { phpVersion } ); + try { + await connectToDaemon(); - await pipePHPResponse( command.response ); - process.exitCode = await command.response.exitCode; + await using command = await runWpCliCommandWithMessaging( site, args, { phpVersion } ); + await pipePHPResponse( command.response ); + process.exitCode = await command.response.exitCode; + } finally { + await disconnectFromDaemon(); + } } function removeArgumentFromArgv( diff --git a/apps/cli/lib/run-wp-cli-command.ts b/apps/cli/lib/run-wp-cli-command.ts index 4280994ba7..bcf78975bc 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 { @@ -28,12 +32,15 @@ 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, killPhpProcessTree, 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'; @@ -49,6 +56,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. */ @@ -103,13 +113,10 @@ function drainToMemory( source: Readable ): Readable { } type RunWpCliCommandOptions = { - siteUrl?: string; - requireSqliteCliCommand?: boolean; phpVersion?: SupportedPHPVersion; -}; - -type DisposableWpCliResponse = Disposable & { - response: WpCliResponse; + requireSqliteCliCommand?: boolean; + siteUrl?: string; + stdio?: 'inherit' | 'pipe'; }; const WASM_SQLITE_COMMAND_PATH = '/tmp/sqlite-command/command.php'; @@ -152,22 +159,42 @@ async function ensureChildSpawned( child: ChildProcess ): Promise< void > { } ); } +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< DisposableWpCliResponse >; async function runNativeWpCliCommand( site: SiteData, args: string[], options: RunWpCliCommandOptions = {} -): Promise< DisposableWpCliResponse > { - const nativeArgs = applyWpCliCommandOptions( 'native', args, options ); +): Promise< DisposableWpCliResponse | DisposableExitCode > { 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 ], { cwd: site.path, - stdio: [ 'ignore', 'pipe', 'pipe' ], + stdio: options.stdio === 'inherit' ? 'inherit' : [ 'ignore', 'pipe', 'pipe' ], detached: DETACH_FOR_GROUP_KILL, } ); @@ -180,19 +207,29 @@ async function runNativeWpCliCommand( child.once( 'exit', ( code ) => resolve( code ?? 1 ) ); } ); + 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' ); + } + }; + + if ( options.stdio === 'inherit' ) { + return { + exitCode: exitCode, + [ Symbol.dispose ]: dispose, + }; + } + return { response: new WpCliResponse( - drainToMemory( child.stdout ), - drainToMemory( child.stderr ), + // Non-null: the 'pipe' stdio mode always provides stdout/stderr streams. + 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' ); - } - }, + [ Symbol.dispose ]: dispose, }; } @@ -214,20 +251,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 +303,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 +316,7 @@ export async function runWpCliCommand( await php.setSpawnHandler( createNoopSpawnHandler() ); - await cleanupLegacyMuPlugins( siteFolder ); + await cleanupLegacyMuPlugins( site.path ); // Mount mu-plugins const [ studioMuPluginsHostPath, loaderMuPluginHostPath ] = await getMuPlugins( { @@ -290,7 +345,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 ), @@ -303,3 +358,45 @@ 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( [ Buffer ] )` emits all of the contents as one chunk; + // `stripLeadingShebang` removes the Playground shebang line if present. + return { + response: new WpCliResponse( + stripLeadingShebang( Readable.from( [ Buffer.from( response.stdout ) ] ) ), + Readable.from( [ Buffer.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. + } + } + + return runWpCliCommand( site, args, options ); +} 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 ); +}