@livestore/utils-dev 0.4.0-dev.9 → 0.5.0-dev.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 (82) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/node/DockerCompose/DockerCompose.d.ts +52 -0
  3. package/dist/node/DockerCompose/DockerCompose.d.ts.map +1 -0
  4. package/dist/node/DockerCompose/DockerCompose.js +158 -0
  5. package/dist/node/DockerCompose/DockerCompose.js.map +1 -0
  6. package/dist/node/DockerCompose/DockerCompose.test.d.ts +2 -0
  7. package/dist/node/DockerCompose/DockerCompose.test.d.ts.map +1 -0
  8. package/dist/node/{DockerComposeService/DockerComposeService.test.js → DockerCompose/DockerCompose.test.js} +18 -18
  9. package/dist/node/DockerCompose/DockerCompose.test.js.map +1 -0
  10. package/dist/node/DockerCompose/mod.d.ts +2 -0
  11. package/dist/node/DockerCompose/mod.d.ts.map +1 -0
  12. package/dist/node/DockerCompose/mod.js +2 -0
  13. package/dist/node/DockerCompose/mod.js.map +1 -0
  14. package/dist/node/FileLogger.d.ts +15 -8
  15. package/dist/node/FileLogger.d.ts.map +1 -1
  16. package/dist/node/FileLogger.js +64 -80
  17. package/dist/node/FileLogger.js.map +1 -1
  18. package/dist/node/cmd-log.d.ts +21 -0
  19. package/dist/node/cmd-log.d.ts.map +1 -0
  20. package/dist/node/cmd-log.js +58 -0
  21. package/dist/node/cmd-log.js.map +1 -0
  22. package/dist/node/cmd.d.ts +20 -14
  23. package/dist/node/cmd.d.ts.map +1 -1
  24. package/dist/node/cmd.js +248 -33
  25. package/dist/node/cmd.js.map +1 -1
  26. package/dist/node/cmd.test.d.ts +2 -0
  27. package/dist/node/cmd.test.d.ts.map +1 -0
  28. package/dist/node/cmd.test.js +103 -0
  29. package/dist/node/cmd.test.js.map +1 -0
  30. package/dist/node/mod.d.ts +5 -5
  31. package/dist/node/mod.d.ts.map +1 -1
  32. package/dist/node/mod.js +114 -66
  33. package/dist/node/mod.js.map +1 -1
  34. package/dist/node/workspace.d.ts +22 -0
  35. package/dist/node/workspace.d.ts.map +1 -0
  36. package/dist/node/workspace.js +26 -0
  37. package/dist/node/workspace.js.map +1 -0
  38. package/dist/node-vitest/Vitest.d.ts +17 -8
  39. package/dist/node-vitest/Vitest.d.ts.map +1 -1
  40. package/dist/node-vitest/Vitest.js +16 -16
  41. package/dist/node-vitest/Vitest.js.map +1 -1
  42. package/dist/node-vitest/Vitest.test.d.ts +6 -0
  43. package/dist/node-vitest/Vitest.test.d.ts.map +1 -1
  44. package/dist/node-vitest/Vitest.test.js +25 -10
  45. package/dist/node-vitest/Vitest.test.js.map +1 -1
  46. package/dist/wrangler/WranglerDevServer.d.ts +32 -20
  47. package/dist/wrangler/WranglerDevServer.d.ts.map +1 -1
  48. package/dist/wrangler/WranglerDevServer.js +79 -60
  49. package/dist/wrangler/WranglerDevServer.js.map +1 -1
  50. package/dist/wrangler/WranglerDevServer.test.js +31 -31
  51. package/dist/wrangler/WranglerDevServer.test.js.map +1 -1
  52. package/dist/wrangler/mod.d.ts +1 -1
  53. package/dist/wrangler/mod.d.ts.map +1 -1
  54. package/dist/wrangler/mod.js +1 -1
  55. package/dist/wrangler/mod.js.map +1 -1
  56. package/package.json +56 -21
  57. package/src/node/{DockerComposeService/DockerComposeService.test.ts → DockerCompose/DockerCompose.test.ts} +23 -20
  58. package/src/node/DockerCompose/DockerCompose.ts +328 -0
  59. package/src/node/DockerCompose/mod.ts +1 -0
  60. package/src/node/DockerCompose/test-fixtures/docker-compose.yml +4 -0
  61. package/src/node/FileLogger.ts +91 -115
  62. package/src/node/cmd-log.ts +95 -0
  63. package/src/node/cmd.test.ts +136 -0
  64. package/src/node/cmd.ts +431 -82
  65. package/src/node/mod.ts +152 -96
  66. package/src/node/workspace.ts +45 -0
  67. package/src/node-vitest/Vitest.test.ts +34 -10
  68. package/src/node-vitest/Vitest.ts +60 -66
  69. package/src/wrangler/WranglerDevServer.test.ts +38 -40
  70. package/src/wrangler/WranglerDevServer.ts +165 -108
  71. package/src/wrangler/fixtures/wrangler.toml +1 -1
  72. package/src/wrangler/mod.ts +1 -6
  73. package/dist/.tsbuildinfo.json +0 -1
  74. package/dist/node/DockerComposeService/DockerComposeService.d.ts +0 -58
  75. package/dist/node/DockerComposeService/DockerComposeService.d.ts.map +0 -1
  76. package/dist/node/DockerComposeService/DockerComposeService.js +0 -144
  77. package/dist/node/DockerComposeService/DockerComposeService.js.map +0 -1
  78. package/dist/node/DockerComposeService/DockerComposeService.test.d.ts +0 -2
  79. package/dist/node/DockerComposeService/DockerComposeService.test.d.ts.map +0 -1
  80. package/dist/node/DockerComposeService/DockerComposeService.test.js.map +0 -1
  81. package/src/node/DockerComposeService/DockerComposeService.ts +0 -328
  82. package/src/node/DockerComposeService/test-fixtures/docker-compose.yml +0 -4
