diff --git a/packages/php-wasm/node/src/test/php.spec.ts b/packages/php-wasm/node/src/test/php.spec.ts index cab2562260..ce90966686 100644 --- a/packages/php-wasm/node/src/test/php.spec.ts +++ b/packages/php-wasm/node/src/test/php.spec.ts @@ -3053,4 +3053,11 @@ describe('sandboxedSpawnHandlerFactory', () => { }); expect(response.text).toEqual(expected); }); + + it('Should be able to run CLI commands via php.cli()', async () => { + const response = await php.cli(['ls', '/tmp/shared-test-directory']); + expect(await response.stdoutText).toEqual( + ['README.md', 'code', ''].join('\n') + ); + }); }); diff --git a/packages/php-wasm/universal/src/lib/php.ts b/packages/php-wasm/universal/src/lib/php.ts index f040c3fe02..977eab626b 100644 --- a/packages/php-wasm/universal/src/lib/php.ts +++ b/packages/php-wasm/universal/src/lib/php.ts @@ -1,5 +1,10 @@ import { logger } from '@php-wasm/logger'; -import { Semaphore, createSpawnHandler, joinPaths } from '@php-wasm/util'; +import { + Semaphore, + basename, + createSpawnHandler, + joinPaths, +} from '@php-wasm/util'; import type { Emscripten } from './emscripten-types'; import type { ListFilesOptions, RmDirOptions } from './fs-helpers'; import { FSHelpers } from './fs-helpers'; @@ -9,6 +14,7 @@ import { getLoadedRuntime } from './load-php-runtime'; import type { PHPRequestHandler } from './php-request-handler'; import { PHPResponse, StreamedPHPResponse } from './php-response'; import type { + ChildProcess, MessageListener, PHPEvent, PHPEventListener, @@ -1446,8 +1452,12 @@ export class PHP implements Disposable { */ async cli( argv: string[], - options: { env?: Record } = {} + options: { env?: Record; cwd?: string } = {} ): Promise { + if (basename(argv[0] ?? '') !== 'php') { + return this.subProcess(argv, options); + } + if (this.#phpWasmInitCalled) { this.#rotationOptions.needsRotating = true; } @@ -1483,6 +1493,79 @@ export class PHP implements Disposable { }); } + /** + * Runs an arbitrary CLI command using the spawn handler associated + * with this PHP instance. + * + * @param argv + * @param options + * @returns StreamedPHPResponse. + */ + private async subProcess( + argv: string[], + options: { env?: Record; cwd?: string } = {} + ): Promise { + const process = this[__private__dont__use].spawnProcess( + argv[0], + argv.slice(1), + { + env: options.env, + cwd: options.cwd ?? this.cwd(), + } + ) as ChildProcess; + + const stderrStream = await createInvertedReadableStream(); + process.on('error', (error) => { + stderrStream.controller.error(error); + }); + process.stderr.on('data', (data) => { + stderrStream.controller.enqueue(data); + }); + + const stdoutStream = await createInvertedReadableStream(); + process.stdout.on('data', (data) => { + stdoutStream.controller.enqueue(data); + }); + + process.on('exit', () => { + // Delay until next tick to ensure we don't close the streams before + // emitting the error event on the stderrStream. + setTimeout(() => { + /** + * Ignore any close() errors, e.g. "stream already closed". We just + * need to try to call close() and forget about this subprocess. + */ + try { + stderrStream.controller.close(); + } catch { + // Ignore error + } + try { + stdoutStream.controller.close(); + } catch { + // Ignore error + } + }, 0); + }); + + return new StreamedPHPResponse( + // Headers stream + new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stdoutStream.stream, + stderrStream.stream, + // Exit code + new Promise((resolve) => { + process.on('exit', (code) => { + resolve(code); + }); + }) + ); + } + setSkipShebang(shouldSkip: boolean) { this[__private__dont__use].ccall( 'wasm_set_skip_shebang',