@livestore/utils-dev 0.4.0-dev.2 → 0.4.0-dev.20

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 (73) hide show
  1. package/dist/.tsbuildinfo.json +1 -1
  2. package/dist/node/DockerComposeService/DockerComposeService.d.ts +58 -0
  3. package/dist/node/DockerComposeService/DockerComposeService.d.ts.map +1 -0
  4. package/dist/node/DockerComposeService/DockerComposeService.js +144 -0
  5. package/dist/node/DockerComposeService/DockerComposeService.js.map +1 -0
  6. package/dist/node/DockerComposeService/DockerComposeService.test.d.ts +2 -0
  7. package/dist/node/DockerComposeService/DockerComposeService.test.d.ts.map +1 -0
  8. package/dist/node/DockerComposeService/DockerComposeService.test.js +64 -0
  9. package/dist/node/DockerComposeService/DockerComposeService.test.js.map +1 -0
  10. package/dist/node/cmd-log.d.ts +21 -0
  11. package/dist/node/cmd-log.d.ts.map +1 -0
  12. package/dist/node/cmd-log.js +47 -0
  13. package/dist/node/cmd-log.js.map +1 -0
  14. package/dist/node/cmd.d.ts +35 -0
  15. package/dist/node/cmd.d.ts.map +1 -0
  16. package/dist/node/cmd.js +235 -0
  17. package/dist/node/cmd.js.map +1 -0
  18. package/dist/node/cmd.test.d.ts +2 -0
  19. package/dist/node/cmd.test.d.ts.map +1 -0
  20. package/dist/node/cmd.test.js +102 -0
  21. package/dist/node/cmd.test.js.map +1 -0
  22. package/dist/node/mod.d.ts +5 -26
  23. package/dist/node/mod.d.ts.map +1 -1
  24. package/dist/node/mod.js +52 -59
  25. package/dist/node/mod.js.map +1 -1
  26. package/dist/node/workspace.d.ts +22 -0
  27. package/dist/node/workspace.d.ts.map +1 -0
  28. package/dist/node/workspace.js +26 -0
  29. package/dist/node/workspace.js.map +1 -0
  30. package/dist/node-vitest/Vitest.d.ts +41 -7
  31. package/dist/node-vitest/Vitest.d.ts.map +1 -1
  32. package/dist/node-vitest/Vitest.js +82 -5
  33. package/dist/node-vitest/Vitest.js.map +1 -1
  34. package/dist/node-vitest/Vitest.test.d.ts +2 -0
  35. package/dist/node-vitest/Vitest.test.d.ts.map +1 -0
  36. package/dist/node-vitest/Vitest.test.js +70 -0
  37. package/dist/node-vitest/Vitest.test.js.map +1 -0
  38. package/dist/wrangler/WranglerDevServer.d.ts +69 -0
  39. package/dist/wrangler/WranglerDevServer.d.ts.map +1 -0
  40. package/dist/wrangler/WranglerDevServer.js +103 -0
  41. package/dist/wrangler/WranglerDevServer.js.map +1 -0
  42. package/dist/wrangler/WranglerDevServer.test.d.ts +2 -0
  43. package/dist/wrangler/WranglerDevServer.test.d.ts.map +1 -0
  44. package/dist/wrangler/WranglerDevServer.test.js +77 -0
  45. package/dist/wrangler/WranglerDevServer.test.js.map +1 -0
  46. package/dist/wrangler/fixtures/cf-worker.d.ts +8 -0
  47. package/dist/wrangler/fixtures/cf-worker.d.ts.map +1 -0
  48. package/dist/wrangler/fixtures/cf-worker.js +11 -0
  49. package/dist/wrangler/fixtures/cf-worker.js.map +1 -0
  50. package/dist/wrangler/mod.d.ts +2 -0
  51. package/dist/wrangler/mod.d.ts.map +1 -0
  52. package/dist/wrangler/mod.js +2 -0
  53. package/dist/wrangler/mod.js.map +1 -0
  54. package/package.json +11 -10
  55. package/src/node/DockerComposeService/DockerComposeService.test.ts +91 -0
  56. package/src/node/DockerComposeService/DockerComposeService.ts +328 -0
  57. package/src/node/DockerComposeService/test-fixtures/docker-compose.yml +4 -0
  58. package/src/node/cmd-log.ts +87 -0
  59. package/src/node/cmd.test.ts +130 -0
  60. package/src/node/cmd.ts +420 -0
  61. package/src/node/mod.ts +63 -116
  62. package/src/node/workspace.ts +45 -0
  63. package/src/node-vitest/Vitest.test.ts +112 -0
  64. package/src/node-vitest/Vitest.ts +193 -17
  65. package/src/wrangler/WranglerDevServer.test.ts +133 -0
  66. package/src/wrangler/WranglerDevServer.ts +220 -0
  67. package/src/wrangler/fixtures/cf-worker.ts +11 -0
  68. package/src/wrangler/fixtures/wrangler.toml +11 -0
  69. package/src/wrangler/mod.ts +6 -0
  70. package/dist/node-vitest/polyfill.d.ts +0 -2
  71. package/dist/node-vitest/polyfill.d.ts.map +0 -1
  72. package/dist/node-vitest/polyfill.js +0 -3
  73. package/dist/node-vitest/polyfill.js.map +0 -1
