Commit 2c8711c

Anton Golub <antongolub@antongolub.com>
2022-05-31 23:06:16
feat: provide customizable logger (#417)
* feat: provide customizable logger * fix: export log as experimental API
v6
1 parent e652772
src/cli.ts
@@ -25,7 +25,8 @@ import { randomId } from './util.js'
 import './globals.js'
 
 await (async function main() {
-  $.verbose = !argv.quiet
+  $.verbose = +(argv.verbose || argv.quiet ? 0 : $.verbose)
+
   if (typeof argv.shell === 'string') {
     $.shell = argv.shell
   }
@@ -220,7 +221,8 @@ function printUsage() {
    zx [options] <script>
 
  Options:
-   --quiet            : don't echo commands
+   --verbose          : verbosity level 0|1|2
+   --quiet            : don't echo commands, same as --verbose=0
    --shell=<path>     : custom shell binary
    --prefix=<command> : prefix all commands
    --experimental     : enable new api proposals
src/context.ts
@@ -15,13 +15,17 @@
 import { AsyncLocalStorage } from 'node:async_hooks'
 
 export type Options = {
-  verbose: boolean
+  verbose: boolean | number
   cwd: string
   env: NodeJS.ProcessEnv
   prefix: string
   shell: string
   maxBuffer: number
   quote: (v: string) => string
+  logOutput?: 'stdout' | 'stderr'
+  logFormat?: (...msg: any[]) => string | string[]
+  logPrint?: (data: any, err?: any) => void
+  logIgnore?: string | string[]
 }
 
 export type Context = Options & {
@@ -46,6 +50,4 @@ export function setRootCtx(ctx: Options) {
 export function getRootCtx() {
   return root
 }
-export function runInCtx(ctx: Options, cb: any) {
-  return storage.run(ctx, cb)
-}
+export const runInCtx = storage.run.bind(storage)
src/core.ts
@@ -24,7 +24,7 @@ import { spawn } from 'node:child_process'
 
 import { chalk, which } from './goods.js'
 import { runInCtx, getCtx, setRootCtx, Context } from './context.js'
-import { printStd, printCmd } from './print.js'
+import { printCmd, log } from './print.js'
 import { quote, substitute } from './guards.js'
 
 import psTreeModule from 'ps-tree'
@@ -61,13 +61,11 @@ export function $(pieces: TemplateStringsArray, ...args: any[]) {
   return promise
 }
 
-setRootCtx($)
-
 $.cwd = process.cwd()
 $.env = process.env
 $.quote = quote
 $.spawn = spawn
-$.verbose = true
+$.verbose = 2
 $.maxBuffer = 200 * 1024 * 1024 /* 200 MiB*/
 $.prefix = '' // Bash not found, no prefix.
 try {
@@ -75,6 +73,8 @@ try {
   $.prefix = 'set -euo pipefail;'
 } catch (e) {}
 
+setRootCtx($)
+
 export class ProcessPromise extends Promise<ProcessOutput> {
   child?: ChildProcessByStdio<Writable, Readable, Readable>
   _resolved = false
@@ -219,12 +219,12 @@ export class ProcessPromise extends Promise<ProcessOutput> {
         stderr = '',
         combined = ''
       let onStdout = (data: any) => {
-        printStd(data)
+        log({ scope: 'cmd', output: 'stdout', raw: true, verbose: 2 }, data)
         stdout += data
         combined += data
       }
       let onStderr = (data: any) => {
-        printStd(null, data)
+        log({ scope: 'cmd', output: 'stderr', raw: true, verbose: 2 }, data)
         stderr += data
         combined += data
       }
src/experimental.ts
@@ -16,6 +16,9 @@ import { ProcessOutput, $ } from './core.js'
 import { sleep } from './goods.js'
 import { isString } from './util.js'
 import { getCtx, runInCtx } from './context.js'
+import { log } from './print.js'
+
+export { log }
 
 // Retries a command a few times. Will return after the first
 // successful attempt, or will throw after specifies attempts count.
@@ -78,13 +81,8 @@ export function startSpinner(title = '') {
   )(setInterval(spin, 100))
 }
 
-export function ctx(
-  cb: Parameters<typeof runInCtx>[1]
-): ReturnType<typeof runInCtx> {
+export function ctx<R extends any>(cb: (_$: typeof $) => R): R {
   const _$ = Object.assign($.bind(null), getCtx())
-  function _cb() {
-    return cb(_$)
-  }
 
-  return runInCtx(_$, _cb)
+  return runInCtx(_$, cb, _$)
 }
src/print.ts
@@ -12,25 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import { getCtx } from './context.js'
+import { getCtx, getRootCtx } from './context.js'
 import { chalk } from './goods.js'
-
-export function printCmd(cmd: string) {
-  if (!getCtx()?.verbose) return
-  if (/\n/.test(cmd)) {
-    console.log(
-      cmd
-        .split('\n')
-        .map((line, i) => (i === 0 ? '$' : '>') + ' ' + colorize(line))
-        .join('\n')
-    )
-  } else {
-    console.log('$', colorize(cmd))
-  }
-}
+import { default as ignore } from 'ignore'
+import { asArray } from './util.js'
 
 export function printStd(data: any, err?: any) {
-  if (!getCtx()?.verbose) return
   if (data) process.stdout.write(data)
   if (err) process.stderr.write(err)
 }
@@ -40,3 +27,46 @@ export function colorize(cmd: string) {
     return chalk.greenBright(substr)
   })
 }
+
+export function log(
+  opts: {
+    scope: string
+    verbose?: 0 | 1 | 2
+    output?: 'stdout' | 'stderr'
+    raw?: boolean
+  },
+  ...msg: any[]
+) {
+  let { scope, verbose = 1, output, raw } = opts
+  let ctx = getCtx()
+  let {
+    logOutput = output,
+    logFormat = () => msg,
+    logPrint = printStd,
+    logIgnore = [],
+  } = ctx
+  let level = Math.min(+getRootCtx().verbose, +ctx.verbose)
+  if (verbose > level) return
+
+  const ig = ignore().add(logIgnore)
+
+  if (!ig.ignores(scope)) {
+    msg = raw ? msg[0] : asArray(logFormat(msg)).join(' ') + '\n'
+    // @ts-ignore
+    logPrint(...(logOutput === 'stdout' ? [msg] : [null, msg]))
+  }
+}
+
+export function printCmd(cmd: string) {
+  if (/\n/.test(cmd)) {
+    log(
+      { scope: 'cmd' },
+      cmd
+        .split('\n')
+        .map((line, i) => (i === 0 ? '$' : '>') + ' ' + colorize(line))
+        .join('\n')
+    )
+  } else {
+    log({ scope: 'cmd' }, '$', colorize(cmd))
+  }
+}
src/util.ts
@@ -19,3 +19,6 @@ export function randomId() {
 export function isString(obj: any) {
   return typeof obj === 'string'
 }
+
+export const asArray = <T>(value: T[] | T | undefined): T[] =>
+  value ? (Array.isArray(value) ? value : [value]) : []
test/cli.test.js
@@ -16,7 +16,7 @@ import { test } from 'uvu'
 import * as assert from 'uvu/assert'
 import '../build/globals.js'
 
-$.verbose = false
+$.verbose = 0
 
 test('supports `-v` flag / prints version', async () => {
   assert.match((await $`node build/cli.js -v`).toString(), /\d+.\d+.\d+/)
test/experimental.test.js
@@ -22,6 +22,7 @@ import {
   startSpinner,
   withTimeout,
   ctx,
+  log,
 } from '../build/experimental.js'
 
 import chalk from 'chalk'
@@ -110,4 +111,8 @@ test('ctx() provides isolates running scopes', async () => {
   $.verbose = false
 })
 
+test('log() API is available', () => {
+  assert.ok(typeof log === 'function')
+})
+
 test.run()
test/log.test.js
@@ -0,0 +1,50 @@
+// Copyright 2022 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { test } from 'uvu'
+import * as assert from 'uvu/assert'
+import { log } from '../build/print.js'
+
+test('logger works', async () => {
+  $.verbose = 1
+  let stdout = ''
+  let stderr = ''
+  $.logFormat = (msg) => msg.map((m) => m.toUpperCase())
+  $.logPrint = (data, err) => {
+    if (data) stdout += data
+    if (err) stderr += err
+  }
+  $.logIgnore = ['b*', 'fetch']
+  log({ scope: 'foo' }, 'foo-test')
+  log({ scope: 'bar' }, 'bar-test')
+  log({ scope: 'baz' }, 'baz-test')
+  log({ scope: 'fetch' }, 'example.com')
+
+  assert.ok(stderr.includes('FOO-TEST'))
+  assert.ok(!stderr.includes('BAR-TEST'))
+  assert.ok(!stderr.includes('BAZ-TEST'))
+  assert.ok(!stderr.includes('EXAMPLE.COM'))
+
+  $.logOutput = 'stdout'
+  log({ scope: 'foo' }, 'foo')
+  assert.ok(stdout.includes('FOO'))
+
+  delete $.logPrint
+  delete $.logFormat
+  delete $.logIgnore
+  delete $.logOutput
+  $.verbose = 0
+})
+
+test.run()
package.json
@@ -49,6 +49,7 @@
     "chalk": "^5.0.1",
     "fs-extra": "^10.1.0",
     "globby": "^13.1.1",
+    "ignore": "^5.2.0",
     "minimist": "^1.2.6",
     "node-fetch": "^3.2.4",
     "ps-tree": "^1.2.0",
README.md
@@ -359,6 +359,49 @@ outputs.
 
 Or use a CLI argument `--quiet` to set `$.verbose = false`.
 
+### `$.logOutput`
+
+Specifies zx debug channel: `stdout/stderr`. Defaults to `stderr`.
+
+### `$.logFormat`
+
+Specifies zx log output formatter. Defaults to `identity`.
+
+```js
+// Nice place to add masker, if you pass creds to zx methods
+$.logFormat = (msg) => msg.map(m => m.toUpperCase())
+```
+
+### `$.logIgnore`
+
+Specifies log events to filter out. Defaults to `''`, so everything is being logged.
+
+```js
+$.logIgnore = ['cd', 'fetch']
+cd('/tmp/foo')
+$.fetch('https://example.com')
+// `$ cd /tmp/foo` is omitted
+// `$ fetch https://example.com` is not printed too
+
+$.logIgnore = 'cmd'
+$`echo 'test'`
+// prints `test` w/o `$ echo 'test'`
+```
+
+### `$.logPrint`
+
+Specifies event logging stream. Defaults to `process.stdout/process.stderr`.
+
+```js
+let stdout = ''
+let stderr = ''
+
+$.logPrint = (data, err) => {
+  if (data) stdout += data
+  if (err) stderr += err
+}
+```
+
 ### `$.env`
 
 Specifies env map. Defaults to `process.env`.
@@ -461,6 +504,18 @@ ctx(async ($) => {
 })
 ```
 
+### `log()`
+
+The logger. Accepts config via `$.log*` options.
+
+```js
+import {log} from 'zx/experimental'
+
+log({scope: 'foo', verbose: 1, output: 'stderr'}, 'some', 'data')
+// some data
+```
+
+
 ## FAQ
 
 ### Passing env variables