diff --git a/packages/php-wasm/cli-util/src/lib/cli-output.ts b/packages/php-wasm/cli-util/src/lib/cli-output.ts new file mode 100644 index 00000000000..cf66c59f817 --- /dev/null +++ b/packages/php-wasm/cli-util/src/lib/cli-output.ts @@ -0,0 +1,77 @@ +interface CLIOutputOptions { + /** Verbosity level: 'quiet', 'normal', or 'debug' */ + verbosity: string; + /** Output stream to write to. Defaults to process.stdout */ + writeStream?: NodeJS.WriteStream; +} + +export class CLIOutput { + private verbosity: string; + protected writeStream: NodeJS.WriteStream; + + constructor(options: CLIOutputOptions) { + this.verbosity = options.verbosity; + this.writeStream = options.writeStream || process.stdout; + } + + get isTTY(): boolean { + return Boolean(this.writeStream.isTTY); + } + + get isQuiet(): boolean { + return this.verbosity === 'quiet'; + } + + /** + * ANSI formatting helpers. + * + * These only apply color codes when outputting to a terminal (TTY). + * When piped to files or non-TTY streams, they return plain text to + * avoid polluting logs with escape sequences. + */ + bold(text: string): string { + return this.isTTY ? `\x1b[1m${text}\x1b[0m` : text; + } + + dim(text: string): string { + return this.isTTY ? `\x1b[2m${text}\x1b[0m` : text; + } + + italic(text: string): string { + return this.isTTY ? `\x1b[3m${text}\x1b[0m` : text; + } + + red(text: string): string { + return this.isTTY ? `\x1b[31m${text}\x1b[0m` : text; + } + + green(text: string): string { + return this.isTTY ? `\x1b[32m${text}\x1b[0m` : text; + } + + yellow(text: string): string { + return this.isTTY ? `\x1b[33m${text}\x1b[0m` : text; + } + + cyan(text: string): string { + return this.isTTY ? `\x1b[36m${text}\x1b[0m` : text; + } + + highlight(text: string): string { + return this.yellow(text); + } + + print(message: string): void { + if (this.isQuiet) return; + this.writeStream.write(`${message}\n`); + } + + printError(message: string): void { + this.writeStream.write(`${this.red('Error:')} ${message}\n`); + } + + printWarning(message: string): void { + if (this.isQuiet) return; + this.writeStream.write(`${this.yellow('Warning:')} ${message}\n`); + } +} diff --git a/packages/php-wasm/cli-util/src/lib/index.ts b/packages/php-wasm/cli-util/src/lib/index.ts index 7ad538c9efa..86425b77333 100644 --- a/packages/php-wasm/cli-util/src/lib/index.ts +++ b/packages/php-wasm/cli-util/src/lib/index.ts @@ -1,2 +1,3 @@ +export * from './cli-output'; export * from './mounts'; export * from './xdebug-path-mappings'; diff --git a/packages/php-wasm/cli-util/src/test/cli-output.spec.ts b/packages/php-wasm/cli-util/src/test/cli-output.spec.ts new file mode 100644 index 00000000000..f1bf0e67cd5 --- /dev/null +++ b/packages/php-wasm/cli-util/src/test/cli-output.spec.ts @@ -0,0 +1,253 @@ +import { CLIOutput } from '../lib/cli-output'; +import { PassThrough } from 'stream'; + +/** + * Creates a fake WriteStream backed by a PassThrough stream. + * Collects all written data into a string for assertions. + */ +function createFakeStream(options: { isTTY: boolean }): { + stream: NodeJS.WriteStream; + output: () => string; +} { + const passThrough = new PassThrough(); + let data = ''; + passThrough.on('data', (chunk) => { + data += chunk.toString(); + }); + + const stream = passThrough as unknown as NodeJS.WriteStream; + stream.isTTY = options.isTTY; + + return { stream, output: () => data }; +} + +describe('CLIOutput', () => { + describe('formatting in TTY mode', () => { + it('should apply ANSI bold codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.bold('test')).toBe('\x1b[1mtest\x1b[0m'); + }); + + it('should apply ANSI dim codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.dim('test')).toBe('\x1b[2mtest\x1b[0m'); + }); + + it('should apply ANSI italic codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.italic('test')).toBe('\x1b[3mtest\x1b[0m'); + }); + + it('should apply ANSI red codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.red('test')).toBe('\x1b[31mtest\x1b[0m'); + }); + + it('should apply ANSI green codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.green('test')).toBe('\x1b[32mtest\x1b[0m'); + }); + + it('should apply ANSI yellow codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.yellow('test')).toBe('\x1b[33mtest\x1b[0m'); + }); + + it('should apply ANSI cyan codes', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.cyan('test')).toBe('\x1b[36mtest\x1b[0m'); + }); + + it('should map highlight to yellow', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.highlight('test')).toBe(output.yellow('test')); + }); + }); + + describe('formatting in non-TTY mode', () => { + it('should return plain text for all formatters', () => { + const { stream } = createFakeStream({ isTTY: false }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.bold('test')).toBe('test'); + expect(output.dim('test')).toBe('test'); + expect(output.italic('test')).toBe('test'); + expect(output.red('test')).toBe('test'); + expect(output.green('test')).toBe('test'); + expect(output.yellow('test')).toBe('test'); + expect(output.cyan('test')).toBe('test'); + expect(output.highlight('test')).toBe('test'); + }); + }); + + describe('print', () => { + it('should write message with newline', () => { + const { stream, output: getOutput } = createFakeStream({ + isTTY: false, + }); + const cliOutput = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + cliOutput.print('hello'); + expect(getOutput()).toBe('hello\n'); + }); + + it('should suppress output in quiet mode', () => { + const { stream, output: getOutput } = createFakeStream({ + isTTY: false, + }); + const cliOutput = new CLIOutput({ + verbosity: 'quiet', + writeStream: stream, + }); + + cliOutput.print('hello'); + expect(getOutput()).toBe(''); + }); + }); + + describe('printError', () => { + it('should prefix with Error:', () => { + const { stream, output: getOutput } = createFakeStream({ + isTTY: false, + }); + const cliOutput = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + cliOutput.printError('something broke'); + expect(getOutput()).toBe('Error: something broke\n'); + }); + + it('should print even in quiet mode', () => { + const { stream, output: getOutput } = createFakeStream({ + isTTY: false, + }); + const cliOutput = new CLIOutput({ + verbosity: 'quiet', + writeStream: stream, + }); + + cliOutput.printError('something broke'); + expect(getOutput()).toBe('Error: something broke\n'); + }); + }); + + describe('printWarning', () => { + it('should prefix with Warning:', () => { + const { stream, output: getOutput } = createFakeStream({ + isTTY: false, + }); + const cliOutput = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + cliOutput.printWarning('watch out'); + expect(getOutput()).toBe('Warning: watch out\n'); + }); + + it('should suppress in quiet mode', () => { + const { stream, output: getOutput } = createFakeStream({ + isTTY: false, + }); + const cliOutput = new CLIOutput({ + verbosity: 'quiet', + writeStream: stream, + }); + + cliOutput.printWarning('watch out'); + expect(getOutput()).toBe(''); + }); + }); + + describe('isTTY', () => { + it('should return true for TTY streams', () => { + const { stream } = createFakeStream({ isTTY: true }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.isTTY).toBe(true); + }); + + it('should return false for non-TTY streams', () => { + const { stream } = createFakeStream({ isTTY: false }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.isTTY).toBe(false); + }); + }); + + describe('isQuiet', () => { + it('should return true when verbosity is quiet', () => { + const { stream } = createFakeStream({ isTTY: false }); + const output = new CLIOutput({ + verbosity: 'quiet', + writeStream: stream, + }); + + expect(output.isQuiet).toBe(true); + }); + + it('should return false when verbosity is normal', () => { + const { stream } = createFakeStream({ isTTY: false }); + const output = new CLIOutput({ + verbosity: 'normal', + writeStream: stream, + }); + + expect(output.isQuiet).toBe(false); + }); + }); +}); diff --git a/packages/php-wasm/cli/src/main.ts b/packages/php-wasm/cli/src/main.ts index b5c0386d8c5..2a6acdb8c29 100644 --- a/packages/php-wasm/cli/src/main.ts +++ b/packages/php-wasm/cli/src/main.ts @@ -9,8 +9,8 @@ import { spawn } from 'child_process'; import { chmodSync, existsSync, mkdtempSync, writeFileSync } from 'fs'; import os from 'os'; import { rootCertificates } from 'tls'; -/* eslint-disable no-console */ import { + CLIOutput, makeXdebugConfig, addXdebugIDEConfig, clearXdebugIDEConfig, @@ -29,14 +29,7 @@ if (!args.length) { args = ['--help']; } -const bold = (text: string) => - process.stdout.isTTY ? '\x1b[1m' + text + '\x1b[0m' : text; - -const italic = (text: string) => - process.stdout.isTTY ? `\x1b[3m${text}\x1b[0m` : text; - -const highlight = (text: string) => - process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text; +const cliOutput = new CLIOutput({ verbosity: 'normal' }); const baseUrl = (import.meta || {}).url; @@ -177,59 +170,64 @@ ${process.argv[0]} ${process.execArgv.join(' ')} ${process.argv[1]} const hasPhpStorm = ides.includes('phpstorm'); const configFiles = Object.values(modifiedConfig); - console.log(''); + cliOutput.print(''); if (configFiles.length > 0) { - console.log(bold(`Xdebug configured successfully`)); - console.log( - highlight(`Updated IDE config: `) + configFiles.join(' ') + cliOutput.print( + cliOutput.bold(`Xdebug configured successfully`) + ); + cliOutput.print( + cliOutput.highlight(`Updated IDE config: `) + + configFiles.join(' ') ); } else { - console.log(bold(`Xdebug configuration failed.`)); - console.log( + cliOutput.print(cliOutput.bold(`Xdebug configuration failed.`)); + cliOutput.print( 'No IDE-specific project settings directory was found in the current working directory.' ); } - console.log(''); + cliOutput.print(''); if (hasVSCode && modifiedConfig['vscode']) { - console.log(bold('VS Code / Cursor instructions:')); - console.log( + cliOutput.print( + cliOutput.bold('VS Code / Cursor instructions:') + ); + cliOutput.print( ' 1. Ensure you have installed an IDE extension for PHP Debugging' ); - console.log( - ` (The ${bold('PHP Debug')} extension by ${bold( + cliOutput.print( + ` (The ${cliOutput.bold('PHP Debug')} extension by ${cliOutput.bold( 'Xdebug' )} has been a solid option)` ); - console.log( + cliOutput.print( ' 2. Open the Run and Debug panel on the left sidebar' ); - console.log( - ` 3. Select "${italic(IDEConfigName)}" from the dropdown` + cliOutput.print( + ` 3. Select "${cliOutput.italic(IDEConfigName)}" from the dropdown` ); - console.log(' 3. Click "start debugging"'); - console.log(' 5. Set a breakpoint.'); - console.log(' 6. Run your command with PHP.wasm CLI.'); + cliOutput.print(' 3. Click "start debugging"'); + cliOutput.print(' 5. Set a breakpoint.'); + cliOutput.print(' 6. Run your command with PHP.wasm CLI.'); if (hasPhpStorm) { - console.log(''); + cliOutput.print(''); } } if (hasPhpStorm && modifiedConfig['phpstorm']) { - console.log(bold('PhpStorm instructions:')); - console.log( - ` 1. Choose "${italic( + cliOutput.print(cliOutput.bold('PhpStorm instructions:')); + cliOutput.print( + ` 1. Choose "${cliOutput.italic( IDEConfigName )}" debug configuration in the toolbar` ); - console.log(' 2. Click the debug button (bug icon)`'); - console.log(' 3. Set a breakpoint.'); - console.log(' 4. Run your command with PHP.wasm CLI.'); + cliOutput.print(' 2. Click the debug button (bug icon)`'); + cliOutput.print(' 3. Set a breakpoint.'); + cliOutput.print(' 4. Run your command with PHP.wasm CLI.'); } - console.log(''); + cliOutput.print(''); } catch (error) { throw new Error('Could not configure Xdebug', { cause: error, diff --git a/packages/playground/cli/src/cli-output.ts b/packages/playground/cli/src/cli-output.ts index c8714d9c6ab..ccc1dda3a0a 100644 --- a/packages/playground/cli/src/cli-output.ts +++ b/packages/playground/cli/src/cli-output.ts @@ -1,25 +1,19 @@ /** * Manages formatted terminal output for the WordPress Playground CLI. * - * This class handles two distinct output modes: - * - TTY (terminal): Uses ANSI escape codes for colors and in-place progress updates - * - Non-TTY (pipes/logs): Skips progress updates entirely, outputs plain text only + * Extends the base CLIOutput from @php-wasm/cli-util with + * Playground-specific output: config summary, progress indicators, + * and server-ready messages. * - * Progress updates rewrite the same line in TTY mode to create smooth animations. - * When output is piped or redirected, progress is suppressed to avoid cluttering - * logs with intermediate states - only the final "Ready!" message appears. + * Progress updates rewrite the same line in TTY mode to create smooth + * animations. When output is piped or redirected, progress is suppressed + * to avoid cluttering logs with intermediate states. */ +import { CLIOutput as BaseCLIOutput } from '@php-wasm/cli-util'; import { shouldRenderProgress } from './utils/progress'; import type { Mount } from '@php-wasm/cli-util'; -export interface CLIOutputOptions { - /** Verbosity level: 'quiet', 'normal', or 'debug' */ - verbosity: string; - /** Output stream to write to. Defaults to process.stdout */ - writeStream?: NodeJS.WriteStream; -} - /** * Configuration details displayed at CLI startup. */ @@ -36,21 +30,10 @@ export interface ConfigSummary { blueprint?: string; } -export class CLIOutput { - private verbosity: string; - private writeStream: NodeJS.WriteStream; +export class CLIOutput extends BaseCLIOutput { private lastProgressLine = ''; private progressActive = false; - constructor(options: CLIOutputOptions) { - this.verbosity = options.verbosity; - this.writeStream = options.writeStream || process.stdout; - } - - get isTTY(): boolean { - return Boolean(this.writeStream.isTTY); - } - /** * Determines if progress updates should be rendered. * @@ -61,41 +44,6 @@ export class CLIOutput { return shouldRenderProgress(this.writeStream); } - get isQuiet(): boolean { - return this.verbosity === 'quiet'; - } - - /** - * ANSI formatting helpers. - * - * These only apply color codes when outputting to a terminal (TTY). - * When piped to files or non-TTY streams, they return plain text to - * avoid polluting logs with escape sequences. - */ - private bold(text: string): string { - return this.isTTY ? `\x1b[1m${text}\x1b[0m` : text; - } - - private dim(text: string): string { - return this.isTTY ? `\x1b[2m${text}\x1b[0m` : text; - } - - private green(text: string): string { - return this.isTTY ? `\x1b[32m${text}\x1b[0m` : text; - } - - private cyan(text: string): string { - return this.isTTY ? `\x1b[36m${text}\x1b[0m` : text; - } - - private yellow(text: string): string { - return this.isTTY ? `\x1b[33m${text}\x1b[0m` : text; - } - - private red(text: string): string { - return this.isTTY ? `\x1b[31m${text}\x1b[0m` : text; - } - printBanner(): void { if (this.isQuiet) return; @@ -254,7 +202,7 @@ export class CLIOutput { * Errors are always shown, even in quiet mode, and interrupt any * active progress display to ensure visibility. */ - printError(message: string): void { + override printError(message: string): void { // Clear any active progress first if (this.progressActive && this.isTTY) { this.writeStream.cursorTo(0); @@ -280,7 +228,7 @@ export class CLIOutput { ); } - printWarning(message: string): void { + override printWarning(message: string): void { if (this.isQuiet) return; this.writeStream.write(`${this.yellow('Warning:')} ${message}\n`); diff --git a/packages/playground/cli/src/run-cli.ts b/packages/playground/cli/src/run-cli.ts index e31244d00fa..f2567485e1c 100644 --- a/packages/playground/cli/src/run-cli.ts +++ b/packages/playground/cli/src/run-cli.ts @@ -851,21 +851,9 @@ export interface RunCLIServer extends AsyncDisposable { }; } -const bold = (text: string) => - process.stdout.isTTY ? '\x1b[1m' + text + '\x1b[0m' : text; - const red = (text: string) => process.stdout.isTTY ? '\x1b[31m' + text + '\x1b[0m' : text; -const dim = (text: string) => - process.stdout.isTTY ? `\x1b[2m${text}\x1b[0m` : text; - -const italic = (text: string) => - process.stdout.isTTY ? `\x1b[3m${text}\x1b[0m` : text; - -const highlight = (text: string) => - process.stdout.isTTY ? `\x1b[33m${text}\x1b[0m` : text; - // These overloads are declared for convenience so runCLI() can return // different things depending on the CLI command without forcing the // callers (mostly automated tests) to check return values. @@ -1086,12 +1074,14 @@ export async function runCLI(args: RunCLIArgs): Promise { ], }); - console.log(bold(`Xdebug configured successfully`)); - console.log( - highlight('Playground source root: ') + + cliOutput.print( + cliOutput.bold(`Xdebug configured successfully`) + ); + cliOutput.print( + cliOutput.highlight('Playground source root: ') + `.playground-xdebug-root` + - italic( - dim( + cliOutput.italic( + cliOutput.dim( ` – you can set breakpoints and preview Playground's VFS structure in there.` ) ) @@ -1138,87 +1128,98 @@ export async function runCLI(args: RunCLIArgs): Promise { const hasPhpStorm = ides.includes('phpstorm'); const configFiles = Object.values(modifiedConfig); - console.log(''); + cliOutput.print(''); if (configFiles.length > 0) { - console.log( - bold(`Xdebug configured successfully`) + cliOutput.print( + cliOutput.bold( + `Xdebug configured successfully` + ) ); - console.log( - highlight(`Updated IDE config: `) + - configFiles.join(' ') + cliOutput.print( + cliOutput.highlight( + `Updated IDE config: ` + ) + configFiles.join(' ') ); - console.log( - highlight('Playground source root: ') + + cliOutput.print( + cliOutput.highlight( + 'Playground source root: ' + ) + `.playground-xdebug-root` + - italic( - dim( + cliOutput.italic( + cliOutput.dim( ` – you can set breakpoints and preview Playground's VFS structure in there.` ) ) ); } else { - console.log( - bold(`Xdebug configuration failed.`) + cliOutput.print( + cliOutput.bold( + `Xdebug configuration failed.` + ) ); - console.log( + cliOutput.print( 'No IDE-specific project settings directory was found in the current working directory.' ); } - console.log(''); + cliOutput.print(''); if (hasVSCode && modifiedConfig['vscode']) { - console.log( - bold('VS Code / Cursor instructions:') + cliOutput.print( + cliOutput.bold( + 'VS Code / Cursor instructions:' + ) ); - console.log( + cliOutput.print( ' 1. Ensure you have installed an IDE extension for PHP Debugging' ); - console.log( - ` (The ${bold('PHP Debug')} extension by ${bold( + cliOutput.print( + ` (The ${cliOutput.bold('PHP Debug')} extension by ${cliOutput.bold( 'Xdebug' )} has been a solid option)` ); - console.log( + cliOutput.print( ' 2. Open the Run and Debug panel on the left sidebar' ); - console.log( - ` 3. Select "${italic( + cliOutput.print( + ` 3. Select "${cliOutput.italic( IDEConfigName )}" from the dropdown` ); - console.log(' 3. Click "start debugging"'); - console.log( + cliOutput.print(' 3. Click "start debugging"'); + cliOutput.print( ' 5. Set a breakpoint. For example, in .playground-xdebug-root/wordpress/index.php' ); - console.log( + cliOutput.print( ' 6. Visit Playground in your browser to hit the breakpoint' ); if (hasPhpStorm) { - console.log(''); + cliOutput.print(''); } } if (hasPhpStorm && modifiedConfig['phpstorm']) { - console.log(bold('PhpStorm instructions:')); - console.log( - ` 1. Choose "${italic( + cliOutput.print( + cliOutput.bold('PhpStorm instructions:') + ); + cliOutput.print( + ` 1. Choose "${cliOutput.italic( IDEConfigName )}" debug configuration in the toolbar` ); - console.log( + cliOutput.print( ' 2. Click the debug button (bug icon)`' ); - console.log( + cliOutput.print( ' 3. Set a breakpoint. For example, in .playground-xdebug-root/wordpress/index.php' ); - console.log( + cliOutput.print( ' 4. Visit Playground in your browser to hit the breakpoint' ); } - console.log(''); + cliOutput.print(''); } catch (error) { throw new Error('Could not configure Xdebug', { cause: error,