package/src/node/cmd.ts CHANGED
@@ -1,99 +1,448 @@
1
- import { isNotUndefined, shouldNeverHappen } from '@livestore/utils'
2
- import { Command, type CommandExecutor, Effect, identity, type PlatformError, Schema } from '@livestore/utils/effect'
1
+ import {
2
+ Cause,
3
+ ChildProcess,
4
+ ChildProcessSpawner,
5
+ Effect,
6
+ Fiber,
7
+ Logger,
8
+ type PlatformError,
9
+ Predicate,
10
+ References,
11
+ Schema,
12
+ Stream,
13
+ } from '@livestore/utils/effect'
14
+ import { NodeFileSystem } from '@livestore/utils/node'
15
+
16
+ import { applyLoggingToCommand } from './cmd-log.ts'
17
+ import * as FileLogger from './FileLogger.ts'
18
+ import { CurrentWorkingDirectory } from './workspace.ts'
19
+
20
+ // Branded zero value so we can compare exit codes without touching internals.
21
+ const SUCCESS_EXIT_CODE: ChildProcessSpawner.ExitCode = ChildProcessSpawner.ExitCode(0)
3
22
 
4
23
  export const cmd: (
5
24
  commandInput: string | (string | undefined)[],
6
- options?:
7
- | {
8
- cwd?: string
9
- stderr?: 'inherit' | 'pipe'
10
- stdout?: 'inherit' | 'pipe'
11
- shell?: boolean
12
- env?: Record<string, string | undefined>
13
- }
14
- | undefined,
15
- ) => Effect.Effect<CommandExecutor.ExitCode, PlatformError.PlatformError | CmdError, CommandExecutor.CommandExecutor> =
16
- Effect.fn('cmd')(function* (commandInput, options) {
17
- const cwd = options?.cwd ?? process.env.WORKSPACE_ROOT ?? shouldNeverHappen('WORKSPACE_ROOT is not set')
18
- const [command, ...args] = Array.isArray(commandInput)
19
- ? commandInput.filter(isNotUndefined)
20
- : commandInput.split(' ')
21
-
22
- const debugEnvStr = Object.entries(options?.env ?? {})
23
- .map(([key, value]) => `${key}='${value}' `)
24
- .join('')
25
- const subshellStr = options?.shell ? ' (in subshell)' : ''
26
- const commandDebugStr = debugEnvStr + [command, ...args].join(' ')
27
-
28
- yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
29
- yield* Effect.annotateCurrentSpan({ 'span.label': commandDebugStr, cwd, command, args })
30
-
31
- return yield* Command.make(command!, ...args).pipe(
32
- // TODO don't forward abort signal to the command
33
- Command.stdin('inherit'), // Forward stdin to the command
34
- // inherit = Stream stdout to process.stdout, pipe = Stream stdout to process.stderr
35
- Command.stdout(options?.stdout ?? 'inherit'),
36
- // inherit = Stream stderr to process.stderr, pipe = Stream stderr to process.stdout
37
- Command.stderr(options?.stderr ?? 'inherit'),
38
- Command.workingDirectory(cwd),
39
- options?.shell ? Command.runInShell(true) : identity,
40
- Command.env(options?.env ?? {}),
41
- Command.exitCode,
42
- Effect.tap((exitCode) =>
43
- exitCode === 0
44
- ? Effect.void
45
- : Effect.fail(
46
- CmdError.make({
47
- command: command!,
48
- args,
49
- cwd,
50
- env: options?.env ?? {},
51
- stderr: options?.stderr ?? 'inherit',
52
- }),
53
- ),
54
- ),
55
- )
25
+ options?: {
26
+ stderr?: 'inherit' | 'pipe'
27
+ stdout?: 'inherit' | 'pipe'
28
+ shell?: boolean
29
+ env?: Record<string, string | undefined>
30
+ /**
31
+ * When provided, streams command output to terminal AND to a canonical log file (`${logDir}/dev.log`) in this directory.
32
+ * Also archives the previous run to `${logDir}/archive/dev-<ISO>.log` and keeps only the latest 50 archives.
33
+ */
34
+ logDir?: string
35
+ /** Optional basename for the canonical log file; defaults to 'dev.log' */
36
+ logFileName?: string
37
+ /** Optional number of archived logs to retain; defaults to 50 */
38
+ logRetention?: number
39
+ },
40
+ ) => Effect.Effect<
41
+ ChildProcessSpawner.ExitCode,
42
+ PlatformError.PlatformError | CmdError,
43
+ ChildProcessSpawner.ChildProcessSpawner | CurrentWorkingDirectory
44
+ > = Effect.fn('cmd')(function* (commandInput, options) {
45
+ const cwd = yield* CurrentWorkingDirectory
46
+
47
+ const asArray = Array.isArray(commandInput)
48
+ const parts = asArray === true ? commandInput.filter(Predicate.isNotUndefined) : undefined
49
+ const [command, ...args] = asArray === true ? (parts as string[]) : commandInput.split(' ')
50
+
51
+ const debugEnvStr = Object.entries(options?.env ?? {})
52
+ .map(([key, value]) => `${key}='${String(value)}' `)
53
+ .join('')
54
+
55
+ const loggingOpts = {
56
+ ...(options?.logDir !== undefined ? { logDir: options.logDir } : {}),
57
+ ...(options?.logFileName !== undefined ? { logFileName: options.logFileName } : {}),
58
+ ...(options?.logRetention !== undefined ? { logRetention: options.logRetention } : {}),
59
+ } as const
60
+ const { input: finalInput, subshell: needsShell, logPath } = yield* applyLoggingToCommand(commandInput, loggingOpts)
61
+
62
+ const stdoutMode = options?.stdout ?? 'inherit'
63
+ const stderrMode = options?.stderr ?? 'inherit'
64
+ const useShell = (options?.shell === true ? true : false) || needsShell
65
+
66
+ const commandDebugStr = debugEnvStr + (Array.isArray(finalInput) === true ? finalInput.join(' ') : finalInput)
67
+ const subshellStr = useShell === true ? ' (in subshell)' : ''
68
+
69
+ yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
70
+ yield* Effect.annotateCurrentSpan({
71
+ 'span.label': commandDebugStr,
72
+ cwd,
73
+ command,
74
+ args,
75
+ logDir: options?.logDir,
56
76
  })
57
77
 
78
+ const baseArgs = {
79
+ commandInput: finalInput,
80
+ cwd,
81
+ env: options?.env ?? {},
82
+ stdoutMode,
83
+ stderrMode,
84
+ useShell,
85
+ } as const
86
+
87
+ const exitCode = yield* Predicate.isNotUndefined(logPath) === true
88
+ ? Effect.gen(function* () {
89
+ yield* Effect.sync(() => console.log(`Logging output to ${logPath}`))
90
+ return yield* runWithLogging({ ...baseArgs, logPath, threadName: commandDebugStr })
91
+ })
92
+ : runWithoutLogging(baseArgs)
93
+
94
+ if (exitCode !== SUCCESS_EXIT_CODE) {
95
+ return yield* CmdError.make({
96
+ command: command!,
97
+ args,
98
+ cwd,
99
+ env: options?.env ?? {},
100
+ stderr: stderrMode,
101
+ })
102
+ }
103
+
104
+ return exitCode
105
+ })
106
+
58
107
  export const cmdText: (
59
108
  commandInput: string | (string | undefined)[],
60
109
  options?: {
61
- cwd?: string
62
110
  stderr?: 'inherit' | 'pipe'
63
111
  runInShell?: boolean
64
112
  env?: Record<string, string | undefined>
65
113
  },
66
- ) => Effect.Effect<string, PlatformError.PlatformError, CommandExecutor.CommandExecutor> = Effect.fn('cmdText')(
67
- function* (commandInput, options) {
68
- const cwd = options?.cwd ?? process.env.WORKSPACE_ROOT ?? shouldNeverHappen('WORKSPACE_ROOT is not set')
69
- const [command, ...args] = Array.isArray(commandInput)
70
- ? commandInput.filter(isNotUndefined)
71
- : commandInput.split(' ')
72
- const debugEnvStr = Object.entries(options?.env ?? {})
73
- .map(([key, value]) => `${key}='${value}' `)
74
- .join('')
75
-
76
- const commandDebugStr = debugEnvStr + [command, ...args].join(' ')
77
- const subshellStr = options?.runInShell ? ' (in subshell)' : ''
78
-
79
- yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
80
- yield* Effect.annotateCurrentSpan({ 'span.label': commandDebugStr, command, cwd })
81
-
82
- return yield* Command.make(command!, ...args).pipe(
83
- // inherit = Stream stderr to process.stderr, pipe = Stream stderr to process.stdout
84
- Command.stderr(options?.stderr ?? 'inherit'),
85
- Command.workingDirectory(cwd),
86
- options?.runInShell ? Command.runInShell(true) : identity,
87
- Command.env(options?.env ?? {}),
88
- Command.string,
89
- )
90
- },
91
- )
114
+ ) => Effect.Effect<
115
+ string,
116
+ PlatformError.PlatformError,
117
+ ChildProcessSpawner.ChildProcessSpawner | CurrentWorkingDirectory
118
+ > = Effect.fn('cmdText')(function* (commandInput, options) {
119
+ const cwd = yield* CurrentWorkingDirectory
120
+ const commandParts =
121
+ Array.isArray(commandInput) === true ? commandInput.filter(Predicate.isNotUndefined) : commandInput.split(' ')
122
+ const [command, ...args] = commandParts
123
+ const debugEnvStr = Object.entries(options?.env ?? {})
124
+ .map(([key, value]) => `${key}='${String(value)}' `)
125
+ .join('')
126
+
127
+ const commandDebugStr = debugEnvStr + [command, ...args].join(' ')
128
+ const subshellStr = options?.runInShell === true ? ' (in subshell)' : ''
92
129
 
93
- export class CmdError extends Schema.TaggedError<CmdError>()('CmdError', {
130
+ yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
131
+ yield* Effect.annotateCurrentSpan({ 'span.label': commandDebugStr, command, cwd })
132
+
133
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
134
+ return yield* spawner.string(
135
+ buildCommand(commandParts, {
136
+ cwd,
137
+ env: options?.env ?? {},
138
+ stderr: options?.stderr ?? 'inherit',
139
+ shell: options?.runInShell === true,
140
+ }),
141
+ )
142
+ })
143
+
144
+ export class CmdError extends Schema.TaggedError<CmdError>('~@livestore/utils-dev/CmdError')('CmdError', {
94
145
  command: Schema.String,
95
146
  args: Schema.Array(Schema.String),
96
147
  cwd: Schema.String,
97
- env: Schema.Record({ key: Schema.String, value: Schema.String.pipe(Schema.UndefinedOr) }),
98
- stderr: Schema.Literal('inherit', 'pipe'),
148
+ env: Schema.Record(Schema.String, Schema.String.pipe(Schema.UndefinedOr)),
149
+ stderr: Schema.Literals(['inherit', 'pipe']),
99
150
  }) {}
151
+
152
+ type TRunBaseArgs = {
153
+ readonly commandInput: string | string[]
154
+ readonly cwd: string
155
+ readonly env: Record<string, string | undefined>
156
+ readonly stdoutMode: 'inherit' | 'pipe'
157
+ readonly stderrMode: 'inherit' | 'pipe'
158
+ readonly useShell: boolean
159
+ }
160
+
161
+ const runWithoutLogging = ({ commandInput, cwd, env, stdoutMode, stderrMode, useShell }: TRunBaseArgs) =>
162
+ Effect.gen(function* () {
163
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
164
+ return yield* spawner.exitCode(
165
+ buildCommand(commandInput, {
166
+ cwd,
167
+ env,
168
+ stdin: 'inherit',
169
+ stdout: stdoutMode,
170
+ stderr: stderrMode,
171
+ shell: useShell,
172
+ }),
173
+ )
174
+ })
175
+
176
+ type TRunWithLoggingArgs = TRunBaseArgs & {
177
+ readonly logPath: string
178
+ readonly threadName: string
179
+ }
180
+
181
+ const runWithLogging = ({
182
+ commandInput,
183
+ cwd,
184
+ env,
185
+ stdoutMode,
186
+ stderrMode,
187
+ useShell,
188
+ logPath,
189
+ threadName,
190
+ }: TRunWithLoggingArgs) =>
191
+ // When logging is enabled we have to replace the `2>&1 | tee` pipeline the
192
+ // shell used to give us. We now pipe both streams through Effect so we can
193
+ // mirror to the terminal (only when requested) and append formatted entries
194
+ // into the canonical log ourselves.
195
+ Effect.scoped(
196
+ Effect.gen(function* () {
197
+ const envWithColor = env.FORCE_COLOR === undefined ? { ...env, FORCE_COLOR: '1' } : env
198
+
199
+ const prettyLoggerOptions = {
200
+ colors: true,
201
+ stderr: false,
202
+ formatDate: FileLogger.formatLogTime,
203
+ } as const
204
+
205
+ const logStringToFile = yield* Logger.make<unknown, string>(({ message }) => {
206
+ if (typeof message === 'string') return message
207
+ return String(message)
208
+ }).pipe(Logger.toFile(logPath, { flag: 'a' }), Effect.provide(NodeFileSystem.layer))
209
+
210
+ const FileLoggerLive = Logger.layer([logStringToFile])
211
+
212
+ const appendLog = ({ channel, content }: { channel: 'stdout' | 'stderr'; content: string }) =>
213
+ Effect.gen(function* () {
214
+ const date = new Date()
215
+ const formatted = FileLogger.formatPrettyLog({
216
+ logLevel: channel === 'stdout' ? 'Info' : 'Warn',
217
+ message: [`[${channel}]${content.length > 0 ? ` ${content}` : ''}`],
218
+ cause: Cause.empty,
219
+ date,
220
+ fiberId: 0,
221
+ spans: [[threadName, date.getTime()]],
222
+ annotations: { thread: threadName },
223
+ options: prettyLoggerOptions,
224
+ })
225
+
226
+ yield* Effect.log(formatted.endsWith('\n') === true ? formatted.slice(0, -1) : formatted).pipe(
227
+ Effect.provideService(References.MinimumLogLevel, 'All'),
228
+ Effect.provide(FileLoggerLive),
229
+ )
230
+ })
231
+
232
+ const command = buildCommand(commandInput, {
233
+ cwd,
234
+ env: envWithColor,
235
+ stdin: 'inherit',
236
+ stdout: 'pipe',
237
+ stderr: 'pipe',
238
+ shell: useShell,
239
+ })
240
+
241
+ // Acquire/start the command and make sure we kill it on interruption.
242
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
243
+ const runningProcess = yield* Effect.acquireRelease(spawner.spawn(command), (proc) =>
244
+ proc.isRunning.pipe(
245
+ Effect.flatMap((running) =>
246
+ running === true ? proc.kill().pipe(Effect.catch(() => Effect.void)) : Effect.void,
247
+ ),
248
+ Effect.ignore,
249
+ ),
250
+ )
251
+
252
+ const stdoutHandler = makeStreamHandler({
253
+ channel: 'stdout',
254
+ ...(stdoutMode === 'inherit' ? { mirrorTarget: process.stdout } : {}),
255
+ appendLog,
256
+ })
257
+ const stderrHandler = makeStreamHandler({
258
+ channel: 'stderr',
259
+ ...(stderrMode === 'inherit' ? { mirrorTarget: process.stderr } : {}),
260
+ appendLog,
261
+ })
262
+
263
+ const stdoutFiber = yield* runningProcess.stdout.pipe(
264
+ Stream.decodeText({ encoding: 'utf8' }),
265
+ Stream.runForEach((chunk) => stdoutHandler.onChunk(chunk)),
266
+ Effect.forkScoped,
267
+ )
268
+
269
+ const stderrFiber = yield* runningProcess.stderr.pipe(
270
+ Stream.decodeText({ encoding: 'utf8' }),
271
+ Stream.runForEach((chunk) => stderrHandler.onChunk(chunk)),
272
+ Effect.forkScoped,
273
+ )
274
+
275
+ // Dump any buffered data and finish both stream fibers before we return.
276
+ const flushOutputs = Effect.gen(function* () {
277
+ const stillRunning = yield* runningProcess.isRunning.pipe(Effect.orElseSucceed(() => false))
278
+ if (stillRunning === true) {
279
+ yield* Effect.ignore(runningProcess.kill())
280
+ }
281
+ yield* Effect.ignore(Fiber.join(stdoutFiber))
282
+ yield* Effect.ignore(Fiber.join(stderrFiber))
283
+ yield* stdoutHandler.flush()
284
+ yield* stderrHandler.flush()
285
+ })
286
+
287
+ const exitCode = yield* runningProcess.exitCode.pipe(Effect.ensuring(flushOutputs))
288
+
289
+ return exitCode
290
+ }),
291
+ )
292
+
293
+ type TBuildCommandOptions = {
294
+ readonly cwd: string
295
+ readonly env: Record<string, string | undefined>
296
+ readonly stdin?: ChildProcess.CommandInput
297
+ readonly stdout?: ChildProcess.CommandOutput
298
+ readonly stderr?: ChildProcess.CommandOutput
299
+ readonly shell: boolean
300
+ }
301
+
302
+ const buildCommand = (input: string | string[], options: TBuildCommandOptions): ChildProcess.Command => {
303
+ const commandOptions = {
304
+ cwd: options.cwd,
305
+ env: options.env,
306
+ extendEnv: true,
307
+ shell: options.shell,
308
+ ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
309
+ ...(options.stdout !== undefined ? { stdout: options.stdout } : {}),
310
+ ...(options.stderr !== undefined ? { stderr: options.stderr } : {}),
311
+ } as const
312
+
313
+ if (Array.isArray(input) === true) {
314
+ const [c, ...a] = input
315
+ return ChildProcess.make(c ?? '', a, commandOptions)
316
+ }
317
+
318
+ if (options.shell === true) {
319
+ return ChildProcess.make(input, commandOptions)
320
+ }
321
+
322
+ const [c, ...a] = input.split(' ')
323
+ return ChildProcess.make(c ?? '', a, commandOptions)
324
+ }
325
+
326
+ type TLineTerminator = 'newline' | 'carriage-return' | 'none'
327
+
328
+ type TStreamHandler = {
329
+ readonly onChunk: (chunk: string) => Effect.Effect<void>
330
+ readonly flush: () => Effect.Effect<void>
331
+ }
332
+
333
+ const makeStreamHandler = ({
334
+ channel,
335
+ mirrorTarget,
336
+ appendLog,
337
+ }: {
338
+ readonly channel: 'stdout' | 'stderr'
339
+ readonly mirrorTarget?: NodeJS.WriteStream
340
+ readonly appendLog: (args: { channel: 'stdout' | 'stderr'; content: string }) => Effect.Effect<void>
341
+ }): TStreamHandler => {
342
+ let buffer = ''
343
+
344
+ // Effect's FileLogger expects line-oriented messages, but the subprocess
345
+ // gives us arbitrary UTF-8 chunks. We keep a tiny line splitter here so the
346
+ // log and console stay readable (and consistent with the previous `tee`
347
+ // behaviour).
348
+ const emit = (content: string, terminator: TLineTerminator) =>
349
+ emitSegment({
350
+ channel,
351
+ content,
352
+ terminator,
353
+ ...(mirrorTarget !== undefined ? { mirrorTarget } : {}),
354
+ appendLog,
355
+ })
356
+
357
+ const consumeBuffer = (): Effect.Effect<void> => {
358
+ if (buffer.length === 0) return Effect.void
359
+
360
+ const lastChar = buffer[buffer.length - 1]
361
+ if (lastChar === '\r') {
362
+ const line = buffer.slice(0, -1)
363
+ buffer = ''
364
+ return emit(line, 'carriage-return')
365
+ }
366
+
367
+ const line = buffer
368
+ buffer = ''
369
+ return line.length === 0 ? Effect.void : emit(line, 'none')
370
+ }
371
+
372
+ return {
373
+ onChunk: (chunk) =>
374
+ Effect.gen(function* () {
375
+ buffer += chunk
376
+ while (buffer.length > 0) {
377
+ const newlineIndex = buffer.indexOf('\n')
378
+ const carriageIndex = buffer.indexOf('\r')
379
+
380
+ if (newlineIndex === -1 && carriageIndex === -1) {
381
+ break
382
+ }
383
+
384
+ let index: number
385
+ let terminator: TLineTerminator
386
+ let skip = 1
387
+
388
+ if (carriageIndex !== -1 && (newlineIndex === -1 || carriageIndex < newlineIndex)) {
389
+ index = carriageIndex
390
+ if (carriageIndex + 1 < buffer.length && buffer[carriageIndex + 1] === '\n') {
391
+ skip = 2
392
+ terminator = 'newline'
393
+ } else {
394
+ terminator = 'carriage-return'
395
+ }
396
+ } else {
397
+ index = newlineIndex!
398
+ terminator = 'newline'
399
+ }
400
+
401
+ const line = buffer.slice(0, index)
402
+ buffer = buffer.slice(index + skip)
403
+ yield* emit(line, terminator)
404
+ }
405
+ }),
406
+ flush: () => consumeBuffer(),
407
+ }
408
+ }
409
+
410
+ const emitSegment = ({
411
+ channel,
412
+ content,
413
+ terminator,
414
+ mirrorTarget,
415
+ appendLog,
416
+ }: {
417
+ readonly channel: 'stdout' | 'stderr'
418
+ readonly content: string
419
+ readonly terminator: TLineTerminator
420
+ readonly mirrorTarget?: NodeJS.WriteStream
421
+ readonly appendLog: (args: { channel: 'stdout' | 'stderr'; content: string }) => Effect.Effect<void>
422
+ }) =>
423
+ Effect.gen(function* () {
424
+ if (mirrorTarget !== undefined) {
425
+ yield* Effect.sync(() => mirrorSegment(mirrorTarget, content, terminator))
426
+ }
427
+
428
+ const contentForLog = terminator === 'carriage-return' ? `${content}\r` : content
429
+
430
+ yield* appendLog({ channel, content: contentForLog })
431
+ })
432
+
433
+ const mirrorSegment = (target: NodeJS.WriteStream, content: string, terminator: TLineTerminator) => {
434
+ switch (terminator) {
435
+ case 'newline': {
436
+ target.write(`${content}\n`)
437
+ break
438
+ }
439
+ case 'carriage-return': {
440
+ target.write(`${content}\r`)
441
+ break
442
+ }
443
+ case 'none': {
444
+ target.write(content)
445
+ break
446
+ }
447
+ }
448
+ }