@remix-run/cli 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -3
  3. package/bootstrap/AGENTS.md +35 -0
  4. package/bootstrap/README.md +27 -0
  5. package/bootstrap/app/controllers/auth.tsx +21 -0
  6. package/bootstrap/app/controllers/home.tsx +26 -0
  7. package/bootstrap/app/router.ts +10 -0
  8. package/bootstrap/app/routes.ts +6 -0
  9. package/bootstrap/app/ui/document.tsx +21 -0
  10. package/bootstrap/app/ui/layout.tsx +22 -0
  11. package/bootstrap/app/utils/render.tsx +7 -0
  12. package/bootstrap/package.json +22 -0
  13. package/bootstrap/server.ts +37 -0
  14. package/bootstrap/tsconfig.json +19 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +1 -0
  18. package/dist/lib/bootstrap-project.d.ts +17 -0
  19. package/dist/lib/bootstrap-project.d.ts.map +1 -0
  20. package/dist/lib/bootstrap-project.js +137 -0
  21. package/dist/lib/cli-context.d.ts +11 -0
  22. package/dist/lib/cli-context.d.ts.map +1 -0
  23. package/dist/lib/cli-context.js +19 -0
  24. package/dist/lib/cli.d.ts +6 -0
  25. package/dist/lib/cli.d.ts.map +1 -0
  26. package/dist/lib/cli.js +87 -0
  27. package/dist/lib/commands/completion.d.ts +3 -0
  28. package/dist/lib/commands/completion.d.ts.map +1 -0
  29. package/dist/lib/commands/completion.js +51 -0
  30. package/dist/lib/commands/doctor.d.ts +4 -0
  31. package/dist/lib/commands/doctor.d.ts.map +1 -0
  32. package/dist/lib/commands/doctor.js +293 -0
  33. package/dist/lib/commands/help.d.ts +4 -0
  34. package/dist/lib/commands/help.d.ts.map +1 -0
  35. package/dist/lib/commands/help.js +124 -0
  36. package/dist/lib/commands/new.d.ts +4 -0
  37. package/dist/lib/commands/new.d.ts.map +1 -0
  38. package/dist/lib/commands/new.js +70 -0
  39. package/dist/lib/commands/routes.d.ts +4 -0
  40. package/dist/lib/commands/routes.d.ts.map +1 -0
  41. package/dist/lib/commands/routes.js +161 -0
  42. package/dist/lib/commands/skills.d.ts +6 -0
  43. package/dist/lib/commands/skills.d.ts.map +1 -0
  44. package/dist/lib/commands/skills.js +222 -0
  45. package/dist/lib/commands/test.d.ts +4 -0
  46. package/dist/lib/commands/test.d.ts.map +1 -0
  47. package/dist/lib/commands/test.js +19 -0
  48. package/dist/lib/commands/version.d.ts +4 -0
  49. package/dist/lib/commands/version.d.ts.map +1 -0
  50. package/dist/lib/commands/version.js +29 -0
  51. package/dist/lib/completion.d.ts +12 -0
  52. package/dist/lib/completion.d.ts.map +1 -0
  53. package/dist/lib/completion.js +408 -0
  54. package/dist/lib/contained-path.d.ts +2 -0
  55. package/dist/lib/contained-path.d.ts.map +1 -0
  56. package/dist/lib/contained-path.js +10 -0
  57. package/dist/lib/controller-files.d.ts +14 -0
  58. package/dist/lib/controller-files.d.ts.map +1 -0
  59. package/dist/lib/controller-files.js +53 -0
  60. package/dist/lib/controller-ownership.d.ts +41 -0
  61. package/dist/lib/controller-ownership.d.ts.map +1 -0
  62. package/dist/lib/controller-ownership.js +224 -0
  63. package/dist/lib/display-path.d.ts +2 -0
  64. package/dist/lib/display-path.d.ts.map +1 -0
  65. package/dist/lib/display-path.js +12 -0
  66. package/dist/lib/doctor/controller-findings.d.ts +4 -0
  67. package/dist/lib/doctor/controller-findings.d.ts.map +1 -0
  68. package/dist/lib/doctor/controller-findings.js +132 -0
  69. package/dist/lib/doctor/controller-fix-plans.d.ts +4 -0
  70. package/dist/lib/doctor/controller-fix-plans.d.ts.map +1 -0
  71. package/dist/lib/doctor/controller-fix-plans.js +130 -0
  72. package/dist/lib/doctor/controller-placeholders.d.ts +4 -0
  73. package/dist/lib/doctor/controller-placeholders.d.ts.map +1 -0
  74. package/dist/lib/doctor/controller-placeholders.js +183 -0
  75. package/dist/lib/doctor/controllers.d.ts +8 -0
  76. package/dist/lib/doctor/controllers.d.ts.map +1 -0
  77. package/dist/lib/doctor/controllers.js +13 -0
  78. package/dist/lib/doctor/environment.d.ts +17 -0
  79. package/dist/lib/doctor/environment.d.ts.map +1 -0
  80. package/dist/lib/doctor/environment.js +226 -0
  81. package/dist/lib/doctor/fixes.d.ts +3 -0
  82. package/dist/lib/doctor/fixes.d.ts.map +1 -0
  83. package/dist/lib/doctor/fixes.js +65 -0
  84. package/dist/lib/doctor/project.d.ts +10 -0
  85. package/dist/lib/doctor/project.d.ts.map +1 -0
  86. package/dist/lib/doctor/project.js +371 -0
  87. package/dist/lib/doctor/types.d.ts +48 -0
  88. package/dist/lib/doctor/types.d.ts.map +1 -0
  89. package/dist/lib/doctor/types.js +15 -0
  90. package/dist/lib/errors.d.ts +173 -0
  91. package/dist/lib/errors.d.ts.map +1 -0
  92. package/dist/lib/errors.js +318 -0
  93. package/dist/lib/help-text.d.ts +13 -0
  94. package/dist/lib/help-text.d.ts.map +1 -0
  95. package/dist/lib/help-text.js +43 -0
  96. package/dist/lib/load-route-map-worker.d.ts +2 -0
  97. package/dist/lib/load-route-map-worker.d.ts.map +1 -0
  98. package/dist/lib/load-route-map-worker.js +92 -0
  99. package/dist/lib/parse-args.d.ts +22 -0
  100. package/dist/lib/parse-args.d.ts.map +1 -0
  101. package/dist/lib/parse-args.js +35 -0
  102. package/dist/lib/remix-version.d.ts +2 -0
  103. package/dist/lib/remix-version.d.ts.map +1 -0
  104. package/dist/lib/remix-version.js +58 -0
  105. package/dist/lib/reporter.d.ts +56 -0
  106. package/dist/lib/reporter.d.ts.map +1 -0
  107. package/dist/lib/reporter.js +357 -0
  108. package/dist/lib/route-map.d.ts +38 -0
  109. package/dist/lib/route-map.d.ts.map +1 -0
  110. package/dist/lib/route-map.js +195 -0
  111. package/dist/lib/skills-cache.d.ts +19 -0
  112. package/dist/lib/skills-cache.d.ts.map +1 -0
  113. package/dist/lib/skills-cache.js +89 -0
  114. package/dist/lib/skills.d.ts +30 -0
  115. package/dist/lib/skills.d.ts.map +1 -0
  116. package/dist/lib/skills.js +441 -0
  117. package/dist/lib/terminal.d.ts +17 -0
  118. package/dist/lib/terminal.d.ts.map +1 -0
  119. package/dist/lib/terminal.js +66 -0
  120. package/package.json +48 -5
  121. package/src/index.ts +1 -0
  122. package/src/lib/bootstrap-project.ts +222 -0
  123. package/src/lib/cli-context.ts +38 -0
  124. package/src/lib/cli.ts +120 -0
  125. package/src/lib/commands/completion.ts +74 -0
  126. package/src/lib/commands/doctor.ts +393 -0
  127. package/src/lib/commands/help.ts +157 -0
  128. package/src/lib/commands/new.ts +104 -0
  129. package/src/lib/commands/routes.ts +251 -0
  130. package/src/lib/commands/skills.ts +306 -0
  131. package/src/lib/commands/test.ts +25 -0
  132. package/src/lib/commands/version.ts +39 -0
  133. package/src/lib/completion.ts +561 -0
  134. package/src/lib/contained-path.ts +13 -0
  135. package/src/lib/controller-files.ts +79 -0
  136. package/src/lib/controller-ownership.ts +397 -0
  137. package/src/lib/display-path.ts +16 -0
  138. package/src/lib/doctor/controller-findings.ts +157 -0
  139. package/src/lib/doctor/controller-fix-plans.ts +184 -0
  140. package/src/lib/doctor/controller-placeholders.ts +242 -0
  141. package/src/lib/doctor/controllers.ts +23 -0
  142. package/src/lib/doctor/environment.ts +289 -0
  143. package/src/lib/doctor/fixes.ts +84 -0
  144. package/src/lib/doctor/project.ts +435 -0
  145. package/src/lib/doctor/types.ts +95 -0
  146. package/src/lib/errors.ts +379 -0
  147. package/src/lib/help-text.ts +88 -0
  148. package/src/lib/load-route-map-worker.ts +110 -0
  149. package/src/lib/parse-args.ts +82 -0
  150. package/src/lib/remix-version.ts +80 -0
  151. package/src/lib/reporter.ts +535 -0
  152. package/src/lib/route-map.ts +303 -0
  153. package/src/lib/skills-cache.ts +140 -0
  154. package/src/lib/skills.ts +706 -0
  155. package/src/lib/terminal.ts +86 -0
