@docker-harpoon/core 0.1.4 → 0.1.5

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 (87) hide show
  1. package/dist/__tests__/test-setup.d.ts.map +1 -1
  2. package/dist/__tests__/test-setup.js +8 -1
  3. package/dist/api/promise.d.ts +1 -1
  4. package/dist/api/promise.d.ts.map +1 -1
  5. package/dist/api/promise.js +28 -18
  6. package/dist/bindings/index.d.ts +2 -2
  7. package/dist/bindings/index.d.ts.map +1 -1
  8. package/dist/bindings/index.js +1 -1
  9. package/dist/bindings/types.d.ts.map +1 -1
  10. package/dist/bindings/types.js +1 -3
  11. package/dist/build-strategies/types.d.ts.map +1 -1
  12. package/dist/config-patchers/index.d.ts.map +1 -1
  13. package/dist/config-patchers/types.d.ts.map +1 -1
  14. package/dist/dockerfile-transformers/core.d.ts.map +1 -1
  15. package/dist/dockerfile-transformers/core.js +2 -5
  16. package/dist/dockerfile-transformers/index.d.ts.map +1 -1
  17. package/dist/dockerfile-transformers/types.d.ts.map +1 -1
  18. package/dist/errors.d.ts +6 -1
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js +3 -3
  21. package/dist/helpers/database.d.ts.map +1 -1
  22. package/dist/helpers/database.js +1 -3
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/resources/container.d.ts +7 -2
  27. package/dist/resources/container.d.ts.map +1 -1
  28. package/dist/resources/container.js +222 -31
  29. package/dist/resources/image.d.ts.map +1 -1
  30. package/dist/resources/image.js +14 -35
  31. package/dist/resources/network.d.ts.map +1 -1
  32. package/dist/resources/network.js +23 -9
  33. package/dist/resources/schemas.d.ts +177 -18
  34. package/dist/resources/schemas.d.ts.map +1 -1
  35. package/dist/resources/schemas.js +1 -1
  36. package/dist/services/CircuitBreaker.d.ts +83 -0
  37. package/dist/services/CircuitBreaker.d.ts.map +1 -0
  38. package/dist/services/CircuitBreaker.js +164 -0
  39. package/dist/services/ContainerPool.d.ts +82 -0
  40. package/dist/services/ContainerPool.d.ts.map +1 -0
  41. package/dist/services/ContainerPool.js +186 -0
  42. package/dist/services/DockerBatcher.d.ts +74 -0
  43. package/dist/services/DockerBatcher.d.ts.map +1 -0
  44. package/dist/services/DockerBatcher.js +107 -0
  45. package/dist/services/DockerClient.d.ts +125 -0
  46. package/dist/services/DockerClient.d.ts.map +1 -0
  47. package/dist/services/DockerClient.js +220 -0
  48. package/dist/services/DockerErrors.d.ts +145 -0
  49. package/dist/services/DockerErrors.d.ts.map +1 -0
  50. package/dist/services/DockerErrors.js +224 -0
  51. package/dist/services/DockerRateLimiter.d.ts +80 -0
  52. package/dist/services/DockerRateLimiter.d.ts.map +1 -0
  53. package/dist/services/DockerRateLimiter.js +93 -0
  54. package/dist/services/EventBus.d.ts +126 -0
  55. package/dist/services/EventBus.d.ts.map +1 -0
  56. package/dist/services/EventBus.js +111 -0
  57. package/dist/services/Harpoon.d.ts +151 -0
  58. package/dist/services/Harpoon.d.ts.map +1 -0
  59. package/dist/services/Harpoon.js +148 -0
  60. package/dist/services/HarpoonConfig.d.ts +60 -0
  61. package/dist/services/HarpoonConfig.d.ts.map +1 -0
  62. package/dist/services/HarpoonConfig.js +67 -0
  63. package/dist/services/HarpoonLogger.d.ts +36 -0
  64. package/dist/services/HarpoonLogger.d.ts.map +1 -0
  65. package/dist/services/HarpoonLogger.js +94 -0
  66. package/dist/services/ReadinessCoordinator.d.ts +128 -0
  67. package/dist/services/ReadinessCoordinator.d.ts.map +1 -0
  68. package/dist/services/ReadinessCoordinator.js +170 -0
  69. package/dist/services/ResourceTracker.d.ts +74 -0
  70. package/dist/services/ResourceTracker.d.ts.map +1 -0
  71. package/dist/services/ResourceTracker.js +145 -0
  72. package/dist/services/index.d.ts +29 -0
  73. package/dist/services/index.d.ts.map +1 -0
  74. package/dist/services/index.js +47 -0
  75. package/dist/testing/helpers.d.ts +114 -0
  76. package/dist/testing/helpers.d.ts.map +1 -0
  77. package/dist/testing/helpers.js +140 -0
  78. package/dist/testing/index.d.ts +29 -0
  79. package/dist/testing/index.d.ts.map +1 -0
  80. package/dist/testing/index.js +47 -0
  81. package/dist/testing/mocks.d.ts +66 -0
  82. package/dist/testing/mocks.d.ts.map +1 -0
  83. package/dist/testing/mocks.js +224 -0
  84. package/dist/utils/process.d.ts +24 -0
  85. package/dist/utils/process.d.ts.map +1 -0
  86. package/dist/utils/process.js +49 -0
  87. package/package.json +12 -8
@@ -6,17 +6,28 @@
6
6
  *
7
7
  * Supports bindings for environment variable injection and lifecycle hooks.
8
8
  */
9
- import { Effect, Schedule, Exit } from 'effect';
9
+ import { Effect, Schedule, Exit, Stream } from 'effect';
10
10
  import { toHarpoonError, ContainerError } from '../errors';
11
11
  import { mergeBindingsEnv } from '../bindings/types';
12
12
  import { containerStatsSchema, execOptionsSchema, execResultSchema, logOptionsSchema, } from './schemas';
13
13
  const CLEANUP_TIMEOUT_MS = 30_000; // 30 seconds for database graceful shutdown
14
+ const makeContainerLogger = (containerName) => ({
15
+ debug: (message, meta) => {
16
+ if (process.env.HARPOON_LOG_LEVEL === 'debug') {
17
+ console.log(`[Container:${containerName}] ${message}`, meta ?? '');
18
+ }
19
+ },
20
+ info: (message, _meta) => console.log(`[Container:${containerName}] ${message}`),
21
+ warn: (message, _meta) => console.warn(`[Container:${containerName}] ${message}`),
22
+ error: (message, meta) => console.error(`[Container:${containerName}] ${message}`, meta ?? ''),
23
+ });
14
24
  const safeRemoveContainer = async (docker, containerId, containerName) => {
15
25
  const container = docker.getContainer(containerId);
26
+ const log = makeContainerLogger(containerName);
16
27
  try {
17
28
  const data = await container.inspect();
18
29
  if (data.State.Running) {
19
- console.log(`[Cleanup] Stopping container: ${containerName}`);
30
+ log.info('Stopping container');
20
31
  try {
21
32
  await container.kill({ signal: 'SIGTERM' });
22
33
  await new Promise((resolve) => {
@@ -46,7 +57,7 @@ const safeRemoveContainer = async (docker, containerId, containerName) => {
46
57
  setTimeout(checkStopped, 100);
47
58
  };
48
59
  const timeout = setTimeout(() => {
49
- console.warn(`[Cleanup] Container ${containerName} did not stop gracefully, forcing`);
60
+ log.warn('Container did not stop gracefully, forcing');
50
61
  container.kill({ signal: 'SIGKILL' }).catch(() => { });
51
62
  safeResolve();
52
63
  }, 5000);
@@ -55,33 +66,34 @@ const safeRemoveContainer = async (docker, containerId, containerName) => {
55
66
  }
56
67
  catch (e) {
57
68
  if (!e.message?.includes('is not running')) {
58
- console.warn(`[Cleanup] Error stopping ${containerName}: ${e.message}`);
69
+ log.warn(`Error stopping: ${e.message}`);
59
70
  }
60
71
  }
61
72
  }
62
73
  await container.remove({ force: true });
63
- console.log(`[Cleanup] Container removed: ${containerName}`);
74
+ log.info('Container removed');
64
75
  }
65
76
  catch (e) {
66
77
  if (e.statusCode === 404) {
67
- console.log(`[Cleanup] Container ${containerName} already removed`);
78
+ log.debug('Container already removed');
68
79
  }
69
80
  else {
70
- console.error(`[Cleanup] Error removing container ${containerName}:`, e.message);
81
+ log.error(`Error removing container: ${e.message}`);
71
82
  }
72
83
  }
73
84
  };
74
85
  export const Container = (resourceId, config, docker) => Effect.acquireRelease(Effect.tryPromise({
75
86
  try: async () => {
87
+ const log = makeContainerLogger(resourceId);
76
88
  try {
77
89
  const existingContainer = docker.getContainer(resourceId);
78
90
  await existingContainer.inspect();
79
- console.log(`[Container] Found existing container ${resourceId}, removing...`);
91
+ log.info('Found existing container, removing...');
80
92
  await safeRemoveContainer(docker, resourceId, resourceId);
81
93
  }
82
94
  catch (e) {
83
95
  if (e.statusCode !== 404) {
84
- console.warn(`[Container] Warning checking for existing container: ${e.message}`);
96
+ log.warn(`Warning checking for existing container: ${e.message}`);
85
97
  }
86
98
  }
87
99
  if (config.ports?.length) {
@@ -95,12 +107,12 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
95
107
  if (!usesConflictingPort)
96
108
  continue;
97
109
  const name = c.Names?.[0]?.replace(/^\//, '') ?? c.Id.substring(0, 12);
98
- console.log(`[Container] Found container "${name}" using port(s) ${externalPorts.join(', ')}, removing...`);
110
+ log.info(`Found container "${name}" using port(s) ${externalPorts.join(', ')}, removing...`);
99
111
  await safeRemoveContainer(docker, c.Id, name);
100
112
  }
101
113
  }
102
114
  catch (e) {
103
- console.warn(`[Container] Warning checking for port conflicts: ${e.message}`);
115
+ log.warn(`Warning checking for port conflicts: ${e.message}`);
104
116
  }
105
117
  }
106
118
  const PortBindings = {};
@@ -113,7 +125,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
113
125
  try {
114
126
  const image = docker.getImage(config.image);
115
127
  await image.inspect();
116
- console.log(`[Container] Verified image exists: ${config.image}`);
128
+ log.debug(`Verified image exists: ${config.image}`);
117
129
  }
118
130
  catch (e) {
119
131
  if (e.statusCode === 404) {
@@ -127,9 +139,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
127
139
  throw new Error(`Failed to verify image "${config.image}": ${e.message}`);
128
140
  }
129
141
  // Merge env vars from bindings
130
- const bindingsEnv = config.bindings
131
- ? mergeBindingsEnv(config.bindings)
132
- : {};
142
+ const bindingsEnv = config.bindings ? mergeBindingsEnv(config.bindings) : {};
133
143
  const finalEnv = {
134
144
  ...bindingsEnv,
135
145
  ...config.env, // User env takes precedence
@@ -142,7 +152,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
142
152
  NetworkMode: config.networks?.[0]?.name,
143
153
  AutoRemove: false,
144
154
  PortBindings,
145
- Binds: config.volumes?.map(v => `${v.hostPath}:${v.containerPath}${v.readonly ? ':ro' : ''}`),
155
+ Binds: config.volumes?.map((v) => `${v.hostPath}:${v.containerPath}${v.readonly ? ':ro' : ''}`),
146
156
  CapAdd: config.capAdd,
147
157
  CapDrop: config.capDrop,
148
158
  Privileged: config.privileged,
@@ -234,8 +244,7 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
234
244
  getIp: (networkName) => Effect.tryPromise({
235
245
  try: async () => {
236
246
  const data = await container.inspect();
237
- return (data.NetworkSettings.Networks[networkName]?.IPAddress ??
238
- 'localhost');
247
+ return data.NetworkSettings.Networks[networkName]?.IPAddress ?? 'localhost';
239
248
  },
240
249
  catch: (e) => new Error(`getIp failed: ${e}`),
241
250
  }),
@@ -249,12 +258,9 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
249
258
  // Calculate CPU percentage
250
259
  const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
251
260
  statsData.precpu_stats.cpu_usage.total_usage;
252
- const systemDelta = statsData.cpu_stats.system_cpu_usage -
253
- statsData.precpu_stats.system_cpu_usage;
261
+ const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
254
262
  const cpuCores = statsData.cpu_stats.online_cpus || 1;
255
- const cpuPercent = systemDelta > 0
256
- ? (cpuDelta / systemDelta) * cpuCores * 100
257
- : 0;
263
+ const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
258
264
  // Calculate memory stats
259
265
  const memUsage = statsData.memory_stats?.usage ?? 0;
260
266
  const memLimit = statsData.memory_stats?.limit ?? 0;
@@ -299,6 +305,67 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
299
305
  },
300
306
  catch: (e) => new Error(`stats failed: ${e}`),
301
307
  }),
308
+ /**
309
+ * Stream container stats as an Effect Stream.
310
+ * Polls every 1 second using Stream.repeatEffectWithSchedule.
311
+ */
312
+ statsStream: () => {
313
+ const getStats = Effect.tryPromise({
314
+ try: async () => {
315
+ const statsData = await container.stats({ stream: false });
316
+ // Calculate CPU percentage
317
+ const cpuDelta = statsData.cpu_stats.cpu_usage.total_usage -
318
+ statsData.precpu_stats.cpu_usage.total_usage;
319
+ const systemDelta = statsData.cpu_stats.system_cpu_usage - statsData.precpu_stats.system_cpu_usage;
320
+ const cpuCores = statsData.cpu_stats.online_cpus || 1;
321
+ const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * cpuCores * 100 : 0;
322
+ // Calculate memory stats
323
+ const memUsage = statsData.memory_stats?.usage ?? 0;
324
+ const memLimit = statsData.memory_stats?.limit ?? 0;
325
+ const memPercent = memLimit > 0 ? (memUsage / memLimit) * 100 : 0;
326
+ // Calculate network stats (sum all interfaces)
327
+ const networks = statsData.networks ?? {};
328
+ let rxBytes = 0;
329
+ let txBytes = 0;
330
+ let rxPackets = 0;
331
+ let txPackets = 0;
332
+ for (const iface of Object.values(networks)) {
333
+ rxBytes += iface.rx_bytes ?? 0;
334
+ txBytes += iface.tx_bytes ?? 0;
335
+ rxPackets += iface.rx_packets ?? 0;
336
+ txPackets += iface.tx_packets ?? 0;
337
+ }
338
+ const result = {
339
+ cpu: {
340
+ usage: statsData.cpu_stats.cpu_usage.total_usage,
341
+ system: statsData.cpu_stats.system_cpu_usage,
342
+ percent: Math.round(cpuPercent * 100) / 100,
343
+ cores: cpuCores,
344
+ },
345
+ memory: {
346
+ usage: memUsage,
347
+ limit: memLimit,
348
+ percent: Math.round(memPercent * 100) / 100,
349
+ usageMB: Math.round((memUsage / 1024 / 1024) * 100) / 100,
350
+ limitMB: Math.round((memLimit / 1024 / 1024) * 100) / 100,
351
+ },
352
+ network: {
353
+ rxBytes,
354
+ txBytes,
355
+ rxPackets,
356
+ txPackets,
357
+ },
358
+ timestamp: new Date().toISOString(),
359
+ containerId: container.id,
360
+ };
361
+ return containerStatsSchema.parse(result);
362
+ },
363
+ catch: (e) => toHarpoonError(new Error(`statsStream failed: ${e}`), resourceId, ContainerError),
364
+ });
365
+ // Poll every 1 second
366
+ const schedule = Schedule.spaced('1 seconds');
367
+ return Stream.repeatEffectWithSchedule(getStats, schedule);
368
+ },
302
369
  /**
303
370
  * Execute a command inside the container.
304
371
  * Returns stdout, stderr, and exit code.
@@ -401,7 +468,10 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
401
468
  const size = buffer.readUInt32BE(offset + 4);
402
469
  if (offset + 8 + size > buffer.length) {
403
470
  // Incomplete payload
404
- const remaining = buffer.slice(offset + 8).toString('utf-8').trim();
471
+ const remaining = buffer
472
+ .slice(offset + 8)
473
+ .toString('utf-8')
474
+ .trim();
405
475
  if (remaining) {
406
476
  yield {
407
477
  stream: streamType === 2 ? 'stderr' : 'stdout',
@@ -472,6 +542,124 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
472
542
  },
473
543
  catch: (e) => new Error(`streamLogs failed: ${e}`),
474
544
  }),
545
+ /**
546
+ * Stream container logs as an Effect Stream.
547
+ * Internal API for Effect-based consumers.
548
+ * Uses Stream.asyncScoped for follow mode to ensure proper cleanup.
549
+ */
550
+ logsStream: (options) => {
551
+ const opts = logOptionsSchema.parse(options ?? {});
552
+ const stdout = opts.stdout ?? true;
553
+ const stderr = opts.stderr ?? true;
554
+ const timestamps = opts.timestamps ?? false;
555
+ const follow = opts.follow ?? false;
556
+ // Helper to parse Docker log format from a buffer
557
+ const parseLogBuffer = (buffer) => {
558
+ const lines = [];
559
+ let bufferOffset = 0;
560
+ while (bufferOffset < buffer.length) {
561
+ if (bufferOffset + 8 > buffer.length) {
562
+ const remaining = buffer.slice(bufferOffset).toString('utf-8').trim();
563
+ if (remaining) {
564
+ lines.push({ stream: 'stdout', message: remaining });
565
+ }
566
+ break;
567
+ }
568
+ const streamType = buffer.readUInt8(bufferOffset);
569
+ const size = buffer.readUInt32BE(bufferOffset + 4);
570
+ if (bufferOffset + 8 + size > buffer.length) {
571
+ const remaining = buffer
572
+ .slice(bufferOffset + 8)
573
+ .toString('utf-8')
574
+ .trim();
575
+ if (remaining) {
576
+ lines.push({
577
+ stream: streamType === 2 ? 'stderr' : 'stdout',
578
+ message: remaining,
579
+ });
580
+ }
581
+ break;
582
+ }
583
+ const payload = buffer.slice(bufferOffset + 8, bufferOffset + 8 + size);
584
+ const message = payload.toString('utf-8');
585
+ let timestamp;
586
+ let content = message;
587
+ if (timestamps) {
588
+ const match = message.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/s);
589
+ if (match) {
590
+ timestamp = match[1];
591
+ content = match[2] ?? '';
592
+ }
593
+ }
594
+ lines.push({
595
+ stream: streamType === 2 ? 'stderr' : 'stdout',
596
+ timestamp,
597
+ message: content.trim(),
598
+ });
599
+ bufferOffset += 8 + size;
600
+ }
601
+ return lines;
602
+ };
603
+ if (follow) {
604
+ // Follow mode: use Stream.asyncScoped for proper resource cleanup
605
+ return Stream.asyncScoped((emit) => Effect.gen(function* () {
606
+ const readable = yield* Effect.tryPromise({
607
+ try: () => container.logs({
608
+ follow: true,
609
+ stdout,
610
+ stderr,
611
+ since: opts.since,
612
+ until: opts.until,
613
+ tail: opts.tail,
614
+ timestamps,
615
+ }),
616
+ catch: (e) => toHarpoonError(new Error(`logsStream failed: ${e}`), resourceId, ContainerError),
617
+ });
618
+ // Set up event handlers
619
+ const onData = (chunk) => {
620
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
621
+ const lines = parseLogBuffer(buffer);
622
+ for (const line of lines) {
623
+ emit.single(line);
624
+ }
625
+ };
626
+ const onError = (err) => {
627
+ emit.fail(toHarpoonError(err, resourceId, ContainerError));
628
+ };
629
+ const onEnd = () => {
630
+ emit.end();
631
+ };
632
+ readable.on('data', onData);
633
+ readable.on('error', onError);
634
+ readable.on('end', onEnd);
635
+ // Register cleanup on scope close
636
+ yield* Effect.addFinalizer(() => Effect.sync(() => {
637
+ readable.removeListener('data', onData);
638
+ readable.removeListener('error', onError);
639
+ readable.removeListener('end', onEnd);
640
+ if (readable.destroy) {
641
+ readable.destroy();
642
+ }
643
+ }));
644
+ }));
645
+ }
646
+ // Non-follow mode: emit all lines from buffer
647
+ return Stream.fromEffect(Effect.tryPromise({
648
+ try: async () => {
649
+ const buffer = await container.logs({
650
+ follow: false,
651
+ stdout,
652
+ stderr,
653
+ since: opts.since,
654
+ until: opts.until,
655
+ tail: opts.tail,
656
+ timestamps,
657
+ });
658
+ return parseLogBuffer(buffer);
659
+ },
660
+ catch: (e) => toHarpoonError(new Error(`logsStream failed: ${e}`), resourceId, ContainerError),
661
+ })).pipe(Stream.flatMap((lines) => Stream.fromIterable(lines)));
662
+ },
475
663
  };
476
664
  // Call onStart hooks for bindings
477
665
  // This is done in Effect context for proper error handling
@@ -479,19 +667,20 @@ export const Container = (resourceId, config, docker) => Effect.acquireRelease(E
479
667
  },
480
668
  catch: (e) => new Error(`Container "${resourceId}" creation failed: ${e}`),
481
669
  }).pipe(Effect.mapError((e) => toHarpoonError(e, resourceId, ContainerError)),
482
- // Run onStart hooks after container creation
670
+ // Run onStart hooks after container creation (in parallel for performance)
483
671
  Effect.tap((resource) => Effect.gen(function* () {
484
672
  if (config.bindings) {
485
- for (const binding of Object.values(config.bindings)) {
486
- if (binding.onStart) {
487
- yield* binding.onStart(resource);
488
- }
673
+ const onStartHooks = Object.values(config.bindings)
674
+ .filter((binding) => binding.onStart !== undefined)
675
+ .map((binding) => binding.onStart(resource));
676
+ if (onStartHooks.length > 0) {
677
+ yield* Effect.all(onStartHooks, { concurrency: 4 });
489
678
  }
490
679
  }
491
680
  }))), (resource, exit) => Effect.gen(function* () {
492
681
  const isInterrupted = Exit.isInterrupted(exit);
493
682
  if (isInterrupted) {
494
- console.log(`[Cleanup] Interrupted - force cleaning container: ${resource.name}`);
683
+ yield* Effect.logDebug('Interrupted - force cleaning container').pipe(Effect.annotateLogs({ service: 'Container', containerId: resource.id }));
495
684
  }
496
685
  yield* Effect.tryPromise({
497
686
  try: () => Promise.race([
@@ -499,7 +688,9 @@ Effect.tap((resource) => Effect.gen(function* () {
499
688
  new Promise((_, reject) => setTimeout(() => reject(new Error('Cleanup timeout')), CLEANUP_TIMEOUT_MS)),
500
689
  ]),
501
690
  catch: (e) => {
502
- console.error(`[Cleanup] Failed to clean up container ${resource.name}:`, e);
691
+ // Log error but don't fail - cleanup should be best effort
692
+ const log = makeContainerLogger(resource.name);
693
+ log.error(`Failed to clean up container`, { error: e });
503
694
  return null;
504
695
  },
505
696
  });
@@ -1 +1 @@
1
- {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../../src/resources/image.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAKhC,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kDAAkD;IAClD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uCAAuC;IACvC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,kFA4HmD,CAAC"}
1
+ {"version":3,"file":"image.d.ts","sourceRoot":"","sources":["../../src/resources/image.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAMhC,MAAM,WAAW,WAAW;IAC1B,0CAA0C;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,6BAA6B;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,kDAAkD;IAClD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uCAAuC;IACvC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,mDAAmD;IACnD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED;;;;;;GAMG;AACH,eAAO,MAAM,KAAK,kFAgGmD,CAAC"}
@@ -8,6 +8,7 @@ import { Effect } from 'effect';
8
8
  import * as fs from 'fs/promises';
9
9
  import { toHarpoonError, ImageError } from '../errors';
10
10
  import { getBuildStrategy } from '../build-strategies';
11
+ import { execCommand } from '../utils/process';
11
12
  /**
12
13
  * Build a Docker image using the configured strategy.
13
14
  *
@@ -45,34 +46,18 @@ export const Image = (tag, config) => Effect.gen(function* () {
45
46
  }
46
47
  }
47
48
  args.push(buildContext.contextPath);
48
- const proc = Bun.spawn(args, {
49
- stdout: 'ignore',
50
- stderr: 'pipe',
51
- });
52
- const stderrOutput = [];
53
- const errorReader = async () => {
54
- if (!proc.stderr)
55
- return;
56
- const reader = proc.stderr.getReader();
57
- const decoder = new TextDecoder();
58
- try {
59
- while (true) {
60
- const { done, value } = await reader.read();
61
- if (done)
62
- break;
63
- const output = decoder.decode(value);
64
- stderrOutput.push(output);
65
- if (output.toLowerCase().includes('error')) {
66
- console.error(`[${tag}] ${output.trim()}`);
67
- }
49
+ // Execute docker build using cross-runtime utility
50
+ const buildResult = await execCommand('docker', args.slice(1), { captureStderr: true });
51
+ // Log errors from stderr if present
52
+ if (buildResult.stderr) {
53
+ for (const line of buildResult.stderr.split('\n')) {
54
+ if (line.toLowerCase().includes('error')) {
55
+ console.error(`[${tag}] ${line.trim()}`);
68
56
  }
69
57
  }
70
- catch (e) {
71
- console.error(`[${tag}] Error reading stderr:`, e);
72
- }
73
- };
74
- await errorReader();
75
- const exitCode = await proc.exited;
58
+ }
59
+ const exitCode = buildResult.exitCode;
60
+ const stderrOutput = buildResult.stderr ? [buildResult.stderr] : [];
76
61
  // Cleanup temp directory if needed
77
62
  if (buildContext.requiresCleanup && buildContext.tempDir) {
78
63
  await fs.rm(buildContext.tempDir, { recursive: true, force: true });
@@ -84,16 +69,10 @@ export const Image = (tag, config) => Effect.gen(function* () {
84
69
  // Verify image was created
85
70
  let verified = false;
86
71
  for (let attempt = 0; attempt < 5; attempt++) {
87
- const verifyProc = Bun.spawn(['docker', 'images', '-q', tag], {
88
- stdout: 'pipe',
89
- stderr: 'pipe',
72
+ const verifyResult = await execCommand('docker', ['images', '-q', tag], {
73
+ captureStdout: true,
90
74
  });
91
- let verifyOutput = '';
92
- if (verifyProc.stdout) {
93
- verifyOutput = await Bun.readableStreamToText(verifyProc.stdout);
94
- }
95
- const verifyExitCode = await verifyProc.exited;
96
- if (verifyExitCode === 0 && verifyOutput.trim()) {
75
+ if (verifyResult.exitCode === 0 && verifyResult.stdout.trim()) {
97
76
  verified = true;
98
77
  break;
99
78
  }
@@ -1 +1 @@
1
- {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/resources/network.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAQ,MAAM,QAAQ,CAAC;AAC7C,OAAO,MAAM,MAAM,WAAW,CAAC;AAG/B,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAiCD;;GAEG;AACH,eAAO,MAAM,OAAO,mHAmFjB,CAAC"}
1
+ {"version":3,"file":"network.d.ts","sourceRoot":"","sources":["../../src/resources/network.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,MAAM,EAAE,KAAK,EAAQ,MAAM,QAAQ,CAAC;AAC7C,OAAO,MAAM,MAAM,WAAW,CAAC;AAG/B,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAmDD;;GAEG;AACH,eAAO,MAAM,OAAO,mHA2EjB,CAAC"}
@@ -8,24 +8,35 @@ import { Effect, Exit } from 'effect';
8
8
  import { toHarpoonError, NetworkError } from '../errors';
9
9
  // Constants
10
10
  const CLEANUP_TIMEOUT_MS = 5_000; // 5 seconds max for network cleanup
11
+ const makeNetworkLogger = (networkName) => ({
12
+ debug: (message) => {
13
+ if (process.env.HARPOON_LOG_LEVEL === 'debug') {
14
+ console.log(`[Network:${networkName}] ${message}`);
15
+ }
16
+ },
17
+ info: (message) => console.log(`[Network:${networkName}] ${message}`),
18
+ warn: (message) => console.warn(`[Network:${networkName}] ${message}`),
19
+ error: (message) => console.error(`[Network:${networkName}] ${message}`),
20
+ });
11
21
  /**
12
22
  * Safely removes a network, handling all possible states.
13
23
  */
14
24
  const safeRemoveNetwork = async (docker, networkId, networkName) => {
25
+ const log = makeNetworkLogger(networkName);
15
26
  try {
16
27
  const network = docker.getNetwork(networkId);
17
28
  await network.remove();
18
- console.log(`[Cleanup] Network removed: ${networkName}`);
29
+ log.info('Network removed');
19
30
  }
20
31
  catch (e) {
21
32
  if (e.statusCode === 404) {
22
- console.log(`[Cleanup] Network ${networkName} already removed`);
33
+ log.debug('Network already removed');
23
34
  }
24
35
  else if (e.message?.includes('has active endpoints')) {
25
- console.warn(`[Cleanup] Network ${networkName} has active endpoints, skipping removal`);
36
+ log.warn('Network has active endpoints, skipping removal');
26
37
  }
27
38
  else {
28
- console.error(`[Cleanup] Error removing network ${networkName}:`, e.message);
39
+ log.error(`Error removing network: ${e.message}`);
29
40
  }
30
41
  }
31
42
  };
@@ -41,21 +52,22 @@ Effect.tryPromise({
41
52
  filters: { name: [config.name] },
42
53
  });
43
54
  const existing = networks.find((n) => n.Name === config.name);
55
+ const log = makeNetworkLogger(config.name);
44
56
  if (existing) {
45
57
  // Try to remove existing network for clean state
46
58
  try {
47
59
  const net = docker.getNetwork(existing.Id);
48
60
  await net.remove();
49
- console.log(`[Network] Removed existing network: ${config.name}`);
61
+ log.info('Removed existing network');
50
62
  }
51
63
  catch (e) {
52
64
  if (e.message?.includes('has active endpoints')) {
53
65
  // Network in use - try to reuse it
54
- console.warn(`[Network] ${config.name} in use, reusing existing network`);
66
+ log.warn('Network in use, reusing existing network');
55
67
  return { id: existing.Id, name: config.name };
56
68
  }
57
69
  // Other errors - try to proceed with creation anyway
58
- console.warn(`[Network] Could not remove ${config.name}: ${e.message}`);
70
+ log.warn(`Could not remove: ${e.message}`);
59
71
  }
60
72
  }
61
73
  // Create new network
@@ -71,7 +83,7 @@ Effect.tryPromise({
71
83
  (resource, exit) => Effect.gen(function* () {
72
84
  const isInterrupted = Exit.isInterrupted(exit);
73
85
  if (isInterrupted) {
74
- console.log(`[Cleanup] Interrupted - cleaning up network: ${resource.name}`);
86
+ yield* Effect.logDebug('Interrupted - cleaning up network').pipe(Effect.annotateLogs({ service: 'Network', networkId: resource.id }));
75
87
  }
76
88
  yield* Effect.tryPromise({
77
89
  try: () => Promise.race([
@@ -79,7 +91,9 @@ Effect.tryPromise({
79
91
  new Promise((_, reject) => setTimeout(() => reject(new Error('Network cleanup timeout')), CLEANUP_TIMEOUT_MS)),
80
92
  ]),
81
93
  catch: (e) => {
82
- console.error(`[Cleanup] Failed to clean up network ${resource.name}:`, e);
94
+ // Log error but don't fail - cleanup should be best effort
95
+ const log = makeNetworkLogger(resource.name);
96
+ log.error(`Failed to clean up network: ${e}`);
83
97
  return null;
84
98
  },
85
99
  });