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/ports.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import type { AppConfig, ServiceConfig } from "../types";
4
+
5
+ // ═══════════════════════════════════════════════════════════════════════════
6
+ // Monorepo Root Detection
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+
9
+ /**
10
+ * Find the monorepo root by looking for package.json with workspaces.
11
+ */
12
+ export function findMonorepoRoot(startDir?: string): string {
13
+ let dir = startDir ?? process.cwd();
14
+ while (dir !== "/") {
15
+ try {
16
+ const pkgPath = resolve(dir, "package.json");
17
+ if (existsSync(pkgPath)) {
18
+ const content = readFileSync(pkgPath, "utf-8");
19
+ const pkg = JSON.parse(content);
20
+ if (pkg.workspaces) {
21
+ return dir;
22
+ }
23
+ }
24
+ } catch {
25
+ // Continue searching
26
+ }
27
+ dir = dirname(dir);
28
+ }
29
+ return process.cwd();
30
+ }
31
+
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+ // Worktree Detection
34
+ // ═══════════════════════════════════════════════════════════════════════════
35
+
36
+ /**
37
+ * Get the worktree name from .git file (if in a worktree).
38
+ */
39
+ export function getWorktreeName(root?: string): string | null {
40
+ const monorepoRoot = root ?? findMonorepoRoot();
41
+ const gitPath = resolve(monorepoRoot, ".git");
42
+ try {
43
+ if (!existsSync(gitPath) || !statSync(gitPath).isFile()) return null;
44
+ const content = readFileSync(gitPath, "utf-8").trim();
45
+ const match = content.match(/^gitdir:\s*(.+)$/);
46
+ if (!match?.[1]) return null;
47
+ return basename(match[1]);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Check if the current directory is a git worktree.
55
+ */
56
+ export function isWorktree(root?: string): boolean {
57
+ return getWorktreeName(root) !== null;
58
+ }
59
+
60
+ // ═══════════════════════════════════════════════════════════════════════════
61
+ // Port Offset Calculation
62
+ // ═══════════════════════════════════════════════════════════════════════════
63
+
64
+ /**
65
+ * Simple hash function for consistent port offsets.
66
+ */
67
+ function simpleHash(str: string): number {
68
+ let hash = 0;
69
+ for (let i = 0; i < str.length; i++) {
70
+ const char = str.charCodeAt(i);
71
+ hash = (hash << 5) - hash + char;
72
+ hash = hash & hash;
73
+ }
74
+ return Math.abs(hash);
75
+ }
76
+
77
+ /**
78
+ * Calculate port offset based on worktree name and optional suffix.
79
+ * Returns 0 for main branch, 10-99 for worktrees.
80
+ */
81
+ export function calculatePortOffset(suffix?: string, root?: string): number {
82
+ const worktreeName = getWorktreeName(root);
83
+ if (!worktreeName) return 0;
84
+ const hashInput = suffix ? `${worktreeName}-${suffix}` : worktreeName;
85
+ // Range 10-99 to avoid conflicts with main (0) and leave room
86
+ return 10 + (simpleHash(hashInput) % 90);
87
+ }
88
+
89
+ // ═══════════════════════════════════════════════════════════════════════════
90
+ // Project Naming
91
+ // ═══════════════════════════════════════════════════════════════════════════
92
+
93
+ /**
94
+ * Generate Docker project name from prefix and directory.
95
+ */
96
+ export function getProjectName(
97
+ prefix: string,
98
+ suffix?: string,
99
+ root?: string,
100
+ ): string {
101
+ const monorepoRoot = root ?? findMonorepoRoot();
102
+ const dirName = basename(monorepoRoot);
103
+ const baseName = `${prefix}-${dirName.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`;
104
+ return suffix ? `${baseName}-${suffix}` : baseName;
105
+ }
106
+
107
+ // ═══════════════════════════════════════════════════════════════════════════
108
+ // Port Computation
109
+ // ═══════════════════════════════════════════════════════════════════════════
110
+
111
+ /**
112
+ * Compute all ports for services and apps with offset applied.
113
+ */
114
+ export function computePorts<
115
+ TServices extends Record<string, ServiceConfig>,
116
+ TApps extends Record<string, AppConfig>,
117
+ >(
118
+ services: TServices,
119
+ apps: TApps | undefined,
120
+ offset: number,
121
+ ): Record<string, number> {
122
+ const ports: Record<string, number> = {};
123
+
124
+ // Add service ports
125
+ for (const [name, config] of Object.entries(services)) {
126
+ ports[name] = config.port + offset;
127
+ // Handle secondary ports (e.g., clickhouseNative)
128
+ if (config.secondaryPort) {
129
+ ports[`${name}Secondary`] = config.secondaryPort + offset;
130
+ }
131
+ }
132
+
133
+ // Add app ports
134
+ if (apps) {
135
+ for (const [name, config] of Object.entries(apps)) {
136
+ ports[name] = config.port + offset;
137
+ }
138
+ }
139
+
140
+ return ports;
141
+ }
142
+
143
+ // ═══════════════════════════════════════════════════════════════════════════
144
+ // URL Generation
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+
147
+ /**
148
+ * Service defaults for common services.
149
+ */
150
+ const SERVICE_DEFAULTS: Record<
151
+ string,
152
+ { user: string; password: string; database: string }
153
+ > = {
154
+ postgres: { user: "postgres", password: "postgres", database: "postgres" },
155
+ postgresql: { user: "postgres", password: "postgres", database: "postgres" },
156
+ redis: { user: "", password: "", database: "" },
157
+ clickhouse: { user: "default", password: "clickhouse", database: "default" },
158
+ mysql: { user: "root", password: "root", database: "mysql" },
159
+ mongodb: { user: "", password: "", database: "" },
160
+ };
161
+
162
+ /**
163
+ * Build URL for a service with given credentials and database.
164
+ */
165
+ function buildServiceUrl(
166
+ serviceName: string,
167
+ ctx: { port: number; host: string },
168
+ config: { database?: string; user?: string; password?: string },
169
+ ): string | null {
170
+ const defaults = SERVICE_DEFAULTS[serviceName];
171
+ if (!defaults && !config.database) return null;
172
+
173
+ const user = config.user ?? defaults?.user ?? "";
174
+ const password = config.password ?? defaults?.password ?? "";
175
+ const database = config.database ?? defaults?.database ?? "";
176
+
177
+ switch (serviceName) {
178
+ case "postgres":
179
+ case "postgresql":
180
+ return `postgresql://${user}:${password}@${ctx.host}:${ctx.port}/${database}`;
181
+ case "redis":
182
+ return `redis://${ctx.host}:${ctx.port}`;
183
+ case "clickhouse":
184
+ return `http://${user}:${password}@${ctx.host}:${ctx.port}/${database}`;
185
+ case "mysql":
186
+ return `mysql://${user}:${password}@${ctx.host}:${ctx.port}/${database}`;
187
+ case "mongodb":
188
+ return `mongodb://${ctx.host}:${ctx.port}/${database}`;
189
+ default:
190
+ return null;
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Compute URLs for all services and apps.
196
+ */
197
+ export function computeUrls<
198
+ TServices extends Record<string, ServiceConfig>,
199
+ TApps extends Record<string, AppConfig>,
200
+ >(
201
+ services: TServices,
202
+ apps: TApps | undefined,
203
+ ports: Record<string, number>,
204
+ localIp: string,
205
+ ): Record<string, string> {
206
+ const urls: Record<string, string> = {};
207
+ const host = "localhost";
208
+
209
+ // Add service URLs
210
+ for (const [name, config] of Object.entries(services)) {
211
+ const port = ports[name];
212
+ const secondaryPort = ports[`${name}Secondary`];
213
+
214
+ // Skip if port is not defined
215
+ if (port === undefined) continue;
216
+
217
+ const ctx = { port, secondaryPort, host, localIp };
218
+
219
+ if (config.urlTemplate) {
220
+ // Use the provided function
221
+ urls[name] = config.urlTemplate(ctx);
222
+ } else {
223
+ // Try to build URL using service name and config options
224
+ const builtUrl = buildServiceUrl(
225
+ name,
226
+ { port, host },
227
+ {
228
+ database: config.database,
229
+ user: config.user,
230
+ password: config.password,
231
+ },
232
+ );
233
+ if (builtUrl) {
234
+ urls[name] = builtUrl;
235
+ } else {
236
+ // Fallback to simple HTTP URL
237
+ urls[name] = `http://${host}:${port}`;
238
+ }
239
+ }
240
+ }
241
+
242
+ // Add app URLs
243
+ if (apps) {
244
+ for (const [name, _config] of Object.entries(apps)) {
245
+ const port = ports[name];
246
+ urls[name] = `http://${host}:${port}`;
247
+ // Also add local IP version for mobile connectivity
248
+ urls[`${name}Local`] = `http://${localIp}:${port}`;
249
+ }
250
+ }
251
+
252
+ return urls;
253
+ }
@@ -0,0 +1,253 @@
1
+ import {
2
+ type ChildProcess,
3
+ execSync,
4
+ type SpawnOptions,
5
+ spawn,
6
+ } from "node:child_process";
7
+ import { resolve } from "node:path";
8
+ import type { AppConfig, DevServerPids, ExecOptions } from "../types";
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // Command Execution
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ export interface ExecResult {
15
+ exitCode: number;
16
+ stdout: string;
17
+ stderr: string;
18
+ }
19
+
20
+ /**
21
+ * Execute a shell command with environment variables.
22
+ */
23
+ export function exec(
24
+ cmd: string,
25
+ root: string,
26
+ envVars: Record<string, string>,
27
+ options: ExecOptions = {},
28
+ ): ExecResult {
29
+ const { cwd, verbose = false, env = {}, throwOnError = true } = options;
30
+
31
+ const workingDir = cwd ? resolve(root, cwd) : root;
32
+ const fullEnv = { ...process.env, ...envVars, ...env };
33
+
34
+ try {
35
+ const stdout = execSync(cmd, {
36
+ cwd: workingDir,
37
+ env: fullEnv,
38
+ encoding: "utf-8",
39
+ stdio: verbose ? "inherit" : ["pipe", "pipe", "pipe"],
40
+ });
41
+
42
+ return {
43
+ exitCode: 0,
44
+ stdout: typeof stdout === "string" ? stdout : "",
45
+ stderr: "",
46
+ };
47
+ } catch (error) {
48
+ const execError = error as {
49
+ status?: number;
50
+ stdout?: string;
51
+ stderr?: string;
52
+ };
53
+ const result: ExecResult = {
54
+ exitCode: execError.status ?? 1,
55
+ stdout: execError.stdout ?? "",
56
+ stderr: execError.stderr ?? "",
57
+ };
58
+
59
+ if (throwOnError) {
60
+ throw new Error(
61
+ `Command failed with exit code ${result.exitCode}: ${cmd}\n${result.stderr}`,
62
+ );
63
+ }
64
+
65
+ return result;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Execute a shell command asynchronously.
71
+ */
72
+ export async function execAsync(
73
+ cmd: string,
74
+ root: string,
75
+ envVars: Record<string, string>,
76
+ options: ExecOptions = {},
77
+ ): Promise<ExecResult> {
78
+ // For now, wrap sync in Promise - can be optimized later with spawn
79
+ return new Promise((resolve) => {
80
+ const result = exec(cmd, root, envVars, {
81
+ ...options,
82
+ throwOnError: false,
83
+ });
84
+ resolve(result);
85
+ });
86
+ }
87
+
88
+ // ═══════════════════════════════════════════════════════════════════════════
89
+ // Process Spawning
90
+ // ═══════════════════════════════════════════════════════════════════════════
91
+
92
+ export interface SpawnDevServerOptions {
93
+ verbose?: boolean;
94
+ detached?: boolean;
95
+ isCI?: boolean;
96
+ }
97
+
98
+ /**
99
+ * Spawn a dev server as a detached process.
100
+ */
101
+ export function spawnDevServer(
102
+ command: string,
103
+ root: string,
104
+ appCwd: string | undefined,
105
+ envVars: Record<string, string>,
106
+ options: SpawnDevServerOptions = {},
107
+ ): ChildProcess {
108
+ const { verbose = false, detached = true, isCI = false } = options;
109
+
110
+ // Parse command into parts
111
+ const parts = command.split(" ");
112
+ const cmd = parts[0];
113
+ const args = parts.slice(1);
114
+
115
+ if (!cmd) {
116
+ throw new Error("Command cannot be empty");
117
+ }
118
+
119
+ const workingDir = appCwd ? resolve(root, appCwd) : root;
120
+
121
+ const spawnOptions: SpawnOptions = {
122
+ cwd: workingDir,
123
+ env: { ...process.env, ...envVars },
124
+ detached,
125
+ stdio: isCI || verbose ? "inherit" : "ignore",
126
+ };
127
+
128
+ const proc = spawn(cmd, args, spawnOptions);
129
+
130
+ if (detached && proc.unref) {
131
+ proc.unref();
132
+ }
133
+
134
+ return proc;
135
+ }
136
+
137
+ /**
138
+ * Start all configured dev servers.
139
+ */
140
+ export function startDevServers(
141
+ apps: Record<string, AppConfig>,
142
+ root: string,
143
+ envVars: Record<string, string>,
144
+ options: {
145
+ verbose?: boolean;
146
+ productionBuild?: boolean;
147
+ isCI?: boolean;
148
+ } = {},
149
+ ): DevServerPids {
150
+ const { verbose = true, productionBuild = false, isCI = false } = options;
151
+ const pids: DevServerPids = {};
152
+
153
+ if (verbose) {
154
+ console.log(
155
+ productionBuild
156
+ ? "🚀 Starting production servers..."
157
+ : "🔧 Starting dev servers...",
158
+ );
159
+ }
160
+
161
+ for (const [name, config] of Object.entries(apps)) {
162
+ const command = productionBuild
163
+ ? (config.prodCommand ?? config.devCommand)
164
+ : config.devCommand;
165
+
166
+ const proc = spawnDevServer(command, root, config.cwd, envVars, {
167
+ verbose,
168
+ isCI,
169
+ });
170
+
171
+ if (proc.pid) {
172
+ pids[name] = proc.pid;
173
+ if (verbose) {
174
+ console.log(` ${name} PID: ${proc.pid}`);
175
+ }
176
+ }
177
+ }
178
+
179
+ return pids;
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════════════════════════════════
183
+ // Process Management
184
+ // ═══════════════════════════════════════════════════════════════════════════
185
+
186
+ /**
187
+ * Stop a process by PID.
188
+ */
189
+ export function stopProcess(pid: number): void {
190
+ try {
191
+ process.kill(pid, "SIGTERM");
192
+ } catch {
193
+ // Process may already be dead
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Stop all processes by their PIDs.
199
+ */
200
+ export function stopAllProcesses(
201
+ pids: DevServerPids,
202
+ options: { verbose?: boolean } = {},
203
+ ): void {
204
+ const { verbose = true } = options;
205
+
206
+ for (const [name, pid] of Object.entries(pids)) {
207
+ if (pid) {
208
+ if (verbose) console.log(` Stopping ${name} (PID: ${pid})`);
209
+ stopProcess(pid);
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Check if a process is alive by sending signal 0.
216
+ */
217
+ export function isProcessAlive(pid: number): boolean {
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
225
+
226
+ // ═══════════════════════════════════════════════════════════════════════════
227
+ // Build Commands
228
+ // ═══════════════════════════════════════════════════════════════════════════
229
+
230
+ /**
231
+ * Run production build for apps that have buildCommand configured.
232
+ */
233
+ export function buildApps(
234
+ apps: Record<string, AppConfig>,
235
+ root: string,
236
+ envVars: Record<string, string>,
237
+ options: { verbose?: boolean } = {},
238
+ ): void {
239
+ const { verbose = true } = options;
240
+
241
+ for (const [name, config] of Object.entries(apps)) {
242
+ if (config.buildCommand) {
243
+ if (verbose) console.log(`🔨 Building ${name}...`);
244
+
245
+ exec(config.buildCommand, root, envVars, {
246
+ cwd: config.cwd,
247
+ verbose,
248
+ });
249
+ }
250
+ }
251
+
252
+ if (verbose) console.log("✓ Build complete");
253
+ }
package/core/utils.ts ADDED
@@ -0,0 +1,127 @@
1
+ import type { AppConfig, DevConfig, ServiceConfig } from "../types";
2
+ import { getLocalIp } from "./network";
3
+ import { calculatePortOffset, computePorts, computeUrls } from "./ports";
4
+
5
+ /**
6
+ * Core utility functions shared across modules.
7
+ */
8
+
9
+ /**
10
+ * Sleep for a given number of milliseconds.
11
+ */
12
+ export function sleep(ms: number): Promise<void> {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ /**
17
+ * Detect if running in a CI environment.
18
+ */
19
+ export function isCI(): boolean {
20
+ return (
21
+ process.env.CI === "true" ||
22
+ process.env.CI === "1" ||
23
+ process.env.GITHUB_ACTIONS === "true" ||
24
+ process.env.GITLAB_CI === "true" ||
25
+ process.env.CIRCLECI === "true" ||
26
+ process.env.JENKINS_URL !== undefined
27
+ );
28
+ }
29
+
30
+ // ═══════════════════════════════════════════════════════════════════════════
31
+ // Vibe Kanban Integration
32
+ // ═══════════════════════════════════════════════════════════════════════════
33
+
34
+ /**
35
+ * Log the frontend port in a format that Vibe Kanban can detect.
36
+ * This is used to communicate the dev server port to external tools.
37
+ *
38
+ * @param port - The port number the frontend is running on
39
+ */
40
+ export function logFrontendPort(port: number | undefined): void {
41
+ console.log(`using_frontend_port:${port}`);
42
+ }
43
+
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+ // Config-based Env Var Helper
46
+ // ═══════════════════════════════════════════════════════════════════════════
47
+
48
+ /**
49
+ * Get an environment variable value from the config.
50
+ * Computes ports/urls and runs envVars to get the value.
51
+ *
52
+ * @param config - The dev config object (from defineDevConfig)
53
+ * @param name - The environment variable name
54
+ * @param options - Optional settings (log for Vibe Kanban detection)
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * // In vite.config.ts
59
+ * import { getEnvVar } from 'buncargo'
60
+ * import config from '../../dev.config'
61
+ *
62
+ * export default defineConfig(async ({ command }) => {
63
+ * const isDev = command === 'serve'
64
+ * const vitePort = isDev ? getEnvVar(config, 'VITE_PORT') : undefined
65
+ * const apiUrl = getEnvVar(config, 'VITE_API_URL')
66
+ * return {
67
+ * server: { port: vitePort, strictPort: true }
68
+ * }
69
+ * })
70
+ * ```
71
+ */
72
+ export function getEnvVar<
73
+ TServices extends Record<string, ServiceConfig>,
74
+ TApps extends Record<string, AppConfig>,
75
+ >(
76
+ config: DevConfig<TServices, TApps>,
77
+ name: string,
78
+ options: { log?: boolean } = {},
79
+ ): string | number | undefined {
80
+ const { log = true } = options;
81
+ const offset = calculatePortOffset();
82
+ const localIp = getLocalIp();
83
+
84
+ // Compute ports and urls
85
+ const ports = computePorts(config.services, config.apps, offset);
86
+ const urls = computeUrls(config.services, config.apps, ports, localIp);
87
+
88
+ // Build env vars from the function
89
+ const envVars = config.envVars?.(
90
+ ports as Parameters<NonNullable<typeof config.envVars>>[0],
91
+ urls as Parameters<NonNullable<typeof config.envVars>>[1],
92
+ {
93
+ projectName: config.projectPrefix,
94
+ localIp,
95
+ portOffset: offset,
96
+ },
97
+ );
98
+
99
+ const value = envVars?.[name];
100
+
101
+ // Log frontend port for Vibe Kanban detection
102
+ if (log && name === "VITE_PORT" && typeof value === "number") {
103
+ logFrontendPort(value);
104
+ }
105
+
106
+ return value;
107
+ }
108
+
109
+ /**
110
+ * Log the API URL in a format that tools can detect.
111
+ * This is used by Expo and other tools to find the API server.
112
+ *
113
+ * @param url - The API URL
114
+ */
115
+ export function logApiUrl(url: string): void {
116
+ console.log(`using_api_url:${url}`);
117
+ }
118
+
119
+ /**
120
+ * Log the Expo API URL in a format that tools can detect.
121
+ * This is typically the local IP address for mobile device connectivity.
122
+ *
123
+ * @param url - The Expo API URL (usually http://<local-ip>:<port>)
124
+ */
125
+ export function logExpoApiUrl(url: string): void {
126
+ console.log(`using_expo_api_url:${url}`);
127
+ }