@@ -0,0 +1,80 @@
1
+ import * as fs from 'node:fs/promises'
2
+ import { createRequire } from 'node:module'
3
+ import * as path from 'node:path'
4
+ import * as process from 'node:process'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ interface RemixPackageJson {
8
+ name?: unknown
9
+ version?: unknown
10
+ }
11
+
12
+ export async function resolveDefaultRemixVersion(
13
+ cwd: string = process.cwd(),
14
+ ): Promise<string | undefined> {
15
+ for (let packageJsonPath of getRemixPackageJsonCandidates(cwd)) {
16
+ let version = await readRemixPackageVersion(packageJsonPath)
17
+ if (version != null) {
18
+ return version
19
+ }
20
+ }
21
+ }
22
+
23
+ function getRemixPackageJsonCandidates(cwd: string): string[] {
24
+ let candidates: string[] = []
25
+ let seen = new Set<string>()
26
+
27
+ function addCandidate(filePath: string | undefined): void {
28
+ if (filePath == null) {
29
+ return
30
+ }
31
+
32
+ let resolvedPath = path.resolve(filePath)
33
+ if (seen.has(resolvedPath)) {
34
+ return
35
+ }
36
+
37
+ seen.add(resolvedPath)
38
+ candidates.push(resolvedPath)
39
+ }
40
+
41
+ function addResolvedPackageJson(basePath: string): void {
42
+ try {
43
+ addCandidate(createRequire(basePath).resolve('remix/package.json'))
44
+ } catch {}
45
+ }
46
+
47
+ addResolvedPackageJson(path.join(cwd, 'package.json'))
48
+
49
+ if (process.argv[1] != null) {
50
+ addResolvedPackageJson(path.resolve(process.argv[1]))
51
+ }
52
+
53
+ addResolvedPackageJson(import.meta.url)
54
+ addCandidate(
55
+ path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../remix/package.json'),
56
+ )
57
+
58
+ return candidates
59
+ }
60
+
61
+ async function readRemixPackageVersion(packageJsonPath: string): Promise<string | undefined> {
62
+ let packageJson: RemixPackageJson
63
+
64
+ try {
65
+ packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as RemixPackageJson
66
+ } catch {
67
+ return undefined
68
+ }
69
+
70
+ if (packageJson.name !== 'remix') {
71
+ return undefined
72
+ }
73
+
74
+ if (typeof packageJson.version !== 'string') {
75
+ return undefined
76
+ }
77
+
78
+ let version = packageJson.version.trim()
79
+ return version.length === 0 ? undefined : version
80
+ }
@@ -0,0 +1,535 @@
1
+ import * as process from 'node:process'
2
+
3
+ import {
4
+ bold,
5
+ canUseAnsi,
6
+ clearCurrentLine,
7
+ lightGray,
8
+ lightGreen,
9
+ lightRed,
10
+ lightYellow,
11
+ remixWordmark,
12
+ } from './terminal.ts'
13
+
14
+ const BULLET = '•'
15
+ const DOT_FRAMES = ['.', '..', '...'] as const
16
+ const DEFAULT_INDENT = ' '
17
+ const DEFAULT_STATUS_FRAME_INTERVAL_MS = 250
18
+
19
+ interface StepProgressLabel {
20
+ complete: string
21
+ running?: string
22
+ }
23
+
24
+ export interface CommandReporter {
25
+ finish(): void
26
+ out: TextChannel
27
+ status: StatusChannel
28
+ }
29
+
30
+ export interface TableOptions {
31
+ headers: string[]
32
+ noHeaders?: boolean
33
+ rows: string[][]
34
+ formatRow?: (line: string, rowIndex: number) => string
35
+ }
36
+
37
+ export interface TextChannel {
38
+ blank(): void
39
+ bullet(text: string): void
40
+ bullets(items: string[]): void
41
+ dedent(): void
42
+ indent(): void
43
+ label(tag: string, text: string, options?: LabelOptions): string
44
+ line(text?: string): void
45
+ section(title: string, callback?: () => void): void
46
+ table(options: TableOptions): void
47
+ withIndent<result>(callback: () => result): result
48
+ }
49
+
50
+ export interface StatusChannel extends TextChannel {
51
+ commandHeader(commandLabel: string): Promise<void>
52
+ failStep(label?: string): void
53
+ skipStep(label: string, reason?: string): void
54
+ startStep(label: string): void
55
+ succeedStep(label?: string): void
56
+ summaryGap(): void
57
+ }
58
+
59
+ export interface LabelOptions {
60
+ tone?: 'error' | 'warn'
61
+ }
62
+
63
+ export interface CreateCommandReporterOptions {
64
+ remixVersion?: string
65
+ stderr?: NodeJS.WriteStream
66
+ statusFrameIntervalMs?: number
67
+ stdout?: NodeJS.WriteStream
68
+ }
69
+
70
+ export interface StepProgressReporter<step extends string> {
71
+ fail(step: step): void
72
+ skip(step: step, reason?: string): void
73
+ start(step: step): void
74
+ succeed(step: step): void
75
+ writeSummaryGap(): void
76
+ }
77
+
78
+ export function createCommandReporter(options: CreateCommandReporterOptions = {}): CommandReporter {
79
+ let stdout = options.stdout ?? process.stdout
80
+ let stderr = options.stderr ?? process.stderr
81
+ let session = new ReporterSessionState()
82
+ let states = new Map<NodeJS.WriteStream, ReporterStreamState>()
83
+ let getState = (stream: NodeJS.WriteStream) => {
84
+ let state = states.get(stream)
85
+ if (state != null) {
86
+ return state
87
+ }
88
+
89
+ state = new ReporterStreamState(stream, session)
90
+ states.set(stream, state)
91
+ return state
92
+ }
93
+
94
+ return {
95
+ finish() {
96
+ for (let state of states.values()) {
97
+ state.finish()
98
+ }
99
+ },
100
+ out: new ReporterTextChannel(stdout, getState(stdout)),
101
+ status: new ReporterStatusChannel(
102
+ stderr,
103
+ getState(stderr),
104
+ options.statusFrameIntervalMs ?? DEFAULT_STATUS_FRAME_INTERVAL_MS,
105
+ options.remixVersion,
106
+ ),
107
+ }
108
+ }
109
+
110
+ export function createStepProgressReporter<step extends string>(
111
+ status: StatusChannel,
112
+ labels: Record<step, string | StepProgressLabel>,
113
+ ): StepProgressReporter<step> {
114
+ return {
115
+ fail(step) {
116
+ status.failStep(getCompleteLabel(labels[step]))
117
+ },
118
+ skip(step, reason) {
119
+ status.skipStep(getCompleteLabel(labels[step]), reason)
120
+ },
121
+ start(step) {
122
+ status.startStep(getRunningLabel(labels[step]))
123
+ },
124
+ succeed(step) {
125
+ status.succeedStep(getCompleteLabel(labels[step]))
126
+ },
127
+ writeSummaryGap() {
128
+ status.summaryGap()
129
+ },
130
+ }
131
+ }
132
+
133
+ export async function runProgressStep<step extends string, result>(
134
+ progress: StepProgressReporter<step> | null | undefined,
135
+ step: step,
136
+ callback: () => Promise<result>,
137
+ ): Promise<result> {
138
+ progress?.start(step)
139
+
140
+ try {
141
+ let result = await callback()
142
+ progress?.succeed(step)
143
+ return result
144
+ } catch (error) {
145
+ progress?.fail(step)
146
+ throw error
147
+ }
148
+ }
149
+
150
+ class ReporterTextChannel implements TextChannel {
151
+ #indentLevel = 0
152
+ #state: ReporterStreamState
153
+ #stream: NodeJS.WriteStream
154
+
155
+ constructor(stream: NodeJS.WriteStream, state: ReporterStreamState) {
156
+ this.#stream = stream
157
+ this.#state = state
158
+ }
159
+
160
+ blank(): void {
161
+ if (!this.#state.hasWrittenContent()) {
162
+ return
163
+ }
164
+
165
+ this.#state.queueBlank()
166
+ }
167
+
168
+ bullet(text: string): void {
169
+ this.line(`${BULLET} ${text}`)
170
+ }
171
+
172
+ bullets(items: string[]): void {
173
+ for (let item of items) {
174
+ this.bullet(item)
175
+ }
176
+ }
177
+
178
+ dedent(): void {
179
+ this.#indentLevel = Math.max(0, this.#indentLevel - 1)
180
+ }
181
+
182
+ indent(): void {
183
+ this.#indentLevel += 1
184
+ }
185
+
186
+ label(tag: string, text: string, options?: LabelOptions): string {
187
+ let line = text.length === 0 ? formatLabel(tag) : `${formatLabel(tag)} ${text}`
188
+
189
+ if (options?.tone === 'warn') {
190
+ return lightYellow(line, this.#stream)
191
+ }
192
+
193
+ if (options?.tone === 'error') {
194
+ return lightRed(line, this.#stream)
195
+ }
196
+
197
+ return line
198
+ }
199
+
200
+ line(text = ''): void {
201
+ if (text.length === 0) {
202
+ this.blank()
203
+ return
204
+ }
205
+
206
+ this.#flushPendingBlank()
207
+ this.#write(`${this.#getIndent()}${text}\n`)
208
+ }
209
+
210
+ section(title: string, callback?: () => void): void {
211
+ this.line(title)
212
+ if (callback == null) {
213
+ return
214
+ }
215
+
216
+ this.withIndent(callback)
217
+ }
218
+
219
+ table({ formatRow, headers, noHeaders = false, rows }: TableOptions): void {
220
+ let columnCount = Math.max(headers.length, ...rows.map((row) => row.length))
221
+ let widths = Array.from({ length: columnCount }, (_, columnIndex) => {
222
+ let headerWidth = headers[columnIndex]?.length ?? 0
223
+ let rowWidth = rows.reduce((width, row) => Math.max(width, row[columnIndex]?.length ?? 0), 0)
224
+ return Math.max(headerWidth, rowWidth)
225
+ })
226
+
227
+ if (!noHeaders) {
228
+ this.line(
229
+ headers
230
+ .map((header, columnIndex) =>
231
+ bold(header.padEnd(widths[columnIndex] ?? header.length), this.#stream),
232
+ )
233
+ .join(' '),
234
+ )
235
+ }
236
+
237
+ rows.forEach((row, rowIndex) => {
238
+ let line = row
239
+ .map((cell, columnIndex) => cell.padEnd(widths[columnIndex] ?? cell.length))
240
+ .join(' ')
241
+ this.line(formatRow == null ? line : formatRow(line, rowIndex))
242
+ })
243
+ }
244
+
245
+ withIndent<result>(callback: () => result): result {
246
+ this.indent()
247
+
248
+ try {
249
+ return callback()
250
+ } finally {
251
+ this.dedent()
252
+ }
253
+ }
254
+
255
+ getStream(): NodeJS.WriteStream {
256
+ return this.#stream
257
+ }
258
+
259
+ writeImmediate(text: string): void {
260
+ this.#state.writeImmediate(text)
261
+ }
262
+
263
+ getIndentText(): string {
264
+ return this.#getIndent()
265
+ }
266
+
267
+ #flushPendingBlank(): void {
268
+ this.#state.flushPendingBlank()
269
+ }
270
+
271
+ #getIndent(): string {
272
+ return DEFAULT_INDENT.repeat(this.#indentLevel)
273
+ }
274
+
275
+ #write(text: string): void {
276
+ this.#state.write(text)
277
+ }
278
+ }
279
+
280
+ class ReporterStatusChannel extends ReporterTextChannel implements StatusChannel {
281
+ #activeLabel: string | null = null
282
+ #activeTimer: NodeJS.Timeout | null = null
283
+ #frameIndex = 0
284
+ #frameIntervalMs: number
285
+ #hasRenderedStep = false
286
+ #hasWrittenSummaryGap = false
287
+ #remixVersion: string | undefined
288
+
289
+ constructor(
290
+ stream: NodeJS.WriteStream,
291
+ state: ReporterStreamState,
292
+ frameIntervalMs: number,
293
+ remixVersion: string | undefined,
294
+ ) {
295
+ super(stream, state)
296
+ this.#frameIntervalMs = frameIntervalMs
297
+ this.#remixVersion = remixVersion
298
+ }
299
+
300
+ async commandHeader(commandLabel: string): Promise<void> {
301
+ if (!this.getStream().isTTY || this.#remixVersion == null) {
302
+ return
303
+ }
304
+
305
+ this.writeImmediate(
306
+ `${remixWordmark(this.getStream())} v${this.#remixVersion} - ${commandLabel}\n\n`,
307
+ )
308
+ }
309
+
310
+ failStep(label = this.#activeLabel ?? ''): void {
311
+ this.#stopAnimation()
312
+ this.#activeLabel = null
313
+ this.#noteStepOutput()
314
+ this.#writeFinalLine(this.#formatFailedLine(label))
315
+ }
316
+
317
+ skipStep(label: string, reason?: string): void {
318
+ this.#stopAnimation()
319
+ if (this.#activeLabel === label) {
320
+ this.#activeLabel = null
321
+ }
322
+ this.#noteStepOutput()
323
+ this.#writeFinalLine(this.#formatSkippedLine(label, reason))
324
+ }
325
+
326
+ startStep(label: string): void {
327
+ this.#stopAnimation()
328
+ this.#activeLabel = label
329
+ this.#noteStepOutput()
330
+
331
+ if (this.#isInteractive()) {
332
+ this.#frameIndex = 0
333
+ this.writeImmediate(this.#renderLiveLine(this.#formatRunningLine(label, DOT_FRAMES[0])))
334
+ this.#activeTimer = setInterval(() => {
335
+ if (this.#activeLabel == null) {
336
+ return
337
+ }
338
+
339
+ this.#frameIndex = (this.#frameIndex + 1) % DOT_FRAMES.length
340
+ this.writeImmediate(
341
+ this.#renderLiveLine(
342
+ this.#formatRunningLine(this.#activeLabel, DOT_FRAMES[this.#frameIndex]),
343
+ ),
344
+ )
345
+ }, this.#frameIntervalMs)
346
+ this.#activeTimer.unref?.()
347
+ return
348
+ }
349
+
350
+ this.writeImmediate(`${this.#formatRunningLine(label, '...')}\n`)
351
+ }
352
+
353
+ succeedStep(label = this.#activeLabel ?? ''): void {
354
+ this.#stopAnimation()
355
+ this.#activeLabel = null
356
+ this.#noteStepOutput()
357
+ this.#writeFinalLine(this.#formatSucceededLine(label))
358
+ }
359
+
360
+ summaryGap(): void {
361
+ if (!this.#hasRenderedStep || this.#hasWrittenSummaryGap) {
362
+ return
363
+ }
364
+
365
+ this.writeImmediate('\n')
366
+ this.#hasWrittenSummaryGap = true
367
+ }
368
+
369
+ #formatFailedLine(label: string): string {
370
+ return `${this.getIndentText()}${lightRed('✗', this.getStream())} ${lightGray(label, this.getStream())}`
371
+ }
372
+
373
+ #formatRunningLine(label: string, dots: string): string {
374
+ return lightGray(`${this.getIndentText()}${BULLET} ${label}${dots}`, this.getStream())
375
+ }
376
+
377
+ #formatSkippedLine(label: string, reason?: string): string {
378
+ let line = reason == null ? `${BULLET} ${label}` : `${BULLET} ${label} (skipped: ${reason})`
379
+ return lightGray(`${this.getIndentText()}${line}`, this.getStream())
380
+ }
381
+
382
+ #formatSucceededLine(label: string): string {
383
+ return `${this.getIndentText()}${lightGreen('✓', this.getStream())} ${lightGray(label, this.getStream())}`
384
+ }
385
+
386
+ #isInteractive(): boolean {
387
+ return canUseAnsi(this.getStream())
388
+ }
389
+
390
+ #noteStepOutput(): void {
391
+ this.#hasRenderedStep = true
392
+ this.#hasWrittenSummaryGap = false
393
+ }
394
+
395
+ #renderLiveLine(line: string): string {
396
+ return `${clearCurrentLine()}${line}`
397
+ }
398
+
399
+ #stopAnimation(): void {
400
+ if (this.#activeTimer != null) {
401
+ clearInterval(this.#activeTimer)
402
+ this.#activeTimer = null
403
+ }
404
+ }
405
+
406
+ #writeFinalLine(line: string): void {
407
+ if (this.#isInteractive()) {
408
+ this.writeImmediate(`${this.#renderLiveLine(line)}\n`)
409
+ return
410
+ }
411
+
412
+ this.writeImmediate(`${line}\n`)
413
+ }
414
+ }
415
+
416
+ class ReporterStreamState {
417
+ #hasWrittenContent = false
418
+ #pendingBlank = false
419
+ #session: ReporterSessionState
420
+ #stream: NodeJS.WriteStream
421
+ #trailingNewlineCount = 0
422
+
423
+ constructor(stream: NodeJS.WriteStream, session: ReporterSessionState) {
424
+ this.#session = session
425
+ this.#stream = stream
426
+ }
427
+
428
+ finish(): void {
429
+ if (!this.#hasWrittenContent) {
430
+ return
431
+ }
432
+
433
+ if (this.#trailingNewlineCount >= 2) {
434
+ return
435
+ }
436
+
437
+ this.#writeRaw('\n')
438
+ this.#pendingBlank = false
439
+ }
440
+
441
+ flushPendingBlank(): void {
442
+ if (!this.#pendingBlank) {
443
+ return
444
+ }
445
+
446
+ this.#writeRaw('\n')
447
+ this.#pendingBlank = false
448
+ }
449
+
450
+ hasWrittenContent(): boolean {
451
+ return this.#hasWrittenContent
452
+ }
453
+
454
+ queueBlank(): void {
455
+ this.#pendingBlank = true
456
+ }
457
+
458
+ write(text: string): void {
459
+ if (!this.#session.hasWrittenPreamble()) {
460
+ this.#writeRaw('\n')
461
+ this.#session.markPreambleWritten()
462
+ }
463
+
464
+ this.#writeRaw(text)
465
+ this.#hasWrittenContent = true
466
+ }
467
+
468
+ writeImmediate(text: string): void {
469
+ if (!this.#session.hasWrittenPreamble()) {
470
+ this.#writeRaw('\n')
471
+ this.#session.markPreambleWritten()
472
+ }
473
+
474
+ this.#writeRaw(text)
475
+ this.#hasWrittenContent = true
476
+ this.#pendingBlank = false
477
+ }
478
+
479
+ #writeRaw(text: string): void {
480
+ this.#stream.write(text)
481
+ this.#updateTrailingNewlines(text)
482
+ }
483
+
484
+ #updateTrailingNewlines(text: string): void {
485
+ let trailingNewlines = 0
486
+
487
+ for (let index = text.length - 1; index >= 0; index--) {
488
+ if (text[index] !== '\n') {
489
+ break
490
+ }
491
+
492
+ trailingNewlines += 1
493
+ }
494
+
495
+ if (trailingNewlines === 0) {
496
+ this.#trailingNewlineCount = 0
497
+ return
498
+ }
499
+
500
+ if (trailingNewlines === text.length) {
501
+ this.#trailingNewlineCount += trailingNewlines
502
+ return
503
+ }
504
+
505
+ this.#trailingNewlineCount = trailingNewlines
506
+ }
507
+ }
508
+
509
+ class ReporterSessionState {
510
+ #hasWrittenPreamble = false
511
+
512
+ hasWrittenPreamble(): boolean {
513
+ return this.#hasWrittenPreamble
514
+ }
515
+
516
+ markPreambleWritten(): void {
517
+ this.#hasWrittenPreamble = true
518
+ }
519
+ }
520
+
521
+ function formatLabel(tag: string): string {
522
+ if (tag.startsWith('[') && tag.endsWith(']')) {
523
+ return tag
524
+ }
525
+
526
+ return `[${tag}]`
527
+ }
528
+
529
+ function getCompleteLabel(label: string | StepProgressLabel): string {
530
+ return typeof label === 'string' ? label : label.complete
531
+ }
532
+
533
+ function getRunningLabel(label: string | StepProgressLabel): string {
534
+ return typeof label === 'string' ? label : (label.running ?? label.complete)
535
+ }