Commit 85c260c
Changed files (10)
docs/configuration.md
@@ -84,7 +84,7 @@ all `$` processes use `process.cwd()` by default (same as `spawn` behavior).
## `$.log`
-Specifies a [logging function](src/core.ts).
+Specifies a [logging function](src/log.ts).
```ts
import {LogEntry, log} from 'zx/core'
@@ -101,6 +101,19 @@ $.log = (entry: LogEntry) => {
}
```
+Log mostly acts like a debugger, so by default it uses `process.error` for output.
+Set `log.output` to change the stream.
+
+```ts
+$.log.output = process.stdout
+```
+
+Set `log.formatCmd` to customize the command highlighter:
+
+```ts
+$.log.formatCmd = (cmd: string) => chalk.bgRedBright.black(cmd)
+```
+
## `$.timeout`
Specifies a timeout for the command execution.
src/core.ts
@@ -44,7 +44,6 @@ import {
} from './vendor-core.ts'
import {
type Duration,
- log,
isString,
isStringLiteral,
getLast,
@@ -62,7 +61,9 @@ import {
bufArrJoin,
} from './util.ts'
-export { log, type LogEntry } from './util.ts'
+import { log } from './log.ts'
+
+export { log, type LogEntry } from './log.ts'
const CWD = Symbol('processCwd')
const SYNC = Symbol('syncExec')
src/log.ts
@@ -0,0 +1,187 @@
+// Copyright 2025 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 { chalk, type RequestInfo, type RequestInit } from './vendor-core.ts'
+import { inspect } from 'node:util'
+
+export type LogEntry = {
+ verbose?: boolean
+} & (
+ | {
+ kind: 'cmd'
+ cmd: string
+ id: string
+ }
+ | {
+ kind: 'stdout' | 'stderr'
+ data: Buffer
+ id: string
+ }
+ | {
+ kind: 'end'
+ exitCode: number | null
+ signal: NodeJS.Signals | null
+ duration: number
+ error: null | Error
+ id: string
+ }
+ | {
+ kind: 'cd'
+ dir: string
+ }
+ | {
+ kind: 'fetch'
+ url: RequestInfo
+ init?: RequestInit
+ }
+ | {
+ kind: 'retry'
+ attempt: number
+ total: number
+ delay: number
+ exception: unknown
+ error?: string
+ }
+ | {
+ kind: 'custom'
+ data: any
+ }
+)
+
+type LogFormatter = (cmd?: string) => string
+type Log = {
+ (entry: LogEntry): void
+ formatCmd?: LogFormatter
+ output?: NodeJS.WriteStream
+}
+export const log: Log = function (entry) {
+ if (!entry.verbose) return
+ const stream = log.output || process.stderr
+ switch (entry.kind) {
+ case 'cmd':
+ stream.write((log.formatCmd || formatCmd)(entry.cmd))
+ break
+ case 'stdout':
+ case 'stderr':
+ case 'custom':
+ stream.write(entry.data)
+ break
+ case 'cd':
+ stream.write('$ ' + chalk.greenBright('cd') + ` ${entry.dir}\n`)
+ break
+ case 'fetch':
+ const init = entry.init ? ' ' + inspect(entry.init) : ''
+ stream.write('$ ' + chalk.greenBright('fetch') + ` ${entry.url}${init}\n`)
+ break
+ case 'retry':
+ stream.write(
+ chalk.bgRed.white(' FAIL ') +
+ ` Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}` +
+ (entry.delay > 0 ? `; next in ${entry.delay}ms` : '') +
+ '\n'
+ )
+ }
+}
+
+const SYNTAX = '()[]{}<>;:+|&='
+const CMD_BREAK = new Set(['|', '&', ';', '>', '<'])
+const SPACE_RE = /\s/
+const RESERVED_WORDS = new Set([
+ 'if',
+ 'then',
+ 'else',
+ 'elif',
+ 'fi',
+ 'case',
+ 'esac',
+ 'for',
+ 'select',
+ 'while',
+ 'until',
+ 'do',
+ 'done',
+ 'in',
+ 'EOF',
+])
+
+export const formatCmd: LogFormatter = function (cmd) {
+ if (cmd == undefined) return chalk.grey('undefined')
+ let q = ''
+ let out = '$ '
+ let buf = ''
+ let mode: 'syntax' | 'quote' | 'dollar' | '' = ''
+ let pos = 0
+ const cap = () => {
+ const word = buf.trim()
+ if (word) {
+ pos++
+ if (mode === 'syntax') {
+ if (CMD_BREAK.has(word)) {
+ pos = 0
+ }
+ out += chalk.red(buf)
+ } else if (mode === 'quote' || mode === 'dollar') {
+ out += chalk.yellowBright(buf)
+ } else if (RESERVED_WORDS.has(word)) {
+ out += chalk.cyanBright(buf)
+ } else if (pos === 1) {
+ out += chalk.greenBright(buf)
+ pos = Infinity
+ } else {
+ out += buf
+ }
+ } else {
+ out += buf
+ }
+ mode = ''
+ buf = ''
+ }
+
+ for (const c of [...cmd]) {
+ if (!q) {
+ if (c === '$') {
+ cap()
+ mode = 'dollar'
+ buf += c
+ cap()
+ } else if (c === "'" || c === '"') {
+ cap()
+ mode = 'quote'
+ q = c
+ buf += c
+ } else if (SPACE_RE.test(c)) {
+ cap()
+ buf += c
+ } else if (SYNTAX.includes(c)) {
+ const isEnv = c === '=' && pos === 0
+ isEnv && (pos = 1)
+ cap()
+ mode = 'syntax'
+ buf += c
+ cap()
+ isEnv && (pos = -1)
+ } else {
+ buf += c
+ }
+ } else {
+ buf += c
+ if (c === q) {
+ cap()
+ q = ''
+ }
+ }
+ }
+ cap()
+ return out.replaceAll('\n', chalk.reset('\n> ')) + '\n'
+}
src/util.ts
@@ -15,13 +15,7 @@
import os from 'node:os'
import path from 'node:path'
import fs, { type Mode } from 'node:fs'
-import {
- chalk,
- type RequestInfo,
- type RequestInit,
- type TSpawnStoreChunks,
-} from './vendor-core.ts'
-import { inspect } from 'node:util'
+import { type TSpawnStoreChunks } from './vendor-core.ts'
export { isStringLiteral } from './vendor-core.ts'
@@ -162,197 +156,6 @@ export function parseDuration(d: Duration) {
throw new Error(`Unknown duration: "${d}".`)
}
-export type LogEntry = {
- verbose?: boolean
-} & (
- | {
- kind: 'cmd'
- cmd: string
- id: string
- }
- | {
- kind: 'stdout' | 'stderr'
- data: Buffer
- id: string
- }
- | {
- kind: 'end'
- exitCode: number | null
- signal: NodeJS.Signals | null
- duration: number
- error: null | Error
- id: string
- }
- | {
- kind: 'cd'
- dir: string
- }
- | {
- kind: 'fetch'
- url: RequestInfo
- init?: RequestInit
- }
- | {
- kind: 'retry'
- attempt: number
- total: number
- delay: number
- exception: unknown
- error?: string
- }
- | {
- kind: 'custom'
- data: any
- }
-)
-
-export function log(entry: LogEntry) {
- if (!entry.verbose) return
- const stream = process.stderr
- switch (entry.kind) {
- case 'cmd':
- stream.write(formatCmd(entry.cmd))
- break
- case 'stdout':
- case 'stderr':
- case 'custom':
- stream.write(entry.data)
- break
- case 'cd':
- stream.write('$ ' + chalk.greenBright('cd') + ` ${entry.dir}\n`)
- break
- case 'fetch':
- const init = entry.init ? ' ' + inspect(entry.init) : ''
- stream.write('$ ' + chalk.greenBright('fetch') + ` ${entry.url}${init}\n`)
- break
- case 'retry':
- stream.write(
- chalk.bgRed.white(' FAIL ') +
- ` Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}` +
- (entry.delay > 0 ? `; next in ${entry.delay}ms` : '') +
- '\n'
- )
- }
-}
-
-export function formatCmd(cmd?: string): string {
- if (cmd == undefined) return chalk.grey('undefined')
- const chars = [...cmd]
- let out = '$ '
- let buf = ''
- let ch: string
- type State = (() => State) | undefined
- let state: State = root
- let wordCount = 0
- while (state) {
- ch = chars.shift() || 'EOF'
- if (ch == '\n') {
- out += style(state, buf) + '\n> '
- buf = ''
- continue
- }
- const next: State = ch === 'EOF' ? undefined : state()
- if (next !== state) {
- out += style(state, buf)
- buf = ''
- }
- state = next === root ? next() : next
- buf += ch
- }
-
- function style(state: State, s: string): string {
- if (s === '') return ''
- if (RESERVED_WORDS.has(s)) {
- return chalk.cyanBright(s)
- }
- if (state == word && wordCount == 0) {
- wordCount++
- return chalk.greenBright(s)
- }
- if (state == syntax) {
- wordCount = 0
- return chalk.cyanBright(s)
- }
- if (state == dollar) return chalk.yellowBright(s)
- if (state?.name.startsWith('str')) return chalk.yellowBright(s)
- return s
- }
-
- function isSyntax(ch: string) {
- return '()[]{}<>;:+|&='.includes(ch)
- }
-
- function root() {
- if (/\s/.test(ch)) return space
- if (isSyntax(ch)) return syntax
- if (ch === '$') return dollar
- if (ch === '"') return strDouble
- if (ch === "'") return strSingle
- return word
- }
-
- function space() {
- return /\s/.test(ch) ? space : root
- }
-
- function word() {
- return /[\w/.]/i.test(ch) ? word : root
- }
-
- function syntax() {
- return isSyntax(ch) ? syntax : root
- }
-
- function dollar() {
- return ch === "'" ? str : root
- }
-
- function str() {
- if (ch === "'") return strEnd
- if (ch === '\\') return strBackslash
- return str
- }
-
- function strBackslash() {
- return strEscape
- }
-
- function strEscape() {
- return str
- }
-
- function strDouble() {
- return ch === '"' ? strEnd : strDouble
- }
-
- function strSingle() {
- return ch === "'" ? strEnd : strSingle
- }
-
- function strEnd() {
- return root
- }
-
- return out + '\n'
-}
-
-const RESERVED_WORDS = new Set([
- 'if',
- 'then',
- 'else',
- 'elif',
- 'fi',
- 'case',
- 'esac',
- 'for',
- 'select',
- 'while',
- 'until',
- 'do',
- 'done',
- 'in',
-])
-
export const once = <T extends (...args: any[]) => any>(fn: T) => {
let called = false
let result: ReturnType<T>
test/all.test.js
@@ -20,6 +20,7 @@ import './export.test.js'
import './global.test.js'
import './goods.test.js'
import './index.test.js'
+import './log.test.ts'
import './md.test.ts'
import './package.test.js'
import './util.test.js'
test/log.test.ts
@@ -0,0 +1,154 @@
+// Copyright 2025 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 assert from 'node:assert'
+import { test, describe, beforeEach, before, after } from 'node:test'
+import { formatCmd, log } from '../src/log.ts'
+
+describe('log', () => {
+ describe('log()', () => {
+ const data = []
+ const stream = {
+ write(s: string) {
+ data.push(s)
+ },
+ } as NodeJS.WriteStream
+
+ before(() => (log.output = stream))
+
+ after(() => delete log.output)
+
+ beforeEach(() => (data.length = 0))
+
+ test('cmd', () => {
+ log({
+ kind: 'cmd',
+ cmd: 'echo hi',
+ id: '1',
+ verbose: true,
+ })
+ assert.equal(data.join(''), '$ \x1B[92mecho\x1B[39m hi\n')
+ })
+
+ test('stdout', () => {
+ log({
+ kind: 'stdout',
+ data: Buffer.from('foo'),
+ id: '1',
+ verbose: true,
+ })
+ assert.equal(data.join(''), 'foo')
+ })
+
+ test('cd', () => {
+ log({
+ kind: 'cd',
+ dir: '/tmp',
+ verbose: true,
+ })
+ assert.equal(data.join(''), '$ \x1B[92mcd\x1B[39m /tmp\n')
+ })
+
+ test('fetch', () => {
+ log({
+ kind: 'fetch',
+ url: 'https://example.com',
+ init: { method: 'GET' },
+ verbose: true,
+ })
+ assert.equal(
+ data.join(''),
+ "$ \x1B[92mfetch\x1B[39m https://example.com { method: 'GET' }\n"
+ )
+ })
+
+ test('retry', () => {
+ log({
+ kind: 'retry',
+ attempt: 1,
+ total: 3,
+ delay: 1000,
+ exception: new Error('foo'),
+ error: 'bar',
+ verbose: true,
+ })
+ assert.equal(
+ data.join(''),
+ '\x1B[41m\x1B[37m FAIL \x1B[39m\x1B[49m Attempt: 1/3; next in 1000ms\n'
+ )
+ })
+ })
+
+ test('formatCwd()', () => {
+ const cases = [
+ [
+ `echo $'hi'`,
+ "$ \x1B[92mecho\x1B[39m \x1B[93m$\x1B[39m\x1B[93m'hi'\x1B[39m\n",
+ ],
+ [`echo$foo`, '$ \x1B[92mecho\x1B[39m\x1B[93m$\x1B[39mfoo\n'],
+ [
+ `test --foo=bar p1 p2`,
+ '$ \x1B[92mtest\x1B[39m --foo\x1B[31m=\x1B[39mbar p1 p2\n',
+ ],
+ [
+ `cmd1 --foo || cmd2`,
+ '$ \x1B[92mcmd1\x1B[39m --foo \x1B[31m|\x1B[39m\x1B[31m|\x1B[39m\x1B[92m cmd2\x1B[39m\n',
+ ],
+ [
+ `A=B C='D' cmd`,
+ "$ A\x1B[31m=\x1B[39mB C\x1B[31m=\x1B[39m\x1B[93m'D'\x1B[39m\x1B[92m cmd\x1B[39m\n",
+ ],
+ [
+ `foo-extra --baz = b-a-z --bar = 'b-a-r' -q -u x`,
+ "$ \x1B[92mfoo-extra\x1B[39m --baz \x1B[31m=\x1B[39m b-a-z --bar \x1B[31m=\x1B[39m \x1B[93m'b-a-r'\x1B[39m -q -u x\n",
+ ],
+ [
+ `while true; do "$" done`,
+ '$ \x1B[96mwhile\x1B[39m true\x1B[31m;\x1B[39m\x1B[96m do\x1B[39m \x1B[93m"$"\x1B[39m\x1B[96m done\x1B[39m\n',
+ ],
+ [
+ `echo '\n str\n'`,
+ "$ \x1B[92mecho\x1B[39m \x1B[93m'\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m str\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[93m'\x1B[39m\n",
+ ],
+ [`$'\\''`, "$ \x1B[93m$\x1B[39m\x1B[93m'\\'\x1B[39m\x1B[93m'\x1B[39m\n"],
+ [
+ 'sass-compiler --style=compressed src/static/bootstrap.scss > dist/static/bootstrap-v5.3.3.min.css',
+ '$ \x1B[92msass-compiler\x1B[39m --style\x1B[31m=\x1B[39mcompressed src/static/bootstrap.scss \x1B[31m>\x1B[39m\x1B[92m dist/static/bootstrap-v5.3.3.min.css\x1B[39m\n',
+ ],
+ [
+ 'echo 1+2 | bc',
+ '$ \x1B[92mecho\x1B[39m 1\x1B[31m+\x1B[39m2 \x1B[31m|\x1B[39m\x1B[92m bc\x1B[39m\n',
+ ],
+ [
+ 'echo test &>> filepath',
+ '$ \x1B[92mecho\x1B[39m test \x1B[31m&\x1B[39m\x1B[31m>\x1B[39m\x1B[31m>\x1B[39m\x1B[92m filepath\x1B[39m\n',
+ ],
+ [
+ 'bc < filepath',
+ '$ \x1B[92mbc\x1B[39m \x1B[31m<\x1B[39m\x1B[92m filepath\x1B[39m\n',
+ ],
+ [
+ `cat << 'EOF' | tee -a filepath
+line 1
+line 2
+EOF`,
+ "$ \x1B[92mcat\x1B[39m \x1B[31m<\x1B[39m\x1B[31m<\x1B[39m \x1B[93m'EOF'\x1B[39m \x1B[31m|\x1B[39m\x1B[92m tee\x1B[39m -a filepath\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0mline 1\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0mline 2\x1B[96m\x1B[39m\x1B[0m\x1B[0m\n\x1B[0m> \x1B[0m\x1B[96mEOF\x1B[39m\n",
+ ],
+ ]
+
+ cases.forEach(([input, expected]) => {
+ assert.equal(formatCmd(input), expected, input)
+ })
+ })
+})
test/package.test.js
@@ -62,6 +62,7 @@ describe('package', () => {
'build/index.cjs',
'build/index.d.ts',
'build/index.js',
+ 'build/log.d.ts',
'build/md.d.ts',
'build/util.cjs',
'build/util.d.ts',
test/util.test.js
@@ -14,10 +14,8 @@
import assert from 'node:assert'
import fs from 'node:fs'
-import { test, describe, after } from 'node:test'
-import { fs as fsCore } from '../build/index.js'
+import { test, describe } from 'node:test'
import {
- formatCmd,
isString,
isStringLiteral,
noop,
@@ -84,25 +82,6 @@ describe('util', () => {
assert.throws(() => parseDuration(-1))
})
- test('formatCwd works', () => {
- assert.equal(
- formatCmd(`echo $'hi'`),
- "$ \u001b[92mecho\u001b[39m \u001b[93m$\u001b[39m\u001b[93m'hi\u001b[39m\u001b[93m'\u001b[39m\n"
- )
- assert.equal(
- formatCmd(`while true; do "$" done`),
- '$ \u001b[96mwhile\u001b[39m \u001b[92mtrue\u001b[39m\u001b[96m;\u001b[39m \u001b[96mdo\u001b[39m \u001b[93m"$\u001b[39m\u001b[93m"\u001b[39m \u001b[96mdone\u001b[39m\n'
- )
- assert.equal(
- formatCmd(`echo '\n str\n'`),
- "$ \u001b[92mecho\u001b[39m \u001b[93m'\u001b[39m\n> \u001b[93m str\u001b[39m\n> \u001b[93m'\u001b[39m\n"
- )
- assert.equal(
- formatCmd(`$'\\''`),
- "$ \u001b[93m$\u001b[39m\u001b[93m'\u001b[39m\u001b[93m\\\u001b[39m\u001b[93m'\u001b[39m\u001b[93m'\u001b[39m\n"
- )
- })
-
// test('normalizeMultilinePieces()', () => {
// assert.equal(
// normalizeMultilinePieces([' a ', 'b c d', ' e']).join(','),
.size-limit.json
@@ -16,7 +16,7 @@
{
"name": "dts libdefs",
"path": "build/*.d.ts",
- "limit": "39 kB",
+ "limit": "39.15 kB",
"brotli": false,
"gzip": false
},
@@ -30,7 +30,7 @@
{
"name": "all",
"path": "build/*",
- "limit": "851.1 kB",
+ "limit": "850.8 kB",
"brotli": false,
"gzip": false
}
package.json
@@ -63,7 +63,7 @@
"fmt": "prettier --write .",
"fmt:check": "prettier --check .",
"build": "npm run build:js && npm run build:dts && npm run build:tests",
- "build:js": "node scripts/build-js.mjs --format=cjs --hybrid --entry=src/*.ts:!src/error.ts:!src/repl.ts:!src/md.ts && npm run build:vendor",
+ "build:js": "node scripts/build-js.mjs --format=cjs --hybrid --entry=src/*.ts:!src/error.ts:!src/repl.ts:!src/md.ts:!src/log.ts && npm run build:vendor",
"build:vendor": "node scripts/build-js.mjs --format=cjs --entry=src/vendor-*.ts --bundle=all",
"build:tests": "node scripts/build-tests.mjs",
"build:dts": "tsc --project tsconfig.json && rm build/error.d.ts build/repl.d.ts && node scripts/build-dts.mjs",