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/bin.ts ADDED
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * CLI Entry Point for buncargo
5
+ *
6
+ * Usage:
7
+ * bunx buncargo dev # Start containers + dev servers
8
+ * bunx buncargo dev --down # Stop containers
9
+ * bunx buncargo dev --reset # Stop + remove volumes
10
+ * bunx buncargo prisma ... # Run prisma commands
11
+ * bunx buncargo help # Show help
12
+ */
13
+
14
+ import { runCli } from "./cli";
15
+ import { createDevEnvironment } from "./environment";
16
+ import type { AppConfig, DevEnvironment, ServiceConfig } from "./types";
17
+
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+ // Config Discovery
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+
22
+ const CONFIG_FILES = [
23
+ "dev.config.ts",
24
+ "dev.config.js",
25
+ "dev-tools.config.ts",
26
+ "dev-tools.config.js",
27
+ ];
28
+
29
+ /**
30
+ * Find and load the dev config file from the current directory.
31
+ * Returns the DevEnvironment created from the config.
32
+ */
33
+ async function loadEnv(): Promise<
34
+ DevEnvironment<Record<string, ServiceConfig>, Record<string, AppConfig>>
35
+ > {
36
+ const cwd = process.cwd();
37
+
38
+ for (const file of CONFIG_FILES) {
39
+ const path = `${cwd}/${file}`;
40
+ const exists = await Bun.file(path).exists();
41
+
42
+ if (exists) {
43
+ try {
44
+ const mod = await import(path);
45
+ const config = mod.default;
46
+
47
+ if (!config) {
48
+ console.error(
49
+ `❌ Config file "${file}" found but no default export.`,
50
+ );
51
+ console.error("");
52
+ console.error(" Export your config as default:");
53
+ console.error("");
54
+ console.error(" import { defineDevConfig } from 'buncargo'");
55
+ console.error("");
56
+ console.error(" export default defineDevConfig({ ... })");
57
+ process.exit(1);
58
+ }
59
+
60
+ // Validate it looks like a config
61
+ if (!config.projectPrefix || !config.services) {
62
+ console.error(`❌ Config file "${file}" is not a valid dev config.`);
63
+ console.error("");
64
+ console.error(" Make sure to use defineDevConfig:");
65
+ console.error("");
66
+ console.error(" export default defineDevConfig({");
67
+ console.error(" projectPrefix: 'myapp',");
68
+ console.error(" services: { ... }");
69
+ console.error(" })");
70
+ process.exit(1);
71
+ }
72
+
73
+ // Create environment from config
74
+ return createDevEnvironment(config);
75
+ } catch (error) {
76
+ console.error(`❌ Failed to load config file "${file}":`);
77
+ console.error(error);
78
+ process.exit(1);
79
+ }
80
+ }
81
+ }
82
+
83
+ console.error("❌ No config file found.");
84
+ console.error("");
85
+ console.error(" Create a dev.config.ts file in your project root:");
86
+ console.error("");
87
+ console.error(" import { defineDevConfig } from 'buncargo'");
88
+ console.error("");
89
+ console.error(" export default defineDevConfig({");
90
+ console.error(" projectPrefix: 'myapp',");
91
+ console.error(" services: {");
92
+ console.error(' postgres: { port: 5432, healthCheck: "pg_isready" }');
93
+ console.error(" }");
94
+ console.error(" })");
95
+ console.error("");
96
+ console.error(" Supported config files:");
97
+ for (const file of CONFIG_FILES) {
98
+ console.error(` - ${file}`);
99
+ }
100
+ process.exit(1);
101
+ }
102
+
103
+ // ═══════════════════════════════════════════════════════════════════════════
104
+ // Command Handlers
105
+ // ═══════════════════════════════════════════════════════════════════════════
106
+
107
+ async function handleDev(args: string[]): Promise<void> {
108
+ const env = await loadEnv();
109
+ await runCli(env, { args });
110
+ }
111
+
112
+ async function handlePrisma(args: string[]): Promise<void> {
113
+ const env = await loadEnv();
114
+
115
+ if (!env.prisma) {
116
+ console.error("❌ Prisma is not configured in your dev config.");
117
+ console.error("");
118
+ console.error(" Add prisma to your config:");
119
+ console.error("");
120
+ console.error(" export default defineDevConfig({");
121
+ console.error(" ...");
122
+ console.error(" prisma: {");
123
+ console.error(" cwd: 'packages/prisma'");
124
+ console.error(" }");
125
+ console.error(" })");
126
+ process.exit(1);
127
+ }
128
+
129
+ // Ensure database is running
130
+ const running = await env.isRunning();
131
+ if (!running) {
132
+ console.log("🐳 Starting database container...");
133
+ await env.start({ startServers: false, wait: true });
134
+ }
135
+
136
+ const exitCode = await env.prisma.run(args);
137
+ process.exit(exitCode);
138
+ }
139
+
140
+ async function handleEnv(): Promise<void> {
141
+ const env = await loadEnv();
142
+ console.log(
143
+ JSON.stringify(
144
+ {
145
+ projectName: env.projectName,
146
+ ports: env.ports,
147
+ urls: env.urls,
148
+ portOffset: env.portOffset,
149
+ isWorktree: env.isWorktree,
150
+ localIp: env.localIp,
151
+ root: env.root,
152
+ },
153
+ null,
154
+ 2,
155
+ ),
156
+ );
157
+ }
158
+
159
+ function showHelp(): void {
160
+ console.log(`
161
+ buncargo - Development environment CLI
162
+
163
+ USAGE:
164
+ bunx buncargo <command> [options]
165
+
166
+ COMMANDS:
167
+ dev Start the development environment
168
+ prisma <args> Run Prisma CLI with correct DATABASE_URL
169
+ env Print environment info as JSON
170
+ help Show this help message
171
+ version Show version
172
+
173
+ DEV OPTIONS:
174
+ --up-only Start containers only (no dev servers)
175
+ --down Stop containers
176
+ --reset Stop containers and remove volumes
177
+ --migrate Run migrations only
178
+ --seed Run seeders
179
+ --lint Run typecheck (no Docker required)
180
+
181
+ EXAMPLES:
182
+ bunx buncargo dev # Start everything
183
+ bunx buncargo dev --down # Stop containers
184
+ bunx buncargo prisma studio # Open Prisma Studio
185
+ bunx buncargo env # Get ports/urls as JSON
186
+
187
+ CONFIG:
188
+ Create a dev.config.ts with a default export:
189
+
190
+ import { defineDevConfig } from 'buncargo'
191
+
192
+ export default defineDevConfig({
193
+ projectPrefix: 'myapp',
194
+ services: { ... },
195
+ apps: { ... }
196
+ })
197
+ `);
198
+ }
199
+
200
+ function showVersion(): void {
201
+ const pkg = require("./package.json");
202
+ console.log(`buncargo v${pkg.version}`);
203
+ }
204
+
205
+ // ═══════════════════════════════════════════════════════════════════════════
206
+ // Main
207
+ // ═══════════════════════════════════════════════════════════════════════════
208
+
209
+ async function main(): Promise<void> {
210
+ const args = process.argv.slice(2);
211
+ const command = args[0];
212
+ const commandArgs = args.slice(1);
213
+
214
+ if (
215
+ !command ||
216
+ command === "help" ||
217
+ command === "--help" ||
218
+ command === "-h"
219
+ ) {
220
+ showHelp();
221
+ process.exit(0);
222
+ }
223
+
224
+ if (command === "version" || command === "--version" || command === "-v") {
225
+ showVersion();
226
+ process.exit(0);
227
+ }
228
+
229
+ switch (command) {
230
+ case "dev":
231
+ await handleDev(commandArgs);
232
+ break;
233
+
234
+ case "prisma":
235
+ await handlePrisma(commandArgs);
236
+ break;
237
+
238
+ case "env":
239
+ await handleEnv();
240
+ break;
241
+
242
+ default:
243
+ console.error(`❌ Unknown command: ${command}`);
244
+ console.error("");
245
+ console.error(' Run "bunx buncargo help" for available commands.');
246
+ process.exit(1);
247
+ }
248
+ }
249
+
250
+ main().catch((error) => {
251
+ console.error("❌ Fatal error:", error);
252
+ process.exit(1);
253
+ });
package/cli.ts ADDED
@@ -0,0 +1,238 @@
1
+ import { spawn } from "node:child_process";
2
+ import { spawnWatchdog, startHeartbeat, stopHeartbeat } from "./core/watchdog";
3
+ import type {
4
+ AppConfig,
5
+ CliOptions,
6
+ DevEnvironment,
7
+ ServiceConfig,
8
+ } from "./types";
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // CLI Runner
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ /**
15
+ * Run the CLI for a dev environment.
16
+ * Handles common flags like --down, --reset, --up-only, --migrate, --seed, --lint.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * import { dev } from './dev.config'
21
+ * import { runCli } from 'buncargo'
22
+ *
23
+ * await runCli(dev)
24
+ * ```
25
+ */
26
+ export async function runCli<
27
+ TServices extends Record<string, ServiceConfig>,
28
+ TApps extends Record<string, AppConfig>,
29
+ >(
30
+ env: DevEnvironment<TServices, TApps>,
31
+ options: CliOptions = {},
32
+ ): Promise<void> {
33
+ const {
34
+ args = process.argv.slice(2),
35
+ watchdog = true,
36
+ watchdogTimeout = 10,
37
+ devServersCommand,
38
+ } = options;
39
+
40
+ // Log environment info
41
+ env.logInfo();
42
+
43
+ // Handle --lint (no Docker required)
44
+ if (args.includes("--lint")) {
45
+ const { runWorkspaceTypecheck } = await import("./lint");
46
+ const result = await runWorkspaceTypecheck({
47
+ root: env.root,
48
+ verbose: true,
49
+ });
50
+ process.exit(result.success ? 0 : 1);
51
+ }
52
+
53
+ // Handle --down
54
+ if (args.includes("--down")) {
55
+ await env.stop();
56
+ process.exit(0);
57
+ }
58
+
59
+ // Handle --reset
60
+ if (args.includes("--reset")) {
61
+ await env.stop({ removeVolumes: true });
62
+ process.exit(0);
63
+ }
64
+
65
+ // Start containers if not already running
66
+ const running = await env.isRunning();
67
+ if (running) {
68
+ console.log("✓ Containers already running");
69
+ } else {
70
+ await env.start({ startServers: false, wait: true });
71
+ }
72
+
73
+ // Handle --migrate (just run hooks, then exit)
74
+ if (args.includes("--migrate")) {
75
+ console.log("");
76
+ console.log("✅ Migrations applied successfully");
77
+ process.exit(0);
78
+ }
79
+
80
+ // Handle --seed (force run seeders via hook context)
81
+ if (args.includes("--seed")) {
82
+ console.log("🌱 Running seeders...");
83
+ const result = await env.exec("bun run run:seeder", {
84
+ throwOnError: false,
85
+ });
86
+ if (result.exitCode !== 0) {
87
+ console.error("❌ Seeding failed");
88
+ process.exit(1);
89
+ }
90
+ console.log("");
91
+ console.log("✅ Seeding complete");
92
+ process.exit(0);
93
+ }
94
+
95
+ // Handle --up-only
96
+ if (args.includes("--up-only")) {
97
+ console.log("");
98
+ console.log("✅ Containers started. Environment ready.");
99
+ console.log("");
100
+ process.exit(0);
101
+ }
102
+
103
+ // Start watchdog and heartbeat for interactive mode
104
+ if (watchdog) {
105
+ await spawnWatchdog(env.projectName, env.root, {
106
+ timeoutMinutes: watchdogTimeout,
107
+ verbose: true,
108
+ });
109
+ startHeartbeat(env.projectName);
110
+ }
111
+
112
+ // Build command: use provided command or auto-build from apps config
113
+ const command = devServersCommand ?? buildDevServersCommand(env.apps);
114
+
115
+ if (!command) {
116
+ console.log("✅ Containers ready. No apps configured.");
117
+ // Keep process alive if no apps
118
+ await new Promise(() => {});
119
+ return;
120
+ }
121
+
122
+ // Start dev servers interactively
123
+ console.log("");
124
+ console.log("🔧 Starting dev servers...");
125
+ console.log("");
126
+
127
+ await runCommand(command, env.root, env.buildEnvVars());
128
+
129
+ // Clean up heartbeat on exit
130
+ stopHeartbeat();
131
+ }
132
+
133
+ // ═══════════════════════════════════════════════════════════════════════════
134
+ // Command Building
135
+ // ═══════════════════════════════════════════════════════════════════════════
136
+
137
+ /**
138
+ * Build a concurrently command from the apps config.
139
+ */
140
+ function buildDevServersCommand(
141
+ apps: Record<string, AppConfig>,
142
+ ): string | null {
143
+ const appEntries = Object.entries(apps);
144
+ if (appEntries.length === 0) return null;
145
+
146
+ // Build commands for each app
147
+ const commands: string[] = [];
148
+ const names: string[] = [];
149
+ const colors = ["blue", "green", "yellow", "magenta", "cyan", "red"];
150
+
151
+ for (const [name, config] of appEntries) {
152
+ names.push(name);
153
+ const cwdPart = config.cwd ? `--cwd ${config.cwd}` : "";
154
+ commands.push(
155
+ `"bun run ${cwdPart} ${config.devCommand}"`.replace(/\s+/g, " ").trim(),
156
+ );
157
+ }
158
+
159
+ // Use concurrently to run all apps
160
+ const namesArg = `-n ${names.join(",")}`;
161
+ const colorsArg = `-c ${colors.slice(0, names.length).join(",")}`;
162
+ const commandsArg = commands.join(" ");
163
+
164
+ return `bun concurrently ${namesArg} ${colorsArg} ${commandsArg}`;
165
+ }
166
+
167
+ // ═══════════════════════════════════════════════════════════════════════════
168
+ // Interactive Command Runner
169
+ // ═══════════════════════════════════════════════════════════════════════════
170
+
171
+ /**
172
+ * Run a command interactively (inherits stdio).
173
+ */
174
+ function runCommand(
175
+ command: string,
176
+ cwd: string,
177
+ envVars: Record<string, string>,
178
+ ): Promise<void> {
179
+ return new Promise((resolve, reject) => {
180
+ const proc = spawn(command, [], {
181
+ cwd,
182
+ env: { ...process.env, ...envVars },
183
+ stdio: "inherit",
184
+ shell: true,
185
+ });
186
+
187
+ proc.on("close", (code) => {
188
+ if (code === 0 || code === null) {
189
+ resolve();
190
+ } else {
191
+ reject(new Error(`Command exited with code ${code}`));
192
+ }
193
+ });
194
+
195
+ proc.on("error", reject);
196
+
197
+ // Handle SIGINT/SIGTERM
198
+ const cleanup = () => {
199
+ proc.kill("SIGTERM");
200
+ };
201
+
202
+ process.on("SIGINT", cleanup);
203
+ process.on("SIGTERM", cleanup);
204
+ });
205
+ }
206
+
207
+ // ═══════════════════════════════════════════════════════════════════════════
208
+ // Utility Functions
209
+ // ═══════════════════════════════════════════════════════════════════════════
210
+
211
+ /**
212
+ * Check if a CLI flag is present.
213
+ */
214
+ export function hasFlag(args: string[], flag: string): boolean {
215
+ return args.includes(flag);
216
+ }
217
+
218
+ /**
219
+ * Get a flag value (e.g., --timeout=10 or --timeout 10).
220
+ */
221
+ export function getFlagValue(args: string[], flag: string): string | undefined {
222
+ // Check --flag=value format
223
+ const prefixed = args.find((arg) => arg.startsWith(`${flag}=`));
224
+ if (prefixed) {
225
+ return prefixed.split("=")[1];
226
+ }
227
+
228
+ // Check --flag value format
229
+ const index = args.indexOf(flag);
230
+ if (index !== -1 && index + 1 < args.length) {
231
+ const nextArg = args[index + 1];
232
+ if (nextArg !== undefined && !nextArg.startsWith("-")) {
233
+ return nextArg;
234
+ }
235
+ }
236
+
237
+ return undefined;
238
+ }
package/config.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type {
2
+ AppConfig,
3
+ DevConfig,
4
+ DevHooks,
5
+ DevOptions,
6
+ EnvVarsBuilder,
7
+ MigrationConfig,
8
+ PrismaConfig,
9
+ SeedConfig,
10
+ ServiceConfig,
11
+ } from "./types";
12
+
13
+ // ═══════════════════════════════════════════════════════════════════════════
14
+ // Config Factory
15
+ // ═══════════════════════════════════════════════════════════════════════════
16
+
17
+ /**
18
+ * Define a dev environment configuration with full TypeScript inference.
19
+ *
20
+ * @example
21
+ * ```typescript
22
+ * const config = defineDevConfig({
23
+ * projectPrefix: 'myapp',
24
+ * services: {
25
+ * postgres: { port: 5432, healthCheck: 'pg_isready' },
26
+ * redis: { port: 6379 },
27
+ * },
28
+ * apps: {
29
+ * api: { port: 3000, devCommand: 'bun run dev', cwd: 'apps/backend' },
30
+ * web: { port: 5173, devCommand: 'bun run dev', cwd: 'apps/frontend' },
31
+ * },
32
+ * envVars: (ports, urls) => ({
33
+ * DATABASE_URL: urls.postgres,
34
+ * REDIS_URL: urls.redis,
35
+ * API_PORT: String(ports.api),
36
+ * }),
37
+ * })
38
+ * ```
39
+ */
40
+ export function defineDevConfig<
41
+ TServices extends Record<string, ServiceConfig>,
42
+ TApps extends Record<string, AppConfig> = Record<string, never>,
43
+ >(config: {
44
+ /** Prefix for Docker project name (e.g., 'myapp' -> 'myapp-main') */
45
+ projectPrefix: string;
46
+ /** Docker Compose services to manage */
47
+ services: TServices;
48
+ /** Applications to start (optional) */
49
+ apps?: TApps;
50
+ /**
51
+ * Environment variables builder. Define all env vars here.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * envVars: (ports, urls, { localIp }) => ({
56
+ * DATABASE_URL: urls.postgres,
57
+ * BASE_URL: urls.api,
58
+ * VITE_PORT: ports.platform,
59
+ * EXPO_API_URL: `http://${localIp}:${ports.api}`
60
+ * })
61
+ * ```
62
+ */
63
+ envVars?: EnvVarsBuilder<TServices, TApps>;
64
+ /** Lifecycle hooks (optional) */
65
+ hooks?: DevHooks<TServices, TApps>;
66
+ /** Migrations to run after containers are ready (optional). Runs in parallel. */
67
+ migrations?: MigrationConfig[];
68
+ /** Seed configuration (optional). Runs after migrations, before servers. */
69
+ seed?: SeedConfig<TServices, TApps>;
70
+ /** Prisma configuration (optional). When set, dev.prisma is available. */
71
+ prisma?: PrismaConfig;
72
+ /** Additional options (optional) */
73
+ options?: DevOptions;
74
+ }): DevConfig<TServices, TApps> {
75
+ return config as DevConfig<TServices, TApps>;
76
+ }
77
+
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+ // Config Validation
80
+ // ═══════════════════════════════════════════════════════════════════════════
81
+
82
+ /**
83
+ * Validate a dev config and return any errors.
84
+ */
85
+ export function validateConfig<
86
+ TServices extends Record<string, ServiceConfig>,
87
+ TApps extends Record<string, AppConfig>,
88
+ >(config: DevConfig<TServices, TApps>): string[] {
89
+ const errors: string[] = [];
90
+
91
+ // Check project prefix
92
+ if (!config.projectPrefix) {
93
+ errors.push("projectPrefix is required");
94
+ } else if (!/^[a-z][a-z0-9-]*$/.test(config.projectPrefix)) {
95
+ errors.push(
96
+ "projectPrefix must start with a letter and contain only lowercase letters, numbers, and hyphens",
97
+ );
98
+ }
99
+
100
+ // Check services
101
+ if (!config.services || Object.keys(config.services).length === 0) {
102
+ errors.push("At least one service is required");
103
+ }
104
+
105
+ for (const [name, service] of Object.entries(config.services ?? {})) {
106
+ if (!service.port || typeof service.port !== "number") {
107
+ errors.push(`Service "${name}" must have a valid port number`);
108
+ }
109
+ if (service.port < 1 || service.port > 65535) {
110
+ errors.push(`Service "${name}" port must be between 1 and 65535`);
111
+ }
112
+ }
113
+
114
+ // Check apps
115
+ for (const [name, app] of Object.entries(config.apps ?? {})) {
116
+ if (!app.port || typeof app.port !== "number") {
117
+ errors.push(`App "${name}" must have a valid port number`);
118
+ }
119
+ if (!app.devCommand) {
120
+ errors.push(`App "${name}" must have a devCommand`);
121
+ }
122
+ }
123
+
124
+ // Check migrations
125
+ for (const migration of config.migrations ?? []) {
126
+ if (!migration.name) {
127
+ errors.push("Migration must have a name");
128
+ }
129
+ if (!migration.command) {
130
+ errors.push(`Migration "${migration.name}" must have a command`);
131
+ }
132
+ }
133
+
134
+ // Check seed
135
+ if (config.seed && !config.seed.command) {
136
+ errors.push("Seed must have a command");
137
+ }
138
+
139
+ return errors;
140
+ }
141
+
142
+ /**
143
+ * Validate config and throw if invalid.
144
+ */
145
+ export function assertValidConfig<
146
+ TServices extends Record<string, ServiceConfig>,
147
+ TApps extends Record<string, AppConfig>,
148
+ >(config: DevConfig<TServices, TApps>): void {
149
+ const errors = validateConfig(config);
150
+ if (errors.length > 0) {
151
+ throw new Error(`Invalid dev config:\n - ${errors.join("\n - ")}`);
152
+ }
153
+ }
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════════
156
+ // Config Helpers
157
+ // ═══════════════════════════════════════════════════════════════════════════
158
+
159
+ /**
160
+ * Merge two configs, with the second taking precedence.
161
+ */
162
+ export function mergeConfigs<
163
+ TServices extends Record<string, ServiceConfig>,
164
+ TApps extends Record<string, AppConfig>,
165
+ >(
166
+ base: DevConfig<TServices, TApps>,
167
+ overrides: Partial<DevConfig<TServices, TApps>>,
168
+ ): DevConfig<TServices, TApps> {
169
+ return {
170
+ ...base,
171
+ ...overrides,
172
+ services: { ...base.services, ...overrides.services } as TServices,
173
+ apps: { ...base.apps, ...overrides.apps } as TApps,
174
+ hooks: { ...base.hooks, ...overrides.hooks },
175
+ migrations: overrides.migrations ?? base.migrations,
176
+ seed: overrides.seed ?? base.seed,
177
+ options: { ...base.options, ...overrides.options },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Create a partial config that can be merged later.
183
+ */
184
+ export function definePartialConfig<
185
+ TServices extends Record<string, ServiceConfig> = Record<
186
+ string,
187
+ ServiceConfig
188
+ >,
189
+ TApps extends Record<string, AppConfig> = Record<string, AppConfig>,
190
+ >(
191
+ config: Partial<DevConfig<TServices, TApps>>,
192
+ ): Partial<DevConfig<TServices, TApps>> {
193
+ return config;
194
+ }