@@ -0,0 +1,87 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { isNotUndefined } from '@livestore/utils'
5
+ import { Effect } from '@livestore/utils/effect'
6
+
7
+ export type TCmdLoggingOptions = {
8
+ readonly logDir?: string
9
+ readonly logFileName?: string
10
+ readonly logRetention?: number
11
+ }
12
+
13
+ /**
14
+ * Prepares logging directories, archives previous canonical log and prunes archives.
15
+ * Returns the canonical current log path if logging is enabled, otherwise undefined.
16
+ */
17
+ export const prepareCmdLogging: (options: TCmdLoggingOptions) => Effect.Effect<string | undefined, never, never> =
18
+ Effect.fn('cmd.logging.prepare')(function* ({
19
+ logDir,
20
+ logFileName = 'dev.log',
21
+ logRetention = 50,
22
+ }: TCmdLoggingOptions) {
23
+ if (!logDir || logDir === '') return undefined as string | undefined
24
+
25
+ const logsDir = logDir
26
+ const archiveDir = path.join(logsDir, 'archive')
27
+ const currentLogPath = path.join(logsDir, logFileName)
28
+
29
+ // Ensure directories exist
30
+ yield* Effect.sync(() => fs.mkdirSync(archiveDir, { recursive: true }))
31
+
32
+ // Archive previous log if present
33
+ if (fs.existsSync(currentLogPath)) {
34
+ const safeIso = new Date().toISOString().replaceAll(':', '-')
35
+ const archivedBase = `${path.parse(logFileName).name}-${safeIso}.log`
36
+ const archivedLog = path.join(archiveDir, archivedBase)
37
+ yield* Effect.try(() => fs.renameSync(currentLogPath, archivedLog)).pipe(
38
+ Effect.catchAll(() =>
39
+ Effect.try(() => {
40
+ fs.copyFileSync(currentLogPath, archivedLog)
41
+ fs.truncateSync(currentLogPath, 0)
42
+ }),
43
+ ),
44
+ Effect.ignore,
45
+ )
46
+
47
+ // Prune archives to retain only the newest N
48
+ yield* Effect.try(() => fs.readdirSync(archiveDir)).pipe(
49
+ Effect.map((names) => names.filter((n) => n.endsWith('.log'))),
50
+ Effect.map((names) =>
51
+ names
52
+ .map((name) => ({ name, mtimeMs: fs.statSync(path.join(archiveDir, name)).mtimeMs }))
53
+ .sort((a, b) => b.mtimeMs - a.mtimeMs),
54
+ ),
55
+ Effect.flatMap((entries) =>
56
+ Effect.forEach(entries.slice(logRetention), (e) =>
57
+ Effect.try(() => fs.unlinkSync(path.join(archiveDir, e.name))).pipe(Effect.ignore),
58
+ ),
59
+ ),
60
+ Effect.ignore,
61
+ )
62
+ }
63
+
64
+ return currentLogPath
65
+ })
66
+
67
+ /**
68
+ * Given a command input, applies logging by piping output through `tee` to the
69
+ * canonical log file. Returns the transformed input and whether a shell is required.
70
+ */
71
+ export const applyLoggingToCommand: (
72
+ commandInput: string | (string | undefined)[],
73
+ options: TCmdLoggingOptions,
74
+ ) => Effect.Effect<{ input: string | string[]; subshell: boolean; logPath?: string }, never, never> = Effect.fn(
75
+ 'cmd.logging.apply',
76
+ )(function* (commandInput, options) {
77
+ const asArray = Array.isArray(commandInput)
78
+ const parts = asArray ? (commandInput as (string | undefined)[]).filter(isNotUndefined) : undefined
79
+
80
+ const logPath = yield* prepareCmdLogging(options)
81
+
82
+ return {
83
+ input: asArray ? ((parts as string[]) ?? []) : (commandInput as string),
84
+ subshell: false,
85
+ ...(logPath ? { logPath } : {}),
86
+ }
87
+ })
@@ -0,0 +1,130 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { CommandExecutor, Duration, Effect, Layer } from '@livestore/utils/effect'
5
+ import { PlatformNode } from '@livestore/utils/node'
6
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
7
+ import { expect } from 'vitest'
8
+ import { cmd } from './cmd.ts'
9
+ import { CurrentWorkingDirectory } from './workspace.ts'
10
+
11
+ const withNode = Vitest.makeWithTestCtx({
12
+ makeLayer: () => Layer.mergeAll(PlatformNode.NodeContext.layer, CurrentWorkingDirectory.live),
13
+ timeout: 20_000,
14
+ })
15
+
16
+ Vitest.describe('cmd helper', () => {
17
+ const ansiRegex = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g')
18
+
19
+ Vitest.scopedLive('runs tokenized string without shell', (test) =>
20
+ Effect.gen(function* () {
21
+ const exit = yield* cmd('printf ok')
22
+ expect(exit).toBe(CommandExecutor.ExitCode(0))
23
+ }).pipe(withNode(test)),
24
+ )
25
+
26
+ Vitest.scopedLive('runs array input', (test) =>
27
+ Effect.gen(function* () {
28
+ const exit = yield* cmd(['printf', 'ok'])
29
+ expect(exit).toBe(CommandExecutor.ExitCode(0))
30
+ }).pipe(withNode(test)),
31
+ )
32
+
33
+ Vitest.scopedLive('supports logging with archive + retention', (test) =>
34
+ Effect.gen(function* () {
35
+ const workspace = process.env.WORKSPACE_ROOT!
36
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', String(Date.now()))
37
+
38
+ // first run
39
+ const exit1 = yield* cmd('printf first', { logDir: logsDir })
40
+ expect(exit1).toBe(CommandExecutor.ExitCode(0))
41
+ const current = path.join(logsDir, 'dev.log')
42
+ expect(fs.existsSync(current)).toBe(true)
43
+ const firstLog = fs.readFileSync(current, 'utf8')
44
+ const firstStdoutLines = firstLog.split('\n').filter((line) => line.includes('[stdout]'))
45
+ expect(firstStdoutLines.length).toBeGreaterThan(0)
46
+ for (const line of firstStdoutLines) {
47
+ expect(line).toContain('[stdout] first')
48
+ expect(line).toContain('INFO')
49
+ expect(line).toContain('printf first')
50
+ }
51
+
52
+ // second run — archives previous
53
+ const exit2 = yield* cmd('printf second', { logDir: logsDir })
54
+ expect(exit2).toBe(CommandExecutor.ExitCode(0))
55
+ const archiveDir = path.join(logsDir, 'archive')
56
+ const archives = fs.readdirSync(archiveDir).filter((f) => f.endsWith('.log'))
57
+ expect(archives.length).toBe(1)
58
+ const archivedPath = path.join(archiveDir, archives[0]!)
59
+ const archivedLog = fs.readFileSync(archivedPath, 'utf8')
60
+ const archivedStdoutLines = archivedLog.split('\n').filter((line) => line.includes('[stdout]'))
61
+ expect(archivedStdoutLines.length).toBeGreaterThan(0)
62
+ for (const line of archivedStdoutLines) {
63
+ expect(line).toContain('[stdout] first')
64
+ }
65
+
66
+ const secondLog = fs.readFileSync(current, 'utf8')
67
+ const secondStdoutLines = secondLog.split('\n').filter((line) => line.includes('[stdout]'))
68
+ expect(secondStdoutLines.length).toBeGreaterThan(0)
69
+ for (const line of secondStdoutLines) {
70
+ expect(line).toContain('[stdout] second')
71
+ expect(line).toContain('INFO')
72
+ }
73
+
74
+ // generate many archives to exercise retention (keep 50)
75
+ for (let i = 0; i < 60; i++) {
76
+ // Use small unique payloads
77
+ yield* cmd(['printf', String(i)], { logDir: logsDir })
78
+ }
79
+ const archivesAfter = fs.readdirSync(archiveDir).filter((f) => f.endsWith('.log'))
80
+ expect(archivesAfter.length).toBeLessThanOrEqual(50)
81
+ }).pipe(withNode(test)),
82
+ )
83
+
84
+ Vitest.scopedLive('streams stdout and stderr with logger formatting', (test) =>
85
+ Effect.gen(function* () {
86
+ const workspace = process.env.WORKSPACE_ROOT!
87
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', `format-${Date.now()}`)
88
+
89
+ const exit = yield* cmd(['node', '-e', "console.log('out'); console.error('err')"], {
90
+ logDir: logsDir,
91
+ })
92
+ expect(exit).toBe(CommandExecutor.ExitCode(0))
93
+
94
+ const current = path.join(logsDir, 'dev.log')
95
+ const logContent = fs.readFileSync(current, 'utf8')
96
+ expect(logContent).toMatch(/\[stdout] out/)
97
+ expect(logContent).toMatch(/\[stderr] err/)
98
+
99
+ const relevantLines = logContent
100
+ .split('\n')
101
+ .map((line) => line.trim())
102
+ .filter((line) => line.includes('[stdout]') || line.includes('[stderr]'))
103
+
104
+ expect(relevantLines.length).toBeGreaterThanOrEqual(2)
105
+
106
+ for (const line of relevantLines) {
107
+ const stripped = line.replace(ansiRegex, '')
108
+ expect(stripped.startsWith('[')).toBe(true)
109
+ expect(stripped).toMatch(/(INFO|WARN)/)
110
+ expect(stripped).toMatch(/\[(stdout|stderr)]/)
111
+ }
112
+ }).pipe(withNode(test)),
113
+ )
114
+
115
+ Vitest.scopedLive('cleans up logged child process when interrupted', (test) =>
116
+ Effect.gen(function* () {
117
+ const workspace = process.env.WORKSPACE_ROOT!
118
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', `timeout-${Date.now()}`)
119
+
120
+ const result = yield* cmd(['node', '-e', 'setTimeout(() => {}, 5000)'], {
121
+ logDir: logsDir,
122
+ stdout: 'pipe',
123
+ stderr: 'pipe',
124
+ }).pipe(Effect.timeoutOption(Duration.millis(200)))
125
+
126
+ expect(result._tag).toBe('None')
127
+ expect(fs.existsSync(path.join(logsDir, 'dev.log'))).toBe(true)
128
+ }).pipe(withNode(test)),
129
+ )
130
+ })
@@ -0,0 +1,420 @@
1
+ import fs from 'node:fs'
2
+
3
+ import { isNotUndefined } from '@livestore/utils'
4
+ import {
5
+ Cause,
6
+ Command,
7
+ type CommandExecutor,
8
+ Effect,
9
+ Fiber,
10
+ FiberId,
11
+ FiberRefs,
12
+ HashMap,
13
+ identity,
14
+ List,
15
+ LogLevel,
16
+ type PlatformError,
17
+ Schema,
18
+ Stream,
19
+ } from '@livestore/utils/effect'
20
+ import { applyLoggingToCommand } from './cmd-log.ts'
21
+ import * as FileLogger from './FileLogger.ts'
22
+ import { CurrentWorkingDirectory } from './workspace.ts'
23
+
24
+ // Branded zero value so we can compare exit codes without touching internals.
25
+ const SUCCESS_EXIT_CODE: CommandExecutor.ExitCode = 0 as CommandExecutor.ExitCode
26
+
27
+ export const cmd: (
28
+ commandInput: string | (string | undefined)[],
29
+ options?:
30
+ | {
31
+ stderr?: 'inherit' | 'pipe'
32
+ stdout?: 'inherit' | 'pipe'
33
+ shell?: boolean
34
+ env?: Record<string, string | undefined>
35
+ /**
36
+ * When provided, streams command output to terminal AND to a canonical log file (`${logDir}/dev.log`) in this directory.
37
+ * Also archives the previous run to `${logDir}/archive/dev-<ISO>.log` and keeps only the latest 50 archives.
38
+ */
39
+ logDir?: string
40
+ /** Optional basename for the canonical log file; defaults to 'dev.log' */
41
+ logFileName?: string
42
+ /** Optional number of archived logs to retain; defaults to 50 */
43
+ logRetention?: number
44
+ }
45
+ | undefined,
46
+ ) => Effect.Effect<
47
+ CommandExecutor.ExitCode,
48
+ PlatformError.PlatformError | CmdError,
49
+ CommandExecutor.CommandExecutor | CurrentWorkingDirectory
50
+ > = Effect.fn('cmd')(function* (commandInput, options) {
51
+ const cwd = yield* CurrentWorkingDirectory
52
+
53
+ const asArray = Array.isArray(commandInput)
54
+ const parts = asArray ? (commandInput as (string | undefined)[]).filter(isNotUndefined) : undefined
55
+ const [command, ...args] = asArray ? (parts as string[]) : (commandInput as string).split(' ')
56
+
57
+ const debugEnvStr = Object.entries(options?.env ?? {})
58
+ .map(([key, value]) => `${key}='${value}' `)
59
+ .join('')
60
+
61
+ const loggingOpts = {
62
+ ...(options?.logDir ? { logDir: options.logDir } : {}),
63
+ ...(options?.logFileName ? { logFileName: options.logFileName } : {}),
64
+ ...(options?.logRetention ? { logRetention: options.logRetention } : {}),
65
+ } as const
66
+ const { input: finalInput, subshell: needsShell, logPath } = yield* applyLoggingToCommand(commandInput, loggingOpts)
67
+
68
+ const stdoutMode = options?.stdout ?? 'inherit'
69
+ const stderrMode = options?.stderr ?? 'inherit'
70
+ const useShell = (options?.shell ? true : false) || needsShell
71
+
72
+ const commandDebugStr =
73
+ debugEnvStr + (Array.isArray(finalInput) ? (finalInput as string[]).join(' ') : (finalInput as string))
74
+ const subshellStr = useShell ? ' (in subshell)' : ''
75
+
76
+ yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
77
+ yield* Effect.annotateCurrentSpan({
78
+ 'span.label': commandDebugStr,
79
+ cwd,
80
+ command,
81
+ args,
82
+ logDir: options?.logDir,
83
+ })
84
+
85
+ const baseArgs = {
86
+ commandInput: finalInput,
87
+ cwd,
88
+ env: options?.env ?? {},
89
+ stdoutMode,
90
+ stderrMode,
91
+ useShell,
92
+ } as const
93
+
94
+ const exitCode = yield* isNotUndefined(logPath)
95
+ ? Effect.gen(function* () {
96
+ yield* Effect.sync(() => console.log(`Logging output to ${logPath}`))
97
+ return yield* runWithLogging({ ...baseArgs, logPath, threadName: commandDebugStr })
98
+ })
99
+ : runWithoutLogging(baseArgs)
100
+
101
+ if (exitCode !== SUCCESS_EXIT_CODE) {
102
+ return yield* Effect.fail(
103
+ CmdError.make({
104
+ command: command!,
105
+ args,
106
+ cwd,
107
+ env: options?.env ?? {},
108
+ stderr: stderrMode,
109
+ }),
110
+ )
111
+ }
112
+
113
+ return exitCode
114
+ })
115
+
116
+ export const cmdText: (
117
+ commandInput: string | (string | undefined)[],
118
+ options?: {
119
+ stderr?: 'inherit' | 'pipe'
120
+ runInShell?: boolean
121
+ env?: Record<string, string | undefined>
122
+ },
123
+ ) => Effect.Effect<string, PlatformError.PlatformError, CommandExecutor.CommandExecutor | CurrentWorkingDirectory> =
124
+ Effect.fn('cmdText')(function* (commandInput, options) {
125
+ const cwd = yield* CurrentWorkingDirectory
126
+ const [command, ...args] = Array.isArray(commandInput)
127
+ ? commandInput.filter(isNotUndefined)
128
+ : commandInput.split(' ')
129
+ const debugEnvStr = Object.entries(options?.env ?? {})
130
+ .map(([key, value]) => `${key}='${value}' `)
131
+ .join('')
132
+
133
+ const commandDebugStr = debugEnvStr + [command, ...args].join(' ')
134
+ const subshellStr = options?.runInShell ? ' (in subshell)' : ''
135
+
136
+ yield* Effect.logDebug(`Running '${commandDebugStr}' in '${cwd}'${subshellStr}`)
137
+ yield* Effect.annotateCurrentSpan({ 'span.label': commandDebugStr, command, cwd })
138
+
139
+ return yield* Command.make(command!, ...args).pipe(
140
+ // inherit = Stream stderr to process.stderr, pipe = Stream stderr to process.stdout
141
+ Command.stderr(options?.stderr ?? 'inherit'),
142
+ Command.workingDirectory(cwd),
143
+ options?.runInShell ? Command.runInShell(true) : identity,
144
+ Command.env(options?.env ?? {}),
145
+ Command.string,
146
+ )
147
+ })
148
+
149
+ export class CmdError extends Schema.TaggedError<CmdError>()('CmdError', {
150
+ command: Schema.String,
151
+ args: Schema.Array(Schema.String),
152
+ cwd: Schema.String,
153
+ env: Schema.Record({ key: Schema.String, value: Schema.String.pipe(Schema.UndefinedOr) }),
154
+ stderr: Schema.Literal('inherit', 'pipe'),
155
+ }) {}
156
+
157
+ type TRunBaseArgs = {
158
+ readonly commandInput: string | string[]
159
+ readonly cwd: string
160
+ readonly env: Record<string, string | undefined>
161
+ readonly stdoutMode: 'inherit' | 'pipe'
162
+ readonly stderrMode: 'inherit' | 'pipe'
163
+ readonly useShell: boolean
164
+ }
165
+
166
+ const runWithoutLogging = ({ commandInput, cwd, env, stdoutMode, stderrMode, useShell }: TRunBaseArgs) =>
167
+ buildCommand(commandInput, useShell).pipe(
168
+ Command.stdin('inherit'),
169
+ Command.stdout(stdoutMode),
170
+ Command.stderr(stderrMode),
171
+ Command.workingDirectory(cwd),
172
+ useShell ? Command.runInShell(true) : identity,
173
+ Command.env(env),
174
+ Command.exitCode,
175
+ )
176
+
177
+ type TRunWithLoggingArgs = TRunBaseArgs & {
178
+ readonly logPath: string
179
+ readonly threadName: string
180
+ }
181
+
182
+ const runWithLogging = ({
183
+ commandInput,
184
+ cwd,
185
+ env,
186
+ stdoutMode,
187
+ stderrMode,
188
+ useShell,
189
+ logPath,
190
+ threadName,
191
+ }: TRunWithLoggingArgs) =>
192
+ // When logging is enabled we have to replace the `2>&1 | tee` pipeline the
193
+ // shell used to give us. We now pipe both streams through Effect so we can
194
+ // mirror to the terminal (only when requested) and append formatted entries
195
+ // into the canonical log ourselves.
196
+ Effect.scoped(
197
+ Effect.gen(function* () {
198
+ const envWithColor = env.FORCE_COLOR === undefined ? { ...env, FORCE_COLOR: '1' } : env
199
+
200
+ const logFile = yield* Effect.acquireRelease(
201
+ Effect.sync(() => fs.openSync(logPath, 'a', 0o666)),
202
+ (fd) => Effect.sync(() => fs.closeSync(fd)),
203
+ )
204
+
205
+ const prettyLogger = FileLogger.prettyLoggerTty({
206
+ colors: true,
207
+ stderr: false,
208
+ formatDate: (date) => `${FileLogger.defaultDateFormat(date)} ${threadName}`,
209
+ })
210
+
211
+ const appendLog = ({ channel, content }: { channel: 'stdout' | 'stderr'; content: string }) =>
212
+ Effect.sync(() => {
213
+ const formatted = prettyLogger.log({
214
+ fiberId: FiberId.none,
215
+ logLevel: channel === 'stdout' ? LogLevel.Info : LogLevel.Warning,
216
+ message: [`[${channel}]${content.length > 0 ? ` ${content}` : ''}`],
217
+ cause: Cause.empty,
218
+ context: FiberRefs.empty(),
219
+ spans: List.empty(),
220
+ annotations: HashMap.empty(),
221
+ date: new Date(),
222
+ })
223
+ fs.writeSync(logFile, formatted)
224
+ })
225
+
226
+ const command = buildCommand(commandInput, useShell).pipe(
227
+ Command.stdin('inherit'),
228
+ Command.stdout('pipe'),
229
+ Command.stderr('pipe'),
230
+ Command.workingDirectory(cwd),
231
+ useShell ? Command.runInShell(true) : identity,
232
+ Command.env(envWithColor),
233
+ )
234
+
235
+ // Acquire/start the command and make sure we kill it on interruption.
236
+ const runningProcess = yield* Effect.acquireRelease(command.pipe(Command.start), (proc) =>
237
+ proc.isRunning.pipe(
238
+ Effect.flatMap((running) => (running ? proc.kill().pipe(Effect.catchAll(() => Effect.void)) : Effect.void)),
239
+ Effect.ignore,
240
+ ),
241
+ )
242
+
243
+ const stdoutHandler = makeStreamHandler({
244
+ channel: 'stdout',
245
+ ...(stdoutMode === 'inherit' ? { mirrorTarget: process.stdout } : {}),
246
+ appendLog,
247
+ })
248
+ const stderrHandler = makeStreamHandler({
249
+ channel: 'stderr',
250
+ ...(stderrMode === 'inherit' ? { mirrorTarget: process.stderr } : {}),
251
+ appendLog,
252
+ })
253
+
254
+ const stdoutFiber = yield* runningProcess.stdout.pipe(
255
+ Stream.decodeText('utf8'),
256
+ Stream.runForEach((chunk) => stdoutHandler.onChunk(chunk)),
257
+ Effect.forkScoped,
258
+ )
259
+
260
+ const stderrFiber = yield* runningProcess.stderr.pipe(
261
+ Stream.decodeText('utf8'),
262
+ Stream.runForEach((chunk) => stderrHandler.onChunk(chunk)),
263
+ Effect.forkScoped,
264
+ )
265
+
266
+ // Dump any buffered data and finish both stream fibers before we return.
267
+ const flushOutputs = Effect.gen(function* () {
268
+ const stillRunning = yield* runningProcess.isRunning.pipe(Effect.catchAll(() => Effect.succeed(false)))
269
+ if (stillRunning) {
270
+ yield* Effect.ignore(runningProcess.kill())
271
+ }
272
+ yield* Effect.ignore(Fiber.join(stdoutFiber))
273
+ yield* Effect.ignore(Fiber.join(stderrFiber))
274
+ yield* stdoutHandler.flush()
275
+ yield* stderrHandler.flush()
276
+ })
277
+
278
+ const exitCode = yield* runningProcess.exitCode.pipe(Effect.ensuring(flushOutputs))
279
+
280
+ return exitCode
281
+ }),
282
+ )
283
+
284
+ const buildCommand = (input: string | string[], useShell: boolean) => {
285
+ if (Array.isArray(input)) {
286
+ const [c, ...a] = input
287
+ return Command.make(c!, ...a)
288
+ }
289
+
290
+ if (useShell) {
291
+ return Command.make(input)
292
+ }
293
+
294
+ const [c, ...a] = input.split(' ')
295
+ return Command.make(c!, ...a)
296
+ }
297
+
298
+ type TLineTerminator = 'newline' | 'carriage-return' | 'none'
299
+
300
+ type TStreamHandler = {
301
+ readonly onChunk: (chunk: string) => Effect.Effect<void, never>
302
+ readonly flush: () => Effect.Effect<void, never>
303
+ }
304
+
305
+ const makeStreamHandler = ({
306
+ channel,
307
+ mirrorTarget,
308
+ appendLog,
309
+ }: {
310
+ readonly channel: 'stdout' | 'stderr'
311
+ readonly mirrorTarget?: NodeJS.WriteStream
312
+ readonly appendLog: (args: { channel: 'stdout' | 'stderr'; content: string }) => Effect.Effect<void, never>
313
+ }): TStreamHandler => {
314
+ let buffer = ''
315
+
316
+ // Effect's FileLogger expects line-oriented messages, but the subprocess
317
+ // gives us arbitrary UTF-8 chunks. We keep a tiny line splitter here so the
318
+ // log and console stay readable (and consistent with the previous `tee`
319
+ // behaviour).
320
+ const emit = (content: string, terminator: TLineTerminator) =>
321
+ emitSegment({
322
+ channel,
323
+ content,
324
+ terminator,
325
+ ...(mirrorTarget ? { mirrorTarget } : {}),
326
+ appendLog,
327
+ })
328
+
329
+ const consumeBuffer = (): Effect.Effect<void, never> => {
330
+ if (buffer.length === 0) return Effect.void
331
+
332
+ const lastChar = buffer[buffer.length - 1]
333
+ if (lastChar === '\r') {
334
+ const line = buffer.slice(0, -1)
335
+ buffer = ''
336
+ return emit(line, 'carriage-return')
337
+ }
338
+
339
+ const line = buffer
340
+ buffer = ''
341
+ return line.length === 0 ? Effect.void : emit(line, 'none')
342
+ }
343
+
344
+ return {
345
+ onChunk: (chunk) =>
346
+ Effect.gen(function* () {
347
+ buffer += chunk
348
+ while (buffer.length > 0) {
349
+ const newlineIndex = buffer.indexOf('\n')
350
+ const carriageIndex = buffer.indexOf('\r')
351
+
352
+ if (newlineIndex === -1 && carriageIndex === -1) {
353
+ break
354
+ }
355
+
356
+ let index: number
357
+ let terminator: TLineTerminator
358
+ let skip = 1
359
+
360
+ if (carriageIndex !== -1 && (newlineIndex === -1 || carriageIndex < newlineIndex)) {
361
+ index = carriageIndex
362
+ if (carriageIndex + 1 < buffer.length && buffer[carriageIndex + 1] === '\n') {
363
+ skip = 2
364
+ terminator = 'newline'
365
+ } else {
366
+ terminator = 'carriage-return'
367
+ }
368
+ } else {
369
+ index = newlineIndex!
370
+ terminator = 'newline'
371
+ }
372
+
373
+ const line = buffer.slice(0, index)
374
+ buffer = buffer.slice(index + skip)
375
+ yield* emit(line, terminator)
376
+ }
377
+ }),
378
+ flush: () => consumeBuffer(),
379
+ }
380
+ }
381
+
382
+ const emitSegment = ({
383
+ channel,
384
+ content,
385
+ terminator,
386
+ mirrorTarget,
387
+ appendLog,
388
+ }: {
389
+ readonly channel: 'stdout' | 'stderr'
390
+ readonly content: string
391
+ readonly terminator: TLineTerminator
392
+ readonly mirrorTarget?: NodeJS.WriteStream
393
+ readonly appendLog: (args: { channel: 'stdout' | 'stderr'; content: string }) => Effect.Effect<void, never>
394
+ }) =>
395
+ Effect.gen(function* () {
396
+ if (mirrorTarget) {
397
+ yield* Effect.sync(() => mirrorSegment(mirrorTarget, content, terminator))
398
+ }
399
+
400
+ const contentForLog = terminator === 'carriage-return' ? `${content}\r` : content
401
+
402
+ yield* appendLog({ channel, content: contentForLog })
403
+ })
404
+
405
+ const mirrorSegment = (target: NodeJS.WriteStream, content: string, terminator: TLineTerminator) => {
406
+ switch (terminator) {
407
+ case 'newline': {
408
+ target.write(`${content}\n`)
409
+ break
410
+ }
411
+ case 'carriage-return': {
412
+ target.write(`${content}\r`)
413
+ break
414
+ }
415
+ case 'none': {
416
+ target.write(content)
417
+ break
418
+ }
419
+ }
420
+ }