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