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/environment.ts ADDED
@@ -0,0 +1,604 @@
1
+ import pc from "picocolors";
2
+ import { assertValidConfig } from "./config";
3
+ import {
4
+ areContainersRunning,
5
+ startContainers,
6
+ stopContainers,
7
+ waitForAllServices,
8
+ } from "./core/docker";
9
+ import { getLocalIp, waitForDevServers, waitForServer } from "./core/network";
10
+ import {
11
+ calculatePortOffset,
12
+ computePorts,
13
+ computeUrls,
14
+ findMonorepoRoot,
15
+ getProjectName,
16
+ isWorktree,
17
+ } from "./core/ports";
18
+ import {
19
+ buildApps,
20
+ execAsync,
21
+ startDevServers,
22
+ stopProcess as stopProcessFn,
23
+ } from "./core/process";
24
+ import { isCI as isCIEnv, logExpoApiUrl, logFrontendPort } from "./core/utils";
25
+ import {
26
+ spawnWatchdog as spawnWatchdogFn,
27
+ startHeartbeat as startHeartbeatFn,
28
+ stopHeartbeat as stopHeartbeatFn,
29
+ stopWatchdog as stopWatchdogFn,
30
+ } from "./core/watchdog";
31
+ import { createPrismaRunner } from "./prisma";
32
+ import type {
33
+ AppConfig,
34
+ ComputedPorts,
35
+ ComputedUrls,
36
+ DevConfig,
37
+ DevEnvironment,
38
+ DevServerPids,
39
+ ExecOptions,
40
+ HookContext,
41
+ PrismaRunner,
42
+ ServiceConfig,
43
+ StartOptions,
44
+ StopOptions,
45
+ } from "./types";
46
+
47
+ // ═══════════════════════════════════════════════════════════════════════════
48
+ // Console Output Formatting (Vite-inspired)
49
+ // ═══════════════════════════════════════════════════════════════════════════
50
+
51
+ /**
52
+ * Format a URL with colored port number (Vite-style).
53
+ */
54
+ function formatUrl(url: string): string {
55
+ return pc.cyan(
56
+ url.replace(/:(\d+)(\/?)/, (_, port, slash) => `:${pc.bold(port)}${slash}`),
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Format a label with arrow prefix (Vite-style).
62
+ */
63
+ function formatLabel(label: string, value: string, arrow = "➜"): string {
64
+ return ` ${pc.green(arrow)} ${pc.bold(label.padEnd(10))} ${value}`;
65
+ }
66
+
67
+ /**
68
+ * Format a dim label (for secondary info).
69
+ */
70
+ function formatDimLabel(label: string, value: string): string {
71
+ return ` ${pc.dim("•")} ${pc.dim(label.padEnd(10))} ${pc.dim(value)}`;
72
+ }
73
+
74
+ // ═══════════════════════════════════════════════════════════════════════════
75
+ // Environment Factory
76
+ // ═══════════════════════════════════════════════════════════════════════════
77
+
78
+ /**
79
+ * Create a dev environment from a configuration.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * import { defineDevConfig, createDevEnvironment } from 'buncargo'
84
+ *
85
+ * const config = defineDevConfig({
86
+ * projectPrefix: 'myapp',
87
+ * services: { postgres: { port: 5432 } },
88
+ * apps: { api: { port: 3000, devCommand: 'bun run dev' } }
89
+ * })
90
+ *
91
+ * export const dev = createDevEnvironment(config)
92
+ *
93
+ * // Usage
94
+ * await dev.start()
95
+ * ```
96
+ */
97
+ export function createDevEnvironment<
98
+ TServices extends Record<string, ServiceConfig>,
99
+ TApps extends Record<string, AppConfig>,
100
+ >(
101
+ config: DevConfig<TServices, TApps>,
102
+ options: { suffix?: string } = {},
103
+ ): DevEnvironment<TServices, TApps> {
104
+ // Validate config
105
+ assertValidConfig(config);
106
+
107
+ // Compute environment values
108
+ const root = findMonorepoRoot();
109
+ const suffix = options.suffix;
110
+ const worktree = isWorktree(root);
111
+ const portOffset = calculatePortOffset(suffix, root);
112
+ const projectName = getProjectName(config.projectPrefix, suffix, root);
113
+ const localIp = getLocalIp();
114
+
115
+ const services = config.services;
116
+ const apps = (config.apps ?? {}) as TApps;
117
+
118
+ // Compute ports and URLs
119
+ const ports = computePorts(services, apps, portOffset) as ComputedPorts<
120
+ TServices,
121
+ TApps
122
+ >;
123
+ const urls = computeUrls(services, apps, ports, localIp) as ComputedUrls<
124
+ TServices,
125
+ TApps
126
+ >;
127
+
128
+ // Build environment variables
129
+ function buildEnvVars(production = false): Record<string, string> {
130
+ const baseEnv: Record<string, string> = {
131
+ COMPOSE_PROJECT_NAME: projectName,
132
+ NODE_ENV: production ? "production" : "development",
133
+ };
134
+
135
+ // Add port environment variables for docker-compose
136
+ for (const [name, port] of Object.entries(ports)) {
137
+ const envName = `${name.toUpperCase()}_PORT`;
138
+ baseEnv[envName] = String(port);
139
+ }
140
+
141
+ // Add URL environment variables
142
+ for (const [name, url] of Object.entries(urls)) {
143
+ const envName = `${name.toUpperCase()}_URL`;
144
+ baseEnv[envName] = url;
145
+ }
146
+
147
+ // Call user's envVars function if provided
148
+ if (config.envVars) {
149
+ const userEnv = config.envVars(ports, urls, {
150
+ projectName,
151
+ localIp,
152
+ portOffset,
153
+ });
154
+ for (const [key, value] of Object.entries(userEnv)) {
155
+ baseEnv[key] = String(value);
156
+ }
157
+ }
158
+
159
+ return baseEnv;
160
+ }
161
+
162
+ // Memoized hook context (created once, reused)
163
+ let hookContext: HookContext<TServices, TApps> | null = null;
164
+
165
+ function getHookContext(): HookContext<TServices, TApps> {
166
+ if (!hookContext) {
167
+ hookContext = {
168
+ projectName,
169
+ ports,
170
+ urls,
171
+ root,
172
+ isCI: isCIEnv(),
173
+ portOffset,
174
+ localIp,
175
+ exec: async (cmd, opts) => {
176
+ const envVars = buildEnvVars();
177
+ return execAsync(cmd, root, envVars, opts);
178
+ },
179
+ };
180
+ }
181
+ return hookContext;
182
+ }
183
+
184
+ // Execute command helper
185
+ function exec(cmd: string, options?: ExecOptions) {
186
+ const envVars = buildEnvVars();
187
+ return execAsync(cmd, root, envVars, options);
188
+ }
189
+
190
+ // ─────────────────────────────────────────────────────────────────────────
191
+ // Container Management
192
+ // ─────────────────────────────────────────────────────────────────────────
193
+
194
+ async function start(
195
+ startOptions: StartOptions = {},
196
+ ): Promise<DevServerPids | null> {
197
+ const isCI = process.env.CI === "true";
198
+ const {
199
+ verbose = config.options?.verbose ?? true,
200
+ wait = true,
201
+ startServers: shouldStartServers = true,
202
+ productionBuild = isCI,
203
+ } = startOptions;
204
+
205
+ const envVars = buildEnvVars(productionBuild);
206
+
207
+ // Log environment info
208
+ if (verbose) {
209
+ logInfo(productionBuild ? "Production Environment" : "Dev Environment");
210
+ }
211
+
212
+ // Start containers
213
+ const serviceCount = Object.keys(services).length;
214
+ const alreadyRunning = await areContainersRunning(
215
+ projectName,
216
+ serviceCount,
217
+ );
218
+
219
+ if (alreadyRunning) {
220
+ if (verbose) console.log("✓ Containers already running");
221
+ } else {
222
+ startContainers(root, projectName, envVars, {
223
+ verbose,
224
+ wait,
225
+ composeFile: config.options?.composeFile,
226
+ });
227
+ }
228
+
229
+ // Wait for services to be healthy
230
+ if (wait) {
231
+ await waitForAllServices(services, ports, {
232
+ verbose,
233
+ projectName,
234
+ root,
235
+ });
236
+ }
237
+
238
+ // Build migrations list (auto-add prisma if configured)
239
+ const allMigrations = [
240
+ // Auto-add prisma migration if prisma is configured
241
+ ...(config.prisma
242
+ ? [
243
+ {
244
+ name: "prisma",
245
+ command: "bunx prisma migrate deploy",
246
+ cwd: config.prisma.cwd ?? "packages/prisma",
247
+ },
248
+ ]
249
+ : []),
250
+ // Add user-defined migrations
251
+ ...(config.migrations ?? []),
252
+ ];
253
+
254
+ // Run migrations if any
255
+ if (allMigrations.length > 0) {
256
+ if (verbose) console.log("📦 Running migrations...");
257
+
258
+ const migrationResults = await Promise.all(
259
+ allMigrations.map(async (migration) => {
260
+ const result = await exec(migration.command, {
261
+ cwd: migration.cwd,
262
+ throwOnError: false,
263
+ });
264
+ return { name: migration.name, result };
265
+ }),
266
+ );
267
+
268
+ // Check for failures
269
+ for (const { name, result } of migrationResults) {
270
+ if (result.exitCode !== 0) {
271
+ console.error(`❌ Migration "${name}" failed`);
272
+ console.error(result.stderr);
273
+ throw new Error(`Migration "${name}" failed`);
274
+ }
275
+ }
276
+
277
+ if (verbose) console.log("✓ Migrations complete");
278
+ }
279
+
280
+ // Run afterContainersReady hook
281
+ if (config.hooks?.afterContainersReady) {
282
+ await config.hooks.afterContainersReady(getHookContext());
283
+ }
284
+
285
+ // Run seed if configured
286
+ if (config.seed) {
287
+ let shouldSeed = true;
288
+
289
+ // Check if seeding is needed using check function
290
+ if (config.seed.check) {
291
+ // Create checkTable helper function with typed service parameter
292
+ const checkTable = async (
293
+ tableName: string,
294
+ service?: keyof TServices,
295
+ ): Promise<boolean> => {
296
+ const serviceName = (service ?? "postgres") as string;
297
+ const serviceUrl = (urls as Record<string, string>)[serviceName];
298
+ if (!serviceUrl) {
299
+ console.warn(`⚠️ Service "${serviceName}" not found for checkTable`);
300
+ return true; // Default to seeding if service not found
301
+ }
302
+ const checkResult = await exec(
303
+ `psql "${serviceUrl}" -tAc 'SELECT COUNT(*) FROM "${tableName}" LIMIT 1'`,
304
+ { throwOnError: false },
305
+ );
306
+ const count = checkResult.stdout.trim();
307
+ return checkResult.exitCode !== 0 || count === "0" || count === "";
308
+ };
309
+
310
+ // Build seed check context with helpers
311
+ const seedCheckContext = {
312
+ ...getHookContext(),
313
+ checkTable,
314
+ };
315
+
316
+ shouldSeed = await config.seed.check(seedCheckContext);
317
+ }
318
+
319
+ if (shouldSeed) {
320
+ if (verbose) console.log("🌱 Running seeders...");
321
+ const seedResult = await exec(config.seed.command, {
322
+ cwd: config.seed.cwd,
323
+ verbose,
324
+ throwOnError: false,
325
+ });
326
+ if (seedResult.exitCode !== 0) {
327
+ console.error("❌ Seeding failed");
328
+ console.error(seedResult.stderr);
329
+ // Don't throw - seeding failure shouldn't stop the environment
330
+ } else {
331
+ if (verbose) console.log("✓ Seeding complete");
332
+ }
333
+ } else {
334
+ if (verbose)
335
+ console.log("✓ Database already has data, skipping seeders");
336
+ }
337
+ }
338
+
339
+ // Start servers if requested
340
+ if (shouldStartServers && Object.keys(apps).length > 0) {
341
+ // Run beforeServers hook
342
+ if (config.hooks?.beforeServers) {
343
+ await config.hooks.beforeServers(getHookContext());
344
+ }
345
+
346
+ // Build if production
347
+ if (productionBuild) {
348
+ buildApps(apps, root, envVars, { verbose });
349
+ }
350
+
351
+ // Start servers
352
+ const pids = startDevServers(apps, root, envVars, {
353
+ verbose,
354
+ productionBuild,
355
+ isCI,
356
+ });
357
+
358
+ // Wait for servers to be ready
359
+ if (verbose) console.log("⏳ Waiting for servers to be ready...");
360
+ await waitForDevServers(apps, ports, {
361
+ timeout: isCI ? 120000 : 60000,
362
+ verbose,
363
+ productionBuild,
364
+ });
365
+
366
+ // Run afterServers hook
367
+ if (config.hooks?.afterServers) {
368
+ await config.hooks.afterServers(getHookContext());
369
+ }
370
+
371
+ if (verbose) console.log("✅ Environment ready\n");
372
+ return pids;
373
+ }
374
+
375
+ if (verbose) console.log("✅ Containers ready\n");
376
+ return null;
377
+ }
378
+
379
+ async function stop(stopOptions: StopOptions = {}): Promise<void> {
380
+ const { verbose = true, removeVolumes = false } = stopOptions;
381
+
382
+ // Run beforeStop hook
383
+ if (config.hooks?.beforeStop) {
384
+ await config.hooks.beforeStop(getHookContext());
385
+ }
386
+
387
+ stopContainers(root, projectName, {
388
+ verbose,
389
+ removeVolumes,
390
+ composeFile: config.options?.composeFile,
391
+ });
392
+ }
393
+
394
+ async function restart(): Promise<void> {
395
+ await stop();
396
+ await start({ startServers: false });
397
+ }
398
+
399
+ async function isRunning(): Promise<boolean> {
400
+ const serviceCount = Object.keys(services).length;
401
+ return areContainersRunning(projectName, serviceCount);
402
+ }
403
+
404
+ // ─────────────────────────────────────────────────────────────────────────
405
+ // Server Management
406
+ // ─────────────────────────────────────────────────────────────────────────
407
+
408
+ async function startServersOnly(
409
+ options: { productionBuild?: boolean; verbose?: boolean } = {},
410
+ ): Promise<DevServerPids> {
411
+ const { productionBuild = false, verbose = true } = options;
412
+ const envVars = buildEnvVars(productionBuild);
413
+ const isCI = process.env.CI === "true";
414
+
415
+ // Build if production
416
+ if (productionBuild) {
417
+ buildApps(apps, root, envVars, { verbose });
418
+ }
419
+
420
+ return startDevServers(apps, root, envVars, {
421
+ verbose,
422
+ productionBuild,
423
+ isCI,
424
+ });
425
+ }
426
+
427
+ async function waitForServersReady(
428
+ options: { timeout?: number; productionBuild?: boolean } = {},
429
+ ): Promise<void> {
430
+ const { timeout = 60000, productionBuild = false } = options;
431
+ await waitForDevServers(apps, ports, { timeout, productionBuild });
432
+ }
433
+
434
+ // ─────────────────────────────────────────────────────────────────────────
435
+ // Utilities
436
+ // ─────────────────────────────────────────────────────────────────────────
437
+
438
+ function logInfo(label = "Docker Dev"): void {
439
+ const serviceNames = Object.keys(services);
440
+ const appNames = Object.keys(apps);
441
+
442
+ console.log("");
443
+ console.log(` ${pc.cyan(pc.bold(`🐳 ${label}`))}`);
444
+ console.log(formatLabel("Project:", pc.white(projectName)));
445
+
446
+ // Services section (Docker containers)
447
+ if (serviceNames.length > 0) {
448
+ console.log("");
449
+ console.log(` ${pc.dim("─── Services ───")}`);
450
+ for (const name of serviceNames) {
451
+ const port = (ports as Record<string, number>)[name];
452
+ const url = `localhost:${port}`;
453
+ console.log(formatLabel(`${name}:`, formatUrl(`http://${url}`)));
454
+ }
455
+ }
456
+
457
+ // Apps section (Dev servers)
458
+ if (appNames.length > 0) {
459
+ console.log("");
460
+ console.log(` ${pc.dim("─── Applications ───")}`);
461
+ for (const name of appNames) {
462
+ const port = (ports as Record<string, number>)[name];
463
+ const localUrl = `http://localhost:${port}`;
464
+ const networkUrl = `http://${localIp}:${port}`;
465
+
466
+ console.log(` ${pc.green("➜")} ${pc.bold(pc.cyan(name))}`);
467
+ console.log(` ${pc.dim("Local:")} ${formatUrl(localUrl)}`);
468
+ console.log(` ${pc.dim("Network:")} ${formatUrl(networkUrl)}`);
469
+ }
470
+ }
471
+
472
+ // Environment info
473
+ console.log("");
474
+ console.log(` ${pc.dim("─── Environment ───")}`);
475
+ console.log(formatDimLabel("Worktree:", worktree ? "yes" : "no"));
476
+ console.log(
477
+ formatDimLabel(
478
+ "Port offset:",
479
+ portOffset > 0 ? `+${portOffset}` : "none",
480
+ ),
481
+ );
482
+ if (suffix) {
483
+ console.log(formatDimLabel("Suffix:", suffix));
484
+ }
485
+ console.log(formatDimLabel("Local IP:", localIp));
486
+ console.log("");
487
+ }
488
+
489
+ async function waitForServerUrl(
490
+ url: string,
491
+ timeout?: number,
492
+ ): Promise<void> {
493
+ await waitForServer(url, { timeout });
494
+ }
495
+
496
+ // ─────────────────────────────────────────────────────────────────────────
497
+ // Watchdog / Heartbeat
498
+ // ─────────────────────────────────────────────────────────────────────────
499
+
500
+ function startHeartbeat(intervalMs?: number): void {
501
+ startHeartbeatFn(projectName, intervalMs);
502
+ }
503
+
504
+ function stopHeartbeat(): void {
505
+ stopHeartbeatFn();
506
+ }
507
+
508
+ async function spawnWatchdog(timeoutMinutes?: number): Promise<void> {
509
+ await spawnWatchdogFn(projectName, root, {
510
+ timeoutMinutes,
511
+ verbose: true,
512
+ composeFile: config.options?.composeFile,
513
+ });
514
+ }
515
+
516
+ function stopWatchdog(): void {
517
+ stopWatchdogFn(projectName);
518
+ }
519
+
520
+ // ─────────────────────────────────────────────────────────────────────────
521
+ // Vibe Kanban Integration
522
+ // ─────────────────────────────────────────────────────────────────────────
523
+
524
+ function getExpoApiUrl(): string {
525
+ const apiPort = (ports as Record<string, number>).api;
526
+ const url = `http://${localIp}:${apiPort}`;
527
+ logExpoApiUrl(url);
528
+ return url;
529
+ }
530
+
531
+ function getFrontendPort(): number | undefined {
532
+ const port = (ports as Record<string, number>).platform;
533
+ logFrontendPort(port);
534
+ return port;
535
+ }
536
+
537
+ // ─────────────────────────────────────────────────────────────────────────
538
+ // Advanced
539
+ // ─────────────────────────────────────────────────────────────────────────
540
+
541
+ function withSuffix(newSuffix: string): DevEnvironment<TServices, TApps> {
542
+ return createDevEnvironment(config, { suffix: newSuffix });
543
+ }
544
+
545
+ // ─────────────────────────────────────────────────────────────────────────
546
+ // Return Environment Object
547
+ // ─────────────────────────────────────────────────────────────────────────
548
+
549
+ // Build base environment
550
+ const env: DevEnvironment<TServices, TApps> = {
551
+ // Configuration access
552
+ projectName,
553
+ ports,
554
+ urls,
555
+ apps,
556
+ portOffset,
557
+ isWorktree: worktree,
558
+ localIp,
559
+ root,
560
+
561
+ // Container management
562
+ start,
563
+ stop,
564
+ restart,
565
+ isRunning,
566
+
567
+ // Server management
568
+ startServers: startServersOnly,
569
+ stopProcess: stopProcessFn,
570
+ waitForServers: waitForServersReady,
571
+
572
+ // Utilities
573
+ buildEnvVars,
574
+ exec,
575
+ waitForServer: waitForServerUrl,
576
+ logInfo,
577
+
578
+ // Vibe Kanban Integration
579
+ getExpoApiUrl,
580
+ getFrontendPort,
581
+
582
+ // Watchdog / Heartbeat
583
+ startHeartbeat,
584
+ stopHeartbeat,
585
+ spawnWatchdog,
586
+ stopWatchdog,
587
+
588
+ // Prisma (created below if configured)
589
+ prisma: undefined,
590
+
591
+ // Advanced
592
+ withSuffix,
593
+ };
594
+
595
+ // Create prisma runner if configured
596
+ if (config.prisma) {
597
+ (env as { prisma: PrismaRunner }).prisma = createPrismaRunner(
598
+ env,
599
+ config.prisma,
600
+ );
601
+ }
602
+
603
+ return env;
604
+ }
package/index.ts ADDED
@@ -0,0 +1,103 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // Main Exports
3
+ // ═══════════════════════════════════════════════════════════════════════════
4
+
5
+ // CLI runner
6
+ export { getFlagValue, hasFlag, runCli } from "./cli";
7
+ // Config factory
8
+ export {
9
+ assertValidConfig,
10
+ defineDevConfig,
11
+ mergeConfigs,
12
+ validateConfig,
13
+ } from "./config";
14
+ // Environment factory
15
+ export { createDevEnvironment } from "./environment";
16
+ // Lint / Typecheck
17
+ export {
18
+ runWorkspaceTypecheck,
19
+ type TypecheckResult,
20
+ type WorkspaceTypecheckOptions,
21
+ type WorkspaceTypecheckResult,
22
+ } from "./lint";
23
+ // Config loader (for programmatic access)
24
+ export { clearDevEnvCache, getDevEnv, loadDevEnv } from "./loader";
25
+
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+ // Types
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+
30
+ export type {
31
+ AppConfig,
32
+ BuiltInHealthCheck,
33
+ // CLI
34
+ CliOptions,
35
+ // Computed types
36
+ ComputedPorts,
37
+ ComputedUrls,
38
+ // Main config
39
+ DevConfig,
40
+ // Environment interface
41
+ DevEnvironment,
42
+ DevHooks,
43
+ DevOptions,
44
+ DevServerPids,
45
+ EnvVarsBuilder,
46
+ ExecOptions,
47
+ HealthCheckFn,
48
+ HookContext,
49
+ // Migrations & Seed
50
+ MigrationConfig,
51
+ // Prisma
52
+ PrismaConfig,
53
+ PrismaRunner,
54
+ SeedCheckContext,
55
+ SeedCheckHelpers,
56
+ SeedConfig,
57
+ // Service & App configs
58
+ ServiceConfig,
59
+ // Start/Stop options
60
+ StartOptions,
61
+ StopOptions,
62
+ UrlBuilderContext,
63
+ UrlBuilderFn,
64
+ } from "./types";
65
+
66
+ // ═══════════════════════════════════════════════════════════════════════════
67
+ // Core Utilities (for advanced use cases)
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+
70
+ export {
71
+ areContainersRunning,
72
+ isContainerRunning,
73
+ MAX_ATTEMPTS,
74
+ POLL_INTERVAL,
75
+ } from "./core/docker";
76
+
77
+ export { getLocalIp, isPortAvailable, waitForServer } from "./core/network";
78
+ export {
79
+ calculatePortOffset,
80
+ findMonorepoRoot,
81
+ getProjectName,
82
+ getWorktreeName,
83
+ isWorktree,
84
+ } from "./core/ports";
85
+
86
+ export { isProcessAlive } from "./core/process";
87
+ export {
88
+ getEnvVar,
89
+ isCI,
90
+ logApiUrl,
91
+ logExpoApiUrl,
92
+ logFrontendPort,
93
+ sleep,
94
+ } from "./core/utils";
95
+ export {
96
+ getHeartbeatFile,
97
+ getWatchdogPidFile,
98
+ isWatchdogRunning,
99
+ spawnWatchdog,
100
+ startHeartbeat,
101
+ stopHeartbeat,
102
+ stopWatchdog,
103
+ } from "./core/watchdog";