Skip to content

Commit

Permalink
feat: wrap stdout and stderr (#629)
Browse files Browse the repository at this point in the history
* feat: wrap stdout and stderr

* fix: spinners

* fix: return type on Command.run

* fix: update Command._run return type

* chore: remove unnecessary assertion

* feat: add logJson protected method

* chore: small fixes

* chore: tests

* chore: json regression
  • Loading branch information
mdonnalley authored Feb 20, 2023
1 parent 499c533 commit 39ea8ea
Show file tree
Hide file tree
Showing 17 changed files with 150 additions and 115 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,4 @@
"pretest": "yarn build --noEmit && tsc -p test --noEmit --skipLibCheck"
},
"types": "lib/index.d.ts"
}
}
36 changes: 25 additions & 11 deletions src/cli-ux/action/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {inspect} from 'util'
import {castArray} from '../../util'
import {stderr, stdout} from '../stream'

export interface ITask {
action: string;
Expand All @@ -21,8 +22,8 @@ export class ActionBase {
protected stdmocks?: ['stdout' | 'stderr', string[]][]

private stdmockOrigs = {
stdout: process.stdout.write,
stderr: process.stderr.write,
stdout: stdout.write,
stderr: stderr.write,
}

public start(action: string, status?: string, opts: Options = {}): void {
Expand Down Expand Up @@ -147,25 +148,29 @@ export class ActionBase {
// mock out stdout/stderr so it doesn't screw up the rendering
protected _stdout(toggle: boolean): void {
try {
const outputs: ['stdout', 'stderr'] = ['stdout', 'stderr']
if (toggle) {
if (this.stdmocks) return
this.stdmockOrigs = {
stdout: process.stdout.write,
stderr: process.stderr.write,
stdout: stdout.write,
stderr: stderr.write,
}

this.stdmocks = []
for (const std of outputs) {
(process[std] as any).write = (...args: any[]) => {
this.stdmocks!.push([std, args] as ['stdout' | 'stderr', string[]])
}
stdout.write = (...args: any[]) => {
this.stdmocks!.push(['stdout', args] as ['stdout', string[]])
return true
}

stderr.write = (...args: any[]) => {
this.stdmocks!.push(['stderr', args] as ['stderr', string[]])
return true
}
} else {
if (!this.stdmocks) return
// this._write('stderr', '\nresetstdmock\n\n\n')
delete this.stdmocks
for (const std of outputs) process[std].write = this.stdmockOrigs[std] as any
stdout.write = this.stdmockOrigs.stdout
stderr.write = this.stdmockOrigs.stderr
}
} catch (error) {
this._write('stderr', inspect(error))
Expand Down Expand Up @@ -196,6 +201,15 @@ export class ActionBase {

// write to the real stdout/stderr
protected _write(std: 'stdout' | 'stderr', s: string | string[]): void {
this.stdmockOrigs[std].apply(process[std], castArray(s) as [string])
switch (std) {
case 'stdout':
this.stdmockOrigs.stdout.apply(stdout, castArray(s) as [string])
break
case 'stderr':
this.stdmockOrigs.stderr.apply(stderr, castArray(s) as [string])
break
default:
throw new Error(`invalid std: ${std}`)
}
}
}
17 changes: 9 additions & 8 deletions src/cli-ux/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Errors from '../errors'
import * as util from 'util'

import * as chalk from 'chalk'
import {ActionBase} from './action/base'
import {config, Config} from './config'
import {ExitError} from './exit'
Expand All @@ -9,6 +9,7 @@ import * as styled from './styled'
import {Table} from './styled'
import * as uxPrompt from './prompt'
import uxWait from './wait'
import {stdout} from './stream'

const hyperlinker = require('hyperlinker')

Expand All @@ -25,9 +26,9 @@ function timeout(p: Promise<any>, ms: number) {

async function _flush() {
const p = new Promise(resolve => {
process.stdout.once('drain', () => resolve(null))
stdout.once('drain', () => resolve(null))
})
const flushed = process.stdout.write('')
const flushed = stdout.write('')

if (flushed) {
return Promise.resolve()
Expand Down Expand Up @@ -67,8 +68,8 @@ export class ux {
this.info(styled.styledObject(obj, keys))
}

public static get styledHeader(): typeof styled.styledHeader {
return styled.styledHeader
public static styledHeader(header: string): void {
this.info(chalk.dim('=== ') + chalk.bold(header) + '\n')
}

public static get styledJSON(): typeof styled.styledJSON {
Expand Down Expand Up @@ -97,18 +98,18 @@ export class ux {

public static trace(format: string, ...args: string[]): void {
if (this.config.outputLevel === 'trace') {
process.stdout.write(util.format(format, ...args) + '\n')
stdout.write(util.format(format, ...args) + '\n')
}
}

public static debug(format: string, ...args: string[]): void {
if (['trace', 'debug'].includes(this.config.outputLevel)) {
process.stdout.write(util.format(format, ...args) + '\n')
stdout.write(util.format(format, ...args) + '\n')
}
}

public static info(format: string, ...args: string[]): void {
process.stdout.write(util.format(format, ...args) + '\n')
stdout.write(util.format(format, ...args) + '\n')
}

public static log(format?: string, ...args: string[]): void {
Expand Down
7 changes: 4 additions & 3 deletions src/cli-ux/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Errors from '../errors'
import config from './config'

import * as chalk from 'chalk'
import {stderr} from './stream'
const ansiEscapes = require('ansi-escapes')
const passwordPrompt = require('password-prompt')

Expand Down Expand Up @@ -39,7 +40,7 @@ function normal(options: IPromptConfig, retries = 100): Promise<string> {
}

process.stdin.setEncoding('utf8')
process.stderr.write(options.prompt)
stderr.write(options.prompt)
process.stdin.resume()
process.stdin.once('data', b => {
if (timer) clearTimeout(timer)
Expand Down Expand Up @@ -77,7 +78,7 @@ async function single(options: IPromptConfig): Promise<string> {
}

function replacePrompt(prompt: string) {
process.stderr.write(ansiEscapes.cursorHide + ansiEscapes.cursorUp(1) + ansiEscapes.cursorLeft + prompt +
stderr.write(ansiEscapes.cursorHide + ansiEscapes.cursorUp(1) + ansiEscapes.cursorLeft + prompt +
ansiEscapes.cursorDown(1) + ansiEscapes.cursorLeft + ansiEscapes.cursorShow)
}

Expand Down Expand Up @@ -161,7 +162,7 @@ export async function anykey(message?: string): Promise<string> {
}

const char = await prompt(message, {type: 'single', required: false})
if (tty) process.stderr.write('\n')
if (tty) stderr.write('\n')
if (char === 'q') Errors.error('quit')
if (char === '\u0003') Errors.error('ctrl-c')
return char
Expand Down
39 changes: 39 additions & 0 deletions src/cli-ux/stream.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* A wrapper around process.stdout and process.stderr that allows us to mock out the streams for testing.
*/
class Stream {
public constructor(public channel: 'stdout' | 'stderr') {}

public get isTTY(): boolean {
return process[this.channel].isTTY
}

public getWindowSize(): number[] {
return process[this.channel].getWindowSize()
}

public write(data: string): boolean {
return process[this.channel].write(data)
}

public read(): boolean {
return process[this.channel].read()
}

public on(event: string, listener: (...args: any[]) => void): Stream {
process[this.channel].on(event, listener)
return this
}

public once(event: string, listener: (...args: any[]) => void): Stream {
process[this.channel].once(event, listener)
return this
}

public emit(event: string, ...args: any[]): boolean {
return process[this.channel].emit(event, ...args)
}
}

export const stdout = new Stream('stdout')
export const stderr = new Stream('stderr')
6 changes: 0 additions & 6 deletions src/cli-ux/styled/header.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/cli-ux/styled/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import styledHeader from './header'
import styledJSON from './json'
import styledObject from './object'
import * as Table from './table'
import tree from './tree'
import progress from './progress'

export {
styledHeader,
styledJSON,
styledObject,
Table,
Expand Down
5 changes: 3 additions & 2 deletions src/cli-ux/styled/table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as chalk from 'chalk'
import {capitalize, sumBy} from '../../util'
import {safeDump} from 'js-yaml'
import {inspect} from 'util'
import {stdout} from '../stream'

const sw = require('string-width')
const {orderBy} = require('natural-orderby')
Expand Down Expand Up @@ -42,7 +43,7 @@ class Table<T extends Record<string, unknown>> {
filter,
'no-header': options['no-header'] ?? false,
'no-truncate': options['no-truncate'] ?? false,
printLine: printLine ?? ((s: any) => process.stdout.write(s + '\n')),
printLine: printLine ?? ((s: any) => stdout.write(s + '\n')),
rowStart: ' ',
sort,
title,
Expand Down Expand Up @@ -190,7 +191,7 @@ class Table<T extends Record<string, unknown>> {
// truncation logic
const shouldShorten = () => {
// don't shorten if full mode
if (options['no-truncate'] || (!process.stdout.isTTY && !process.env.CLI_UX_SKIP_TTY_CHECK)) return
if (options['no-truncate'] || (!stdout.isTTY && !process.env.CLI_UX_SKIP_TTY_CHECK)) return

// don't shorten if there is enough screen width
const dataMaxWidth = sumBy(columns, c => c.width!)
Expand Down
27 changes: 15 additions & 12 deletions src/command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {fileURLToPath} from 'url'
import * as chalk from 'chalk'
import {format, inspect} from 'util'
import * as ux from './cli-ux'
import {ux} from './cli-ux'
import {Config} from './config'
import * as Errors from './errors'
import {PrettyPrintableError} from './errors'
Expand All @@ -27,14 +27,15 @@ import {CommandError} from './interfaces/errors'
import {boolean} from './flags'
import {requireJson} from './util'
import {PJSON} from './interfaces'
import {stdout} from './cli-ux/stream'

const pjson = requireJson<PJSON>(__dirname, '..', 'package.json')

/**
* swallows stdout epipe errors
* this occurs when stdout closes such as when piping to head
*/
process.stdout.on('error', (err: any) => {
stdout.on('error', (err: any) => {
if (err && err.code === 'EPIPE')
return
throw err
Expand Down Expand Up @@ -149,7 +150,7 @@ export abstract class Command {
* @param {LoadOptions} opts options
* @returns {Promise<unknown>} result
*/
public static async run<T extends Command>(this: new(argv: string[], config: Config) => T, argv?: string[], opts?: LoadOptions): Promise<unknown> {
public static async run<T extends Command>(this: new(argv: string[], config: Config) => T, argv?: string[], opts?: LoadOptions): Promise<ReturnType<T['run']>> {
if (!argv) argv = process.argv.slice(2)

// Handle the case when a file URL string is passed in such as 'import.meta.url'; covert to file path.
Expand All @@ -165,7 +166,7 @@ export abstract class Command {
cmd.ctor.id = id
}

return cmd._run()
return cmd._run<ReturnType<T['run']>>()
}

protected static _baseFlags: FlagInput
Expand Down Expand Up @@ -207,7 +208,7 @@ export abstract class Command {
return this.constructor as typeof Command
}

protected async _run<T>(): Promise<T | undefined> {
protected async _run<T>(): Promise<T> {
let err: Error | undefined
let result
try {
Expand All @@ -222,11 +223,9 @@ export abstract class Command {
await this.finally(err)
}

if (result && this.jsonEnabled()) {
ux.styledJSON(this.toSuccessJson(result))
}
if (result && this.jsonEnabled()) this.logJson(this.toSuccessJson(result))

return result
return result as T
}

public exit(code = 0): void {
Expand All @@ -249,14 +248,14 @@ export abstract class Command {
public log(message = '', ...args: any[]): void {
if (!this.jsonEnabled()) {
message = typeof message === 'string' ? message : inspect(message)
process.stdout.write(format(message, ...args) + '\n')
stdout.write(format(message, ...args) + '\n')
}
}

public logToStderr(message = '', ...args: any[]): void {
if (!this.jsonEnabled()) {
message = typeof message === 'string' ? message : inspect(message)
process.stderr.write(format(message, ...args) + '\n')
stdout.write(format(message, ...args) + '\n')
}
}

Expand Down Expand Up @@ -327,7 +326,7 @@ export abstract class Command {
protected async catch(err: CommandError): Promise<any> {
process.exitCode = process.exitCode ?? err.exitCode ?? 1
if (this.jsonEnabled()) {
ux.styledJSON(this.toErrorJson(err))
this.logJson(this.toErrorJson(err))
} else {
if (!err.message) throw err
try {
Expand All @@ -354,6 +353,10 @@ export abstract class Command {
protected toErrorJson(err: unknown): any {
return {error: err}
}

protected logJson(json: unknown): void {
ux.styledJSON(json)
}
}

export namespace Command {
Expand Down
3 changes: 2 additions & 1 deletion src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import ModuleLoader from '../module-loader'
import {getHelpFlagAdditions} from '../help/util'
import {Command} from '../command'
import {CompletableOptionFlag, Arg} from '../interfaces/parser'
import {stdout} from '../cli-ux/stream'

// eslint-disable-next-line new-cap
const debug = Debug()
Expand Down Expand Up @@ -270,7 +271,7 @@ export class Config implements IConfig {
exit(code)
},
log(message?: any, ...args: any[]) {
process.stdout.write(format(message, ...args) + '\n')
stdout.write(format(message, ...args) + '\n')
},
error(message, options: { code?: string; exit?: number } = {}) {
error(message, options)
Expand Down
2 changes: 1 addition & 1 deletion src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export class Plugin implements IPlugin {
if (!root) throw new Error(`could not find package.json with ${inspect(this.options)}`)
this.root = root
this._debug('reading %s plugin %s', this.type, root)
this.pjson = await loadJSON(path.join(root, 'package.json')) as any
this.pjson = await loadJSON(path.join(root, 'package.json'))
this.name = this.pjson.name
this.alias = this.options.name ?? this.pjson.name
const pjsonPath = path.join(root, 'package.json')
Expand Down
Loading

0 comments on commit 39ea8ea

Please sign in to comment.