@livestore/utils-dev 0.4.0-dev.8 → 0.4.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 (59) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/node/DockerComposeService/DockerComposeService.d.ts +9 -4
  3. package/dist/node/DockerComposeService/DockerComposeService.d.ts.map +1 -1
  4. package/dist/node/DockerComposeService/DockerComposeService.js +74 -49
  5. package/dist/node/DockerComposeService/DockerComposeService.js.map +1 -1
  6. package/dist/node/DockerComposeService/DockerComposeService.test.js +2 -2
  7. package/dist/node/DockerComposeService/DockerComposeService.test.js.map +1 -1
  8. package/dist/node/FileLogger.d.ts.map +1 -1
  9. package/dist/node/FileLogger.js +3 -3
  10. package/dist/node/FileLogger.js.map +1 -1
  11. package/dist/node/cmd-log.d.ts +21 -0
  12. package/dist/node/cmd-log.d.ts.map +1 -0
  13. package/dist/node/cmd-log.js +47 -0
  14. package/dist/node/cmd-log.js.map +1 -0
  15. package/dist/node/cmd.d.ts +12 -4
  16. package/dist/node/cmd.d.ts.map +1 -1
  17. package/dist/node/cmd.js +207 -29
  18. package/dist/node/cmd.js.map +1 -1
  19. package/dist/node/cmd.test.d.ts +2 -0
  20. package/dist/node/cmd.test.d.ts.map +1 -0
  21. package/dist/node/cmd.test.js +103 -0
  22. package/dist/node/cmd.test.js.map +1 -0
  23. package/dist/node/mod.d.ts +3 -2
  24. package/dist/node/mod.d.ts.map +1 -1
  25. package/dist/node/mod.js +63 -18
  26. package/dist/node/mod.js.map +1 -1
  27. package/dist/node/workspace.d.ts +22 -0
  28. package/dist/node/workspace.d.ts.map +1 -0
  29. package/dist/node/workspace.js +26 -0
  30. package/dist/node/workspace.js.map +1 -0
  31. package/dist/node-vitest/Vitest.d.ts.map +1 -1
  32. package/dist/node-vitest/Vitest.js +11 -11
  33. package/dist/node-vitest/Vitest.js.map +1 -1
  34. package/dist/node-vitest/Vitest.test.d.ts +8 -0
  35. package/dist/node-vitest/Vitest.test.d.ts.map +1 -1
  36. package/dist/node-vitest/Vitest.test.js +11 -7
  37. package/dist/node-vitest/Vitest.test.js.map +1 -1
  38. package/dist/wrangler/WranglerDevServer.d.ts +21 -4
  39. package/dist/wrangler/WranglerDevServer.d.ts.map +1 -1
  40. package/dist/wrangler/WranglerDevServer.js +23 -10
  41. package/dist/wrangler/WranglerDevServer.js.map +1 -1
  42. package/dist/wrangler/WranglerDevServer.test.js +3 -3
  43. package/dist/wrangler/WranglerDevServer.test.js.map +1 -1
  44. package/package.json +74 -21
  45. package/src/node/DockerComposeService/DockerComposeService.test.ts +5 -2
  46. package/src/node/DockerComposeService/DockerComposeService.ts +152 -101
  47. package/src/node/DockerComposeService/test-fixtures/docker-compose.yml +1 -1
  48. package/src/node/FileLogger.ts +4 -3
  49. package/src/node/cmd-log.ts +84 -0
  50. package/src/node/cmd.test.ts +134 -0
  51. package/src/node/cmd.ts +380 -62
  52. package/src/node/mod.ts +69 -21
  53. package/src/node/workspace.ts +46 -0
  54. package/src/node-vitest/Vitest.test.ts +12 -7
  55. package/src/node-vitest/Vitest.ts +28 -24
  56. package/src/wrangler/WranglerDevServer.test.ts +5 -3
  57. package/src/wrangler/WranglerDevServer.ts +54 -13
  58. package/src/wrangler/fixtures/wrangler.toml +1 -1
  59. package/dist/.tsbuildinfo.json +0 -1
@@ -1,9 +1,10 @@
1
- import { omitUndefineds } from '@livestore/utils'
1
+ import { objectToString, omitUndefineds } from '@livestore/utils'
2
2
  import {
3
3
  Command,
4
4
  type CommandExecutor,
5
5
  Duration,
6
6
  Effect,
7
+ Fiber,
7
8
  type PlatformError,
8
9
  Schedule,
9
10
  Schema,
@@ -11,7 +12,7 @@ import {
11
12
  Stream,
12
13
  } from '@livestore/utils/effect'
13
14
 
14
- export class DockerComposeError extends Schema.TaggedError<DockerComposeError>()('DockerComposeError', {
15
+ export class DockerComposeError extends Schema.TaggedError<DockerComposeError>('~@livestore/utils-dev/DockerComposeError')('DockerComposeError', {
15
16
  cause: Schema.Defect,
16
17
  note: Schema.String,
17
18
  }) {}
@@ -19,6 +20,8 @@ export class DockerComposeError extends Schema.TaggedError<DockerComposeError>()
19
20
  export interface DockerComposeArgs {
20
21
  readonly cwd: string
21
22
  readonly serviceName?: string
23
+ /** Unique project name to isolate this compose instance. If not provided, a random one is generated. */
24
+ readonly projectName?: string
22
25
  }
23
26
 
24
27
  export interface StartOptions {
@@ -51,92 +54,133 @@ export interface DockerComposeOperations {
51
54
  readonly logs: (
52
55
  options?: LogsOptions,
53
56
  ) => Stream.Stream<string, DockerComposeError | PlatformError.PlatformError, Scope.Scope>
57
+ /** The unique project name used to isolate this compose instance */
58
+ readonly projectName: string
54
59
  }
55
60
 
61
+ const generateProjectName = (): string => `ls-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
62
+
56
63
  export class DockerComposeService extends Effect.Service<DockerComposeService>()('DockerComposeService', {
57
64
  scoped: (args: DockerComposeArgs) =>
58
65
  Effect.gen(function* () {
59
66
  const { cwd, serviceName } = args
67
+ const projectName = args.projectName ?? generateProjectName()
60
68
 
61
69
  const commandExecutorContext = yield* Effect.context<CommandExecutor.CommandExecutor>()
62
70
 
71
+ const baseComposeArgs = ['-p', projectName]
72
+
63
73
  const pull = Effect.gen(function* () {
64
74
  yield* Effect.log(`Pulling Docker Compose images in ${cwd}`)
65
75
 
66
- yield* Command.make('docker', 'compose', 'pull').pipe(
76
+ // TODO (@IMax153) Refactor the effect command related code below as there is probably a much more elegant way to accomplish what we want here in a more effect idiomatic way.
77
+ const pullCommand = Command.make('docker', 'compose', ...baseComposeArgs, 'pull').pipe(
67
78
  Command.workingDirectory(cwd),
68
- Command.exitCode,
69
- Effect.flatMap((exitCode: number) =>
79
+ Command.stdout('pipe'),
80
+ Command.stderr('pipe'),
81
+ )
82
+
83
+ const process = yield* pullCommand.pipe(Command.start, Effect.provide(commandExecutorContext))
84
+
85
+ const stdoutFiber = yield* process.stdout.pipe(
86
+ Stream.decodeText('utf8'),
87
+ Stream.runFold('', (acc, chunk) => acc + chunk),
88
+ Effect.fork,
89
+ )
90
+
91
+ const stderrFiber = yield* process.stderr.pipe(
92
+ Stream.decodeText('utf8'),
93
+ Stream.runFold('', (acc, chunk) => acc + chunk),
94
+ Effect.fork,
95
+ )
96
+
97
+ const exitCode = yield* process.exitCode
98
+ const stdout = yield* Fiber.join(stdoutFiber)
99
+ const stderr = yield* Fiber.join(stderrFiber)
100
+
101
+ const exitCodeNumber = Number(exitCode)
102
+
103
+ if (exitCodeNumber !== 0) {
104
+ const stdoutLog = stdout.length > 0 ? stdout : '<empty stdout>'
105
+ const stderrLog = stderr.length > 0 ? stderr : '<empty stderr>'
106
+ const failureMessage = [
107
+ `Docker compose pull failed with exit code ${exitCodeNumber} in ${cwd}`,
108
+ `stdout:\n${stdoutLog}`,
109
+ `stderr:\n${stderrLog}`,
110
+ ].join('\n')
111
+
112
+ yield* Effect.logError(failureMessage)
113
+
114
+ return yield* new DockerComposeError({
115
+ cause: new Error(`Docker compose pull failed with exit code ${exitCodeNumber}`),
116
+ note: failureMessage,
117
+ })
118
+ }
119
+
120
+ yield* Effect.log(`Successfully pulled Docker Compose images`)
121
+ }).pipe(
122
+ Effect.retry({
123
+ schedule: Schedule.exponentialBackoff10Sec,
124
+ while: Schema.is(DockerComposeError),
125
+ }),
126
+ Effect.withSpan('pullDockerComposeImages'),
127
+ Effect.scoped,
128
+ )
129
+
130
+ const start = Effect.fn('startDockerCompose')(function* (options: StartOptions = {}) {
131
+ const { detached = true, healthCheck } = options
132
+
133
+ // Build start command
134
+ const startArgs = ['docker', 'compose', ...baseComposeArgs, 'up']
135
+ if (detached === true) startArgs.push('-d')
136
+ if (serviceName !== undefined) startArgs.push(serviceName)
137
+
138
+ const command = yield* Command.make(startArgs[0]!, ...startArgs.slice(1)).pipe(
139
+ Command.workingDirectory(cwd),
140
+ Command.env(options.env ?? {}),
141
+ Command.stderr('inherit'),
142
+ Command.stdout('inherit'),
143
+ Command.start,
144
+ Effect.mapError(
145
+ (cause) =>
146
+ new DockerComposeError({
147
+ cause,
148
+ note: `Failed to start Docker Compose services in ${cwd}`,
149
+ }),
150
+ ),
151
+ Effect.provide(commandExecutorContext),
152
+ )
153
+
154
+ // Wait for command completion
155
+ yield* command.exitCode.pipe(
156
+ Effect.flatMap((exitCode) =>
70
157
  exitCode === 0
71
158
  ? Effect.void
72
159
  : Effect.fail(
73
160
  new DockerComposeError({
74
- cause: new Error(`Docker compose pull failed with exit code ${exitCode}`),
75
- note: `Docker compose pull failed with exit code ${exitCode}`,
161
+ cause: new Error(`Docker compose exited with code ${exitCode}`),
162
+ note: `Docker Compose failed to start with exit code ${exitCode}. Env: ${JSON.stringify(options.env)}`,
76
163
  }),
77
164
  ),
78
165
  ),
79
166
  Effect.provide(commandExecutorContext),
80
167
  )
81
168
 
82
- yield* Effect.log(`Successfully pulled Docker Compose images`)
83
- }).pipe(Effect.withSpan('pullDockerComposeImages'))
84
-
85
- const start = (options: StartOptions = {}) =>
86
- Effect.gen(function* () {
87
- const { detached = true, healthCheck } = options
88
-
89
- // Build start command
90
- const baseArgs = ['docker', 'compose', 'up']
91
- if (detached) baseArgs.push('-d')
92
- if (serviceName) baseArgs.push(serviceName)
93
-
94
- const command = yield* Command.make(baseArgs[0]!, ...baseArgs.slice(1)).pipe(
95
- Command.workingDirectory(cwd),
96
- Command.env(options.env ?? {}),
97
- Command.stderr('inherit'),
98
- Command.stdout('inherit'),
99
- Command.start,
100
- Effect.catchAll((cause) =>
101
- Effect.fail(
102
- new DockerComposeError({
103
- cause,
104
- note: `Failed to start Docker Compose services in ${cwd}`,
105
- }),
106
- ),
107
- ),
108
- Effect.provide(commandExecutorContext),
109
- )
110
-
111
- // Wait for command completion
112
- yield* command.exitCode.pipe(
113
- Effect.flatMap((exitCode) =>
114
- exitCode === 0
115
- ? Effect.void
116
- : Effect.fail(
117
- new DockerComposeError({
118
- cause: new Error(`Docker compose exited with code ${exitCode}`),
119
- note: `Docker Compose failed to start with exit code ${exitCode}. Env: ${JSON.stringify(options.env)}`,
120
- }),
121
- ),
122
- ),
123
- Effect.provide(commandExecutorContext),
124
- )
125
-
126
- // Perform health check if requested
127
- if (healthCheck) {
128
- yield* performHealthCheck(healthCheck).pipe(Effect.provide(commandExecutorContext))
129
- }
169
+ // Perform health check if requested
170
+ if (healthCheck !== undefined) {
171
+ yield* performHealthCheck(healthCheck).pipe(Effect.provide(commandExecutorContext))
172
+ }
130
173
 
131
- yield* Effect.log(`Docker Compose services started successfully in ${cwd}`)
132
- }).pipe(Effect.withSpan('startDockerCompose'))
174
+ yield* Effect.log(`Docker Compose services started successfully in ${cwd}`)
175
+ })
133
176
 
134
177
  const stop = Effect.gen(function* () {
135
178
  yield* Effect.log(`Stopping Docker Compose services in ${cwd}`)
136
179
 
137
- const stopCommand = serviceName
138
- ? Command.make('docker', 'compose', 'stop', serviceName)
139
- : Command.make('docker', 'compose', 'stop')
180
+ const stopCommand =
181
+ serviceName !== undefined
182
+ ? Command.make('docker', 'compose', ...baseComposeArgs, 'stop', serviceName)
183
+ : Command.make('docker', 'compose', ...baseComposeArgs, 'stop')
140
184
 
141
185
  yield* stopCommand.pipe(
142
186
  Command.workingDirectory(cwd),
@@ -161,22 +205,21 @@ export class DockerComposeService extends Effect.Service<DockerComposeService>()
161
205
  Effect.gen(function* () {
162
206
  const { follow = false, tail, since } = options
163
207
 
164
- const baseArgs = ['docker', 'compose', 'logs']
165
- if (follow) baseArgs.push('-f')
166
- if (tail) baseArgs.push('--tail', tail.toString())
167
- if (since) baseArgs.push('--since', since)
168
- if (serviceName) baseArgs.push(serviceName)
208
+ const logsArgs = ['docker', 'compose', ...baseComposeArgs, 'logs']
209
+ if (follow === true) logsArgs.push('-f')
210
+ if (tail !== undefined) logsArgs.push('--tail', tail.toString())
211
+ if (since !== undefined) logsArgs.push('--since', since)
212
+ if (serviceName !== undefined) logsArgs.push(serviceName)
169
213
 
170
- const command = yield* Command.make(baseArgs[0]!, ...baseArgs.slice(1)).pipe(
214
+ const command = yield* Command.make(logsArgs[0]!, ...logsArgs.slice(1)).pipe(
171
215
  Command.workingDirectory(cwd),
172
216
  Command.start,
173
- Effect.catchAll((cause) =>
174
- Effect.fail(
217
+ Effect.mapError(
218
+ (cause) =>
175
219
  new DockerComposeError({
176
220
  cause,
177
221
  note: `Failed to read Docker Compose logs in ${cwd}`,
178
222
  }),
179
- ),
180
223
  ),
181
224
  Effect.provide(commandExecutorContext),
182
225
  )
@@ -194,40 +237,49 @@ export class DockerComposeService extends Effect.Service<DockerComposeService>()
194
237
  )
195
238
  }).pipe(Stream.unwrapScoped)
196
239
 
197
- const down = (options?: {
240
+ const down = Effect.fn('downDockerCompose')(function* (options?: {
198
241
  readonly env?: Record<string, string>
199
242
  readonly volumes?: boolean
200
243
  readonly removeOrphans?: boolean
201
- }) =>
202
- Effect.gen(function* () {
203
- yield* Effect.log(`Tearing down Docker Compose services in ${cwd}`)
244
+ }) {
245
+ yield* Effect.log(`Tearing down Docker Compose services in ${cwd}`)
204
246
 
205
- const baseArgs = ['docker', 'compose', 'down']
206
- if (options?.volumes) baseArgs.push('-v')
207
- if (options?.removeOrphans) baseArgs.push('--remove-orphans')
208
- if (serviceName) baseArgs.push(serviceName)
247
+ const downArgs = ['docker', 'compose', ...baseComposeArgs, 'down']
248
+ if (options?.volumes === true) downArgs.push('-v')
249
+ if (options?.removeOrphans === true) downArgs.push('--remove-orphans')
250
+ if (serviceName !== undefined) downArgs.push(serviceName)
209
251
 
210
- yield* Command.make(baseArgs[0]!, ...baseArgs.slice(1)).pipe(
211
- Command.workingDirectory(cwd),
212
- Command.env(options?.env ?? {}),
213
- Command.exitCode,
214
- Effect.flatMap((exitCode: number) =>
215
- exitCode === 0
216
- ? Effect.void
217
- : Effect.fail(
218
- new DockerComposeError({
219
- cause: new Error(`Docker compose down exited with code ${exitCode}`),
220
- note: `Failed to tear down Docker Compose services`,
221
- }),
222
- ),
223
- ),
224
- Effect.provide(commandExecutorContext),
225
- )
252
+ yield* Command.make(downArgs[0]!, ...downArgs.slice(1)).pipe(
253
+ Command.workingDirectory(cwd),
254
+ Command.env(options?.env ?? {}),
255
+ Command.exitCode,
256
+ Effect.flatMap((exitCode: number) =>
257
+ exitCode === 0
258
+ ? Effect.void
259
+ : Effect.fail(
260
+ new DockerComposeError({
261
+ cause: new Error(`Docker compose down exited with code ${exitCode}`),
262
+ note: `Failed to tear down Docker Compose services`,
263
+ }),
264
+ ),
265
+ ),
266
+ Effect.provide(commandExecutorContext),
267
+ )
226
268
 
227
- yield* Effect.log(`Docker Compose services torn down successfully`)
228
- }).pipe(Effect.withSpan('downDockerCompose'))
269
+ yield* Effect.log(`Docker Compose services torn down successfully`)
270
+ })
229
271
 
230
- return { pull, start, stop, down, logs }
272
+ // Register cleanup finalizer to ensure containers are removed when scope closes
273
+ yield* Effect.addFinalizer(() =>
274
+ down({ volumes: true, removeOrphans: true }).pipe(
275
+ Effect.tap(() => Effect.log(`Docker Compose cleanup completed for project ${projectName}`)),
276
+ Effect.catchAll((error) =>
277
+ Effect.log('Docker Compose cleanup failed for project', projectName, objectToString(error)),
278
+ ),
279
+ ),
280
+ )
281
+
282
+ return { pull, start, stop, down, logs, projectName }
231
283
  }),
232
284
  }) {}
233
285
 
@@ -251,17 +303,16 @@ const performHealthCheck = ({
251
303
 
252
304
  const healthCheck = checkHealth.pipe(
253
305
  Effect.repeat({
254
- while: (healthy: boolean) => !healthy,
306
+ while: (healthy: boolean) => healthy === false,
255
307
  schedule: Schedule.fixed(interval),
256
308
  }),
257
309
  Effect.timeout(timeout),
258
- Effect.catchAll(() =>
259
- Effect.fail(
310
+ Effect.mapError(
311
+ () =>
260
312
  new DockerComposeError({
261
313
  cause: new Error('Health check timeout'),
262
314
  note: `Health check failed for ${url} after ${Duration.toMillis(timeout)}ms`,
263
315
  }),
264
- ),
265
316
  ),
266
317
  )
267
318
 
@@ -284,7 +335,7 @@ export const startDockerComposeServicesScoped = (
284
335
 
285
336
  // Start the services
286
337
  yield* dockerCompose.start({
287
- ...omitUndefineds({ healthCheck: args.healthCheck ? args.healthCheck : undefined }),
338
+ ...omitUndefineds({ healthCheck: args.healthCheck !== undefined ? args.healthCheck : undefined }),
288
339
  })
289
340
 
290
341
  // Add cleanup finalizer to the current scope
@@ -1,4 +1,4 @@
1
1
  services:
2
2
  hello-world:
3
3
  image: hello-world
4
- profiles: [manual] # Don't auto-start this service
4
+ profiles: [manual] # Don't auto-start this service
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import util from 'node:util'
4
+
4
5
  import {
5
6
  Cause,
6
7
  Effect,
@@ -120,7 +121,7 @@ export const prettyLoggerTty = (options: {
120
121
  readonly formatDate: (date: Date) => string
121
122
  readonly onLog?: (str: string) => void
122
123
  }) => {
123
- const color = options.colors ? withColor : withColorNoop
124
+ const color = options.colors === true ? withColor : withColorNoop
124
125
  return Logger.make<unknown, string>(({ annotations, cause, date, fiberId, logLevel, message: message_, spans }) => {
125
126
  let str = ''
126
127
 
@@ -141,7 +142,7 @@ export const prettyLoggerTty = (options: {
141
142
  ` ${color(logLevel.label, ...logLevelColors[logLevel._tag])}` +
142
143
  ` (${FiberId.threadName(fiberId)})`
143
144
 
144
- if (List.isCons(spans)) {
145
+ if (List.isCons(spans) === true) {
145
146
  const now = date.getTime()
146
147
  const render = LogSpan.render(now)
147
148
  for (const span of spans) {
@@ -162,7 +163,7 @@ export const prettyLoggerTty = (options: {
162
163
  log(firstLine)
163
164
  // if (!processIsBun) console.group()
164
165
 
165
- if (!Cause.isEmpty(cause)) {
166
+ if (Cause.isEmpty(cause) === false) {
166
167
  logIndented(Cause.pretty(cause, { renderErrorCause: true }))
167
168
  }
168
169
 
@@ -0,0 +1,84 @@
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> = Effect.fn(
18
+ 'cmd.logging.prepare',
19
+ )(function* ({ logDir, logFileName = 'dev.log', logRetention = 50 }: TCmdLoggingOptions) {
20
+ if (logDir == null || logDir === '') return undefined as string | undefined
21
+
22
+ const logsDir = logDir
23
+ const archiveDir = path.join(logsDir, 'archive')
24
+ const currentLogPath = path.join(logsDir, logFileName)
25
+
26
+ // Ensure directories exist
27
+ yield* Effect.sync(() => fs.mkdirSync(archiveDir, { recursive: true }))
28
+
29
+ // Archive previous log if present
30
+ if (fs.existsSync(currentLogPath) === true) {
31
+ const safeIso = new Date().toISOString().replaceAll(':', '-')
32
+ const archivedBase = `${path.parse(logFileName).name}-${safeIso}.log`
33
+ const archivedLog = path.join(archiveDir, archivedBase)
34
+ yield* Effect.try(() => fs.renameSync(currentLogPath, archivedLog)).pipe(
35
+ Effect.catchAll(() =>
36
+ Effect.try(() => {
37
+ fs.copyFileSync(currentLogPath, archivedLog)
38
+ fs.truncateSync(currentLogPath, 0)
39
+ }),
40
+ ),
41
+ Effect.ignore,
42
+ )
43
+
44
+ // Prune archives to retain only the newest N
45
+ yield* Effect.try(() => fs.readdirSync(archiveDir)).pipe(
46
+ Effect.map((names) => names.filter((n) => n.endsWith('.log'))),
47
+ Effect.map((names) =>
48
+ names
49
+ .map((name) => ({ name, mtimeMs: fs.statSync(path.join(archiveDir, name)).mtimeMs }))
50
+ .sort((a, b) => b.mtimeMs - a.mtimeMs),
51
+ ),
52
+ Effect.flatMap((entries) =>
53
+ Effect.forEach(entries.slice(logRetention), (e) =>
54
+ Effect.try(() => fs.unlinkSync(path.join(archiveDir, e.name))).pipe(Effect.ignore),
55
+ ),
56
+ ),
57
+ Effect.ignore,
58
+ )
59
+ }
60
+
61
+ return currentLogPath
62
+ })
63
+
64
+ /**
65
+ * Given a command input, applies logging by piping output through `tee` to the
66
+ * canonical log file. Returns the transformed input and whether a shell is required.
67
+ */
68
+ export const applyLoggingToCommand: (
69
+ commandInput: string | (string | undefined)[],
70
+ options: TCmdLoggingOptions,
71
+ ) => Effect.Effect<{ input: string | string[]; subshell: boolean; logPath?: string }> = Effect.fn('cmd.logging.apply')(
72
+ function* (commandInput, options) {
73
+ const asArray = Array.isArray(commandInput)
74
+ const parts = asArray === true ? commandInput.filter(isNotUndefined) : undefined
75
+
76
+ const logPath = yield* prepareCmdLogging(options)
77
+
78
+ return {
79
+ input: asArray === true ? ((parts as string[]) ?? []) : commandInput,
80
+ subshell: false,
81
+ ...(logPath !== undefined ? { logPath } : {}),
82
+ }
83
+ },
84
+ )
@@ -0,0 +1,134 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { CommandExecutor, Duration, Effect, Layer } from '@livestore/utils/effect'
4
+ import { PlatformNode } from '@livestore/utils/node'
5
+ import { Vitest } from '@livestore/utils-dev/node-vitest'
6
+ import { expect } from 'vitest'
7
+
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
+ /** TODO(#1020): Investigate CI timeout flakiness for cmd tests that spawn many child processes */
34
+ Vitest.scopedLive(
35
+ 'supports logging with archive + retention',
36
+ (test) =>
37
+ Effect.gen(function* () {
38
+ const workspace = process.env.WORKSPACE_ROOT!
39
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', String(Date.now()))
40
+
41
+ // first run
42
+ const exit1 = yield* cmd('printf first', { logDir: logsDir })
43
+ expect(exit1).toBe(CommandExecutor.ExitCode(0))
44
+ const current = path.join(logsDir, 'dev.log')
45
+ expect(fs.existsSync(current)).toBe(true)
46
+ const firstLog = fs.readFileSync(current, 'utf8')
47
+ const firstStdoutLines = firstLog.split('\n').filter((line) => line.includes('[stdout]'))
48
+ expect(firstStdoutLines.length).toBeGreaterThan(0)
49
+ for (const line of firstStdoutLines) {
50
+ expect(line).toContain('[stdout] first')
51
+ expect(line).toContain('INFO')
52
+ expect(line).toContain('printf first')
53
+ }
54
+
55
+ // second run — archives previous
56
+ const exit2 = yield* cmd('printf second', { logDir: logsDir })
57
+ expect(exit2).toBe(CommandExecutor.ExitCode(0))
58
+ const archiveDir = path.join(logsDir, 'archive')
59
+ const archives = fs.readdirSync(archiveDir).filter((f) => f.endsWith('.log'))
60
+ expect(archives.length).toBe(1)
61
+ const archivedPath = path.join(archiveDir, archives[0]!)
62
+ const archivedLog = fs.readFileSync(archivedPath, 'utf8')
63
+ const archivedStdoutLines = archivedLog.split('\n').filter((line) => line.includes('[stdout]'))
64
+ expect(archivedStdoutLines.length).toBeGreaterThan(0)
65
+ for (const line of archivedStdoutLines) {
66
+ expect(line).toContain('[stdout] first')
67
+ }
68
+
69
+ const secondLog = fs.readFileSync(current, 'utf8')
70
+ const secondStdoutLines = secondLog.split('\n').filter((line) => line.includes('[stdout]'))
71
+ expect(secondStdoutLines.length).toBeGreaterThan(0)
72
+ for (const line of secondStdoutLines) {
73
+ expect(line).toContain('[stdout] second')
74
+ expect(line).toContain('INFO')
75
+ }
76
+
77
+ // generate many archives to exercise retention (keep 50)
78
+ for (let i = 0; i < 60; i++) {
79
+ // Use small unique payloads
80
+ yield* cmd(['printf', String(i)], { logDir: logsDir })
81
+ }
82
+ const archivesAfter = fs.readdirSync(archiveDir).filter((f) => f.endsWith('.log'))
83
+ expect(archivesAfter.length).toBeLessThanOrEqual(50)
84
+ }).pipe(withNode(test)),
85
+ { timeout: 30_000, retry: 3 },
86
+ )
87
+
88
+ Vitest.scopedLive('streams stdout and stderr with logger formatting', (test) =>
89
+ Effect.gen(function* () {
90
+ const workspace = process.env.WORKSPACE_ROOT!
91
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', `format-${Date.now()}`)
92
+
93
+ const exit = yield* cmd(['node', '-e', "console.log('out'); console.error('err')"], {
94
+ logDir: logsDir,
95
+ })
96
+ expect(exit).toBe(CommandExecutor.ExitCode(0))
97
+
98
+ const current = path.join(logsDir, 'dev.log')
99
+ const logContent = fs.readFileSync(current, 'utf8')
100
+ expect(logContent).toMatch(/\[stdout] out/)
101
+ expect(logContent).toMatch(/\[stderr] err/)
102
+
103
+ const relevantLines = logContent
104
+ .split('\n')
105
+ .map((line) => line.trim())
106
+ .filter((line) => line.includes('[stdout]') || line.includes('[stderr]'))
107
+
108
+ expect(relevantLines.length).toBeGreaterThanOrEqual(2)
109
+
110
+ for (const line of relevantLines) {
111
+ const stripped = line.replace(ansiRegex, '')
112
+ expect(stripped.startsWith('[')).toBe(true)
113
+ expect(stripped).toMatch(/(INFO|WARN)/)
114
+ expect(stripped).toMatch(/\[(stdout|stderr)]/)
115
+ }
116
+ }).pipe(withNode(test)),
117
+ )
118
+
119
+ Vitest.scopedLive('cleans up logged child process when interrupted', (test) =>
120
+ Effect.gen(function* () {
121
+ const workspace = process.env.WORKSPACE_ROOT!
122
+ const logsDir = path.join(workspace, 'tmp', 'cmd-tests', `timeout-${Date.now()}`)
123
+
124
+ const result = yield* cmd(['node', '-e', 'setTimeout(() => {}, 5000)'], {
125
+ logDir: logsDir,
126
+ stdout: 'pipe',
127
+ stderr: 'pipe',
128
+ }).pipe(Effect.timeoutOption(Duration.millis(200)))
129
+
130
+ expect(result._tag).toBe('None')
131
+ expect(fs.existsSync(path.join(logsDir, 'dev.log'))).toBe(true)
132
+ }).pipe(withNode(test)),
133
+ )
134
+ })