main
1// Copyright 2025 Google LLC
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import { chalk, type RequestInfo, type RequestInit } from './vendor-core.ts'
16import { inspect } from 'node:util'
17import { type Buffer } from 'node:buffer'
18import process from 'node:process'
19
20export type LogEntry = {
21 verbose?: boolean
22} & (
23 | {
24 kind: 'cmd'
25 cmd: string
26 cwd: string
27 id: string
28 }
29 | {
30 kind: 'stdout'
31 data: Buffer
32 id: string
33 }
34 | {
35 kind: 'stderr'
36 data: Buffer
37 id: string
38 }
39 | {
40 kind: 'end'
41 exitCode: number | null
42 signal: NodeJS.Signals | null
43 duration: number
44 error: null | Error
45 id: string
46 }
47 | {
48 kind: 'cd'
49 dir: string
50 }
51 | {
52 kind: 'fetch'
53 url: RequestInfo
54 init?: RequestInit
55 }
56 | {
57 kind: 'retry'
58 attempt: number
59 total: number
60 delay: number
61 exception: unknown
62 error?: string
63 }
64 | {
65 kind: 'custom'
66 data: any
67 }
68 | {
69 kind: 'kill'
70 pid: number | `${number}`
71 signal: NodeJS.Signals | null
72 }
73)
74
75type LogFormatters = {
76 [key in LogEntry['kind']]: (
77 entry: Extract<LogEntry, { kind: key }>
78 ) => string | Buffer
79}
80
81const formatters: LogFormatters = {
82 cmd({ cmd }) {
83 return formatCmd(cmd)
84 },
85 stdout({ data }) {
86 return data
87 },
88 stderr({ data }) {
89 return data
90 },
91 custom({ data }) {
92 return data
93 },
94 fetch(entry) {
95 const init = entry.init ? ' ' + inspect(entry.init) : ''
96 return `$ ${chalk.greenBright('fetch')} ${entry.url}${init}\n`
97 },
98 cd(entry) {
99 return `$ ${chalk.greenBright('cd')} ${entry.dir}\n`
100 },
101 retry(entry) {
102 const attempt = `Attempt: ${entry.attempt}${entry.total == Infinity ? '' : `/${entry.total}`}`
103 const delay = entry.delay > 0 ? `; next in ${entry.delay}ms` : ''
104
105 return `${chalk.bgRed.white(' FAIL ')} ${attempt}${delay}\n`
106 },
107 end() {
108 return ''
109 },
110 kill() {
111 return ''
112 },
113}
114
115type Log = {
116 (entry: LogEntry): void
117 formatters?: Partial<LogFormatters>
118 output?: NodeJS.WriteStream
119}
120
121export const log: Log = function (entry) {
122 if (!entry.verbose) return
123 const stream = log.output || process.stderr
124 const format = (log.formatters?.[entry.kind] || formatters[entry.kind]) as (
125 entry: LogEntry
126 ) => string | Buffer
127 if (!format) return // ignore unknown log entries
128
129 stream.write(format(entry))
130}
131
132const SPACE_RE = /\s/
133const SYNTAX = '()[]{}<>;:+|&='
134const CMD_BREAK = '|&;><'
135const RESERVED_WORDS = new Set([
136 'if',
137 'then',
138 'else',
139 'elif',
140 'fi',
141 'case',
142 'esac',
143 'for',
144 'select',
145 'while',
146 'until',
147 'do',
148 'done',
149 'in',
150 'EOF',
151])
152
153export function formatCmd(cmd: string): string {
154 if (cmd == undefined) return chalk.grey('undefined')
155 let q = ''
156 let out = '$ '
157 let buf = ''
158 let mode: 'syntax' | 'quote' | 'dollar' | '' = ''
159 let pos = 0
160 const cap = () => {
161 const word = buf.trim()
162 if (word) {
163 pos++
164 if (mode === 'syntax') {
165 if (CMD_BREAK.includes(word)) {
166 pos = 0
167 }
168 out += chalk.red(buf)
169 } else if (mode === 'quote' || mode === 'dollar') {
170 out += chalk.yellowBright(buf)
171 } else if (RESERVED_WORDS.has(word)) {
172 out += chalk.cyanBright(buf)
173 } else if (pos === 1) {
174 out += chalk.greenBright(buf)
175 pos = Infinity
176 } else {
177 out += buf
178 }
179 } else {
180 out += buf
181 }
182 mode = ''
183 buf = ''
184 }
185
186 for (const c of [...cmd]) {
187 if (!q) {
188 if (c === '$') {
189 cap()
190 mode = 'dollar'
191 buf += c
192 cap()
193 } else if (c === "'" || c === '"') {
194 cap()
195 mode = 'quote'
196 q = c
197 buf += c
198 } else if (SPACE_RE.test(c)) {
199 cap()
200 buf += c
201 } else if (SYNTAX.includes(c)) {
202 const isEnv = c === '=' && pos === 0
203 isEnv && (pos = 1)
204 cap()
205 mode = 'syntax'
206 buf += c
207 cap()
208 isEnv && (pos = -1)
209 } else {
210 buf += c
211 }
212 } else {
213 buf += c
214 if (c === q) {
215 cap()
216 q = ''
217 }
218 }
219 }
220 cap()
221 return out.replaceAll('\n', chalk.reset('\n> ')) + '\n'
222}