buncargo 1.0.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.
package/core/docker.ts ADDED
@@ -0,0 +1,384 @@
1
+ import { execSync } from "node:child_process";
2
+ import type {
3
+ BuiltInHealthCheck,
4
+ HealthCheckFn,
5
+ ServiceConfig,
6
+ } from "../types";
7
+ import { sleep } from "./utils";
8
+
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+ // Constants
11
+ // ═══════════════════════════════════════════════════════════════════════════
12
+
13
+ export const POLL_INTERVAL = 250; // Fast polling for quicker startup
14
+ export const MAX_ATTEMPTS = 120; // 30 seconds total (120 * 250ms)
15
+
16
+ // ═══════════════════════════════════════════════════════════════════════════
17
+ // Container Status Checks
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+
20
+ /**
21
+ * Check if a specific container service is running using docker ps.
22
+ */
23
+ export async function isContainerRunning(
24
+ project: string,
25
+ service: string,
26
+ ): Promise<boolean> {
27
+ try {
28
+ const result = execSync(
29
+ `docker ps --filter "label=com.docker.compose.project=${project}" --filter "label=com.docker.compose.service=${service}" --format "{{.State}}"`,
30
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
31
+ );
32
+ return result.trim() === "running";
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Check if all expected containers are running.
40
+ */
41
+ export async function areContainersRunning(
42
+ project: string,
43
+ minCount = 1,
44
+ ): Promise<boolean> {
45
+ try {
46
+ const result = execSync(
47
+ `docker ps --filter "label=com.docker.compose.project=${project}" --format "{{.State}}"`,
48
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] },
49
+ );
50
+ const states = result.trim().split("\n").filter(Boolean);
51
+ if (states.length < minCount) return false;
52
+ return states.every((state) => state === "running");
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ // ═══════════════════════════════════════════════════════════════════════════
59
+ // Container Lifecycle
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+
62
+ export interface StartContainersOptions {
63
+ verbose?: boolean;
64
+ wait?: boolean;
65
+ composeFile?: string;
66
+ }
67
+
68
+ /**
69
+ * Start Docker Compose containers.
70
+ */
71
+ export function startContainers(
72
+ root: string,
73
+ projectName: string,
74
+ envVars: Record<string, string>,
75
+ options: StartContainersOptions = {},
76
+ ): void {
77
+ const { verbose = true, wait = true, composeFile } = options;
78
+
79
+ if (verbose) console.log("🐳 Starting Docker containers...");
80
+
81
+ const composeArg = composeFile ? `-f ${composeFile}` : "";
82
+ const waitFlag = wait ? "--wait" : "";
83
+ const cmd = `docker compose ${composeArg} up -d ${waitFlag}`.trim();
84
+
85
+ execSync(cmd, {
86
+ cwd: root,
87
+ env: { ...process.env, ...envVars, COMPOSE_PROJECT_NAME: projectName },
88
+ stdio: verbose ? "inherit" : "ignore",
89
+ });
90
+
91
+ if (verbose) console.log("✓ Containers started");
92
+ }
93
+
94
+ export interface StopContainersOptions {
95
+ verbose?: boolean;
96
+ removeVolumes?: boolean;
97
+ composeFile?: string;
98
+ }
99
+
100
+ /**
101
+ * Stop Docker Compose containers.
102
+ */
103
+ export function stopContainers(
104
+ root: string,
105
+ projectName: string,
106
+ options: StopContainersOptions = {},
107
+ ): void {
108
+ const { verbose = true, removeVolumes = false, composeFile } = options;
109
+
110
+ if (verbose) {
111
+ console.log(
112
+ removeVolumes
113
+ ? "🗑️ Stopping containers and removing volumes..."
114
+ : "🛑 Stopping containers...",
115
+ );
116
+ }
117
+
118
+ const composeArg = composeFile ? `-f ${composeFile}` : "";
119
+ const volumeFlag = removeVolumes ? "-v" : "";
120
+ const cmd = `docker compose ${composeArg} down ${volumeFlag}`.trim();
121
+
122
+ execSync(cmd, {
123
+ cwd: root,
124
+ env: { ...process.env, COMPOSE_PROJECT_NAME: projectName },
125
+ stdio: verbose ? "inherit" : "ignore",
126
+ });
127
+
128
+ if (verbose) console.log("✓ Containers stopped");
129
+ }
130
+
131
+ /**
132
+ * Start a specific service only.
133
+ */
134
+ export function startService(
135
+ root: string,
136
+ projectName: string,
137
+ serviceName: string,
138
+ envVars: Record<string, string>,
139
+ options: { verbose?: boolean; composeFile?: string } = {},
140
+ ): void {
141
+ const { verbose = true, composeFile } = options;
142
+
143
+ if (verbose) console.log(`🐳 Starting ${serviceName}...`);
144
+
145
+ const composeArg = composeFile ? `-f ${composeFile}` : "";
146
+ const cmd = `docker compose ${composeArg} up -d ${serviceName}`.trim();
147
+
148
+ execSync(cmd, {
149
+ cwd: root,
150
+ env: { ...process.env, ...envVars, COMPOSE_PROJECT_NAME: projectName },
151
+ stdio: verbose ? "inherit" : "ignore",
152
+ });
153
+ }
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+ // Built-in Health Checks
157
+ // ═══════════════════════════════════════════════════════════════════════════
158
+
159
+ export interface HealthCheckContext {
160
+ projectName?: string;
161
+ root?: string;
162
+ }
163
+
164
+ /**
165
+ * Create a health check function from a built-in type.
166
+ */
167
+ export function createBuiltInHealthCheck(
168
+ type: BuiltInHealthCheck,
169
+ serviceName: string,
170
+ context: HealthCheckContext = {},
171
+ ): HealthCheckFn {
172
+ const { projectName, root } = context;
173
+
174
+ switch (type) {
175
+ case "pg_isready":
176
+ return async () => {
177
+ try {
178
+ const projectArg = projectName ? `-p ${projectName}` : "";
179
+ execSync(
180
+ `docker compose ${projectArg} exec -T ${serviceName} pg_isready -U postgres`,
181
+ {
182
+ cwd: root,
183
+ stdio: ["pipe", "pipe", "pipe"],
184
+ },
185
+ );
186
+ return true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ };
191
+
192
+ case "redis-cli":
193
+ return async () => {
194
+ try {
195
+ const projectArg = projectName ? `-p ${projectName}` : "";
196
+ execSync(
197
+ `docker compose ${projectArg} exec -T ${serviceName} redis-cli ping`,
198
+ {
199
+ cwd: root,
200
+ stdio: ["pipe", "pipe", "pipe"],
201
+ },
202
+ );
203
+ return true;
204
+ } catch {
205
+ return false;
206
+ }
207
+ };
208
+
209
+ case "http":
210
+ return async (port) => {
211
+ try {
212
+ const controller = new AbortController();
213
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
214
+ try {
215
+ const response = await fetch(`http://localhost:${port}/`, {
216
+ signal: controller.signal as RequestInit["signal"],
217
+ });
218
+ clearTimeout(timeoutId);
219
+ return response.ok || response.status === 404;
220
+ } catch {
221
+ clearTimeout(timeoutId);
222
+ return false;
223
+ }
224
+ } catch {
225
+ return false;
226
+ }
227
+ };
228
+
229
+ case "tcp":
230
+ return async (port) => {
231
+ // TCP check using a quick fetch that will fail fast if port is closed
232
+ try {
233
+ const controller = new AbortController();
234
+ const timeoutId = setTimeout(() => controller.abort(), 1000);
235
+ try {
236
+ await fetch(`http://localhost:${port}/`, {
237
+ signal: controller.signal as RequestInit["signal"],
238
+ });
239
+ clearTimeout(timeoutId);
240
+ return true;
241
+ } catch (error) {
242
+ clearTimeout(timeoutId);
243
+ // Connection refused means port is not open
244
+ // Other errors (like timeout) might mean it's open but not HTTP
245
+ if (
246
+ error instanceof Error &&
247
+ error.message.includes("ECONNREFUSED")
248
+ ) {
249
+ return false;
250
+ }
251
+ return true; // Assume open for other errors
252
+ }
253
+ } catch {
254
+ return false;
255
+ }
256
+ };
257
+
258
+ default:
259
+ return async () => true;
260
+ }
261
+ }
262
+
263
+ // ═══════════════════════════════════════════════════════════════════════════
264
+ // Service Readiness
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+
267
+ /**
268
+ * Wait for a service to be healthy.
269
+ */
270
+ export async function waitForService(
271
+ serviceName: string,
272
+ config: ServiceConfig,
273
+ port: number,
274
+ options: {
275
+ maxAttempts?: number;
276
+ pollInterval?: number;
277
+ projectName?: string;
278
+ root?: string;
279
+ } = {},
280
+ ): Promise<void> {
281
+ const {
282
+ maxAttempts = MAX_ATTEMPTS,
283
+ pollInterval = POLL_INTERVAL,
284
+ projectName,
285
+ root,
286
+ } = options;
287
+
288
+ // No health check configured - just return
289
+ if (config.healthCheck === false || config.healthCheck === undefined) {
290
+ return;
291
+ }
292
+
293
+ // Get or create health check function
294
+ const healthCheckFn =
295
+ typeof config.healthCheck === "function"
296
+ ? config.healthCheck
297
+ : createBuiltInHealthCheck(
298
+ config.healthCheck,
299
+ config.serviceName ?? serviceName,
300
+ { projectName, root },
301
+ );
302
+
303
+ for (let i = 0; i < maxAttempts; i++) {
304
+ const isHealthy = await healthCheckFn(port);
305
+ if (isHealthy) return;
306
+ await sleep(pollInterval);
307
+ }
308
+
309
+ throw new Error(`Service ${serviceName} did not become ready in time`);
310
+ }
311
+
312
+ /**
313
+ * Wait for all services to be healthy.
314
+ */
315
+ export async function waitForAllServices(
316
+ services: Record<string, ServiceConfig>,
317
+ ports: Record<string, number>,
318
+ options: {
319
+ maxAttempts?: number;
320
+ pollInterval?: number;
321
+ verbose?: boolean;
322
+ projectName?: string;
323
+ root?: string;
324
+ } = {},
325
+ ): Promise<void> {
326
+ const { verbose = true, ...waitOptions } = options;
327
+
328
+ if (verbose) console.log("⏳ Waiting for services to be healthy...");
329
+
330
+ const promises = Object.entries(services).map(([name, config]) => {
331
+ const port = ports[name];
332
+ if (port === undefined) {
333
+ console.warn(
334
+ `⚠️ No port found for service ${name}, skipping health check`,
335
+ );
336
+ return Promise.resolve();
337
+ }
338
+ return waitForService(name, config, port, waitOptions);
339
+ });
340
+
341
+ await Promise.all(promises);
342
+
343
+ if (verbose) console.log("✓ All services healthy");
344
+ }
345
+
346
+ /**
347
+ * Wait for a service to be healthy using a built-in health check type.
348
+ * Simpler API when you don't have a ServiceConfig object.
349
+ */
350
+ export async function waitForServiceByType(
351
+ serviceName: string,
352
+ healthCheckType: BuiltInHealthCheck,
353
+ port: number,
354
+ options: {
355
+ maxAttempts?: number;
356
+ pollInterval?: number;
357
+ verbose?: boolean;
358
+ projectName?: string;
359
+ root?: string;
360
+ } = {},
361
+ ): Promise<void> {
362
+ const {
363
+ maxAttempts = MAX_ATTEMPTS,
364
+ pollInterval = POLL_INTERVAL,
365
+ verbose = false,
366
+ projectName,
367
+ root,
368
+ } = options;
369
+ const healthCheckFn = createBuiltInHealthCheck(healthCheckType, serviceName, {
370
+ projectName,
371
+ root,
372
+ });
373
+
374
+ for (let i = 0; i < maxAttempts; i++) {
375
+ const isHealthy = await healthCheckFn(port);
376
+ if (isHealthy) {
377
+ if (verbose) console.log(`✓ ${serviceName} is ready`);
378
+ return;
379
+ }
380
+ await sleep(pollInterval);
381
+ }
382
+
383
+ throw new Error(`Service ${serviceName} did not become ready in time`);
384
+ }
package/core/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // Re-export all core utilities
2
+ export * from "./docker";
3
+ export * from "./network";
4
+ export * from "./ports";
5
+ export * from "./process";
6
+ export * from "./utils";
7
+ export * from "./watchdog";
@@ -0,0 +1,152 @@
1
+ import { networkInterfaces } from "node:os";
2
+ import type { AppConfig } from "../types";
3
+ import { sleep } from "./utils";
4
+
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ // Local IP Detection
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+
9
+ /**
10
+ * Gets the local IP address of the machine for mobile device connectivity.
11
+ * Prefers IPv4 addresses on non-internal interfaces.
12
+ */
13
+ export function getLocalIp(): string {
14
+ const interfaces = networkInterfaces();
15
+
16
+ for (const name of Object.keys(interfaces)) {
17
+ const nets = interfaces[name];
18
+ if (!nets) continue;
19
+
20
+ for (const net of nets) {
21
+ // Skip internal (loopback) addresses
22
+ if (net.family === "IPv4" && !net.internal) {
23
+ return net.address;
24
+ }
25
+ }
26
+ }
27
+
28
+ return "127.0.0.1";
29
+ }
30
+
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ // HTTP Health Checks
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+
35
+ export interface WaitForServerOptions {
36
+ /** Timeout in milliseconds */
37
+ timeout?: number;
38
+ /** Polling interval in milliseconds */
39
+ interval?: number;
40
+ /** Log progress */
41
+ verbose?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Wait for an HTTP server to respond.
46
+ */
47
+ export async function waitForServer(
48
+ url: string,
49
+ options: WaitForServerOptions = {},
50
+ ): Promise<void> {
51
+ const { timeout = 30000, interval = 2000, verbose = false } = options;
52
+
53
+ const start = Date.now();
54
+ let attempts = 0;
55
+
56
+ while (Date.now() - start < timeout) {
57
+ attempts++;
58
+ const controller = new AbortController();
59
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
60
+ try {
61
+ const response = await fetch(url, {
62
+ signal: controller.signal as RequestInit["signal"],
63
+ });
64
+ clearTimeout(timeoutId);
65
+ // Accept 2xx, 3xx, or 404 (server is up, just no route)
66
+ if (response.ok || response.status === 404) {
67
+ if (verbose) {
68
+ console.log(` ✓ ${url} ready after ${attempts} attempts`);
69
+ }
70
+ return;
71
+ }
72
+ } catch {
73
+ clearTimeout(timeoutId);
74
+ // Server not ready yet
75
+ if (verbose && attempts % 5 === 0) {
76
+ console.log(
77
+ ` ⏳ Waiting for ${url}... (${Math.round((Date.now() - start) / 1000)}s)`,
78
+ );
79
+ }
80
+ }
81
+ await sleep(interval);
82
+ }
83
+
84
+ throw new Error(
85
+ `Server at ${url} did not respond within ${timeout}ms after ${attempts} attempts`,
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Wait for all dev servers to be ready.
91
+ */
92
+ export async function waitForDevServers(
93
+ apps: Record<string, AppConfig>,
94
+ ports: Record<string, number>,
95
+ options: {
96
+ timeout?: number;
97
+ verbose?: boolean;
98
+ productionBuild?: boolean;
99
+ } = {},
100
+ ): Promise<void> {
101
+ const { timeout = 60000, verbose = true } = options;
102
+
103
+ if (verbose) console.log("⏳ Waiting for servers to be ready...");
104
+
105
+ const promises: Promise<void>[] = [];
106
+
107
+ for (const [name, config] of Object.entries(apps)) {
108
+ const port = ports[name];
109
+ const healthPath = config.healthEndpoint ?? "/";
110
+ const url = `http://localhost:${port}${healthPath}`;
111
+ const appTimeout = config.healthTimeout ?? timeout;
112
+
113
+ promises.push(waitForServer(url, { timeout: appTimeout, verbose }));
114
+ }
115
+
116
+ await Promise.all(promises);
117
+
118
+ if (verbose) console.log("✓ All servers ready");
119
+ }
120
+
121
+ // ═══════════════════════════════════════════════════════════════════════════
122
+ // Port Availability
123
+ // ═══════════════════════════════════════════════════════════════════════════
124
+
125
+ /**
126
+ * Check if a port is available (not in use).
127
+ */
128
+ export async function isPortAvailable(port: number): Promise<boolean> {
129
+ const controller = new AbortController();
130
+ const timeoutId = setTimeout(() => controller.abort(), 500);
131
+ try {
132
+ const _response = await fetch(`http://localhost:${port}/`, {
133
+ signal: controller.signal as RequestInit["signal"],
134
+ });
135
+ clearTimeout(timeoutId);
136
+ // If we get any response, port is in use
137
+ return false;
138
+ } catch (error) {
139
+ clearTimeout(timeoutId);
140
+ if (error instanceof Error) {
141
+ // Connection refused means port is free
142
+ if (
143
+ error.message.includes("ECONNREFUSED") ||
144
+ error.message.includes("fetch failed")
145
+ ) {
146
+ return true;
147
+ }
148
+ }
149
+ // Timeout or other error - assume port is free
150
+ return true;
151
+ }
152
+ }