@chriscode/devmux 1.0.0 → 1.3.1

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.
@@ -0,0 +1,631 @@
1
+ import {
2
+ __esm,
3
+ __export,
4
+ init_esm_shims
5
+ } from "./chunk-66UOCF5R.js";
6
+
7
+ // src/utils/worktree.ts
8
+ import { execSync } from "child_process";
9
+ function detectWorktreeName() {
10
+ try {
11
+ const gitDir = execSync("git rev-parse --git-dir", {
12
+ encoding: "utf-8",
13
+ stdio: ["pipe", "pipe", "pipe"]
14
+ }).trim();
15
+ const worktreeMatch = gitDir.match(/\.git\/worktrees\/([^/]+)/);
16
+ if (worktreeMatch) {
17
+ return worktreeMatch[1];
18
+ }
19
+ return null;
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+ function sanitizeInstanceId(name) {
25
+ return name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 30);
26
+ }
27
+ function resolveInstanceId() {
28
+ const envInstanceId = process.env.DEVMUX_INSTANCE_ID;
29
+ if (envInstanceId) {
30
+ return sanitizeInstanceId(envInstanceId);
31
+ }
32
+ const worktreeName = detectWorktreeName();
33
+ if (worktreeName) {
34
+ return sanitizeInstanceId(worktreeName);
35
+ }
36
+ return "";
37
+ }
38
+ var init_worktree = __esm({
39
+ "src/utils/worktree.ts"() {
40
+ "use strict";
41
+ init_esm_shims();
42
+ }
43
+ });
44
+
45
+ // src/utils/port.ts
46
+ function calculatePortOffset(instanceId) {
47
+ if (!instanceId) return 0;
48
+ let hash = 0;
49
+ for (let i = 0; i < instanceId.length; i++) {
50
+ hash = (hash << 5) - hash + instanceId.charCodeAt(i);
51
+ hash = hash & hash;
52
+ }
53
+ return Math.abs(hash) % 999 + 1;
54
+ }
55
+ function resolvePort(basePort, instanceId) {
56
+ return basePort + calculatePortOffset(instanceId);
57
+ }
58
+ var init_port = __esm({
59
+ "src/utils/port.ts"() {
60
+ "use strict";
61
+ init_esm_shims();
62
+ }
63
+ });
64
+
65
+ // src/config/loader.ts
66
+ var loader_exports = {};
67
+ __export(loader_exports, {
68
+ getBasePort: () => getBasePort,
69
+ getResolvedPort: () => getResolvedPort,
70
+ getServiceCwd: () => getServiceCwd,
71
+ getSessionName: () => getSessionName,
72
+ loadConfig: () => loadConfig
73
+ });
74
+ import { readFileSync, existsSync } from "fs";
75
+ import { resolve, dirname } from "path";
76
+ function findConfigFile(startDir) {
77
+ let dir = resolve(startDir);
78
+ const root = dirname(dir);
79
+ while (dir !== root) {
80
+ for (const name of CONFIG_NAMES) {
81
+ const configPath = resolve(dir, name);
82
+ if (existsSync(configPath)) {
83
+ return configPath;
84
+ }
85
+ }
86
+ const pkgPath = resolve(dir, "package.json");
87
+ if (existsSync(pkgPath)) {
88
+ try {
89
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
90
+ if (pkg.devmux) {
91
+ return pkgPath;
92
+ }
93
+ } catch {
94
+ }
95
+ }
96
+ dir = dirname(dir);
97
+ }
98
+ return null;
99
+ }
100
+ function loadConfigFromFile(configPath) {
101
+ const content = readFileSync(configPath, "utf-8");
102
+ if (configPath.endsWith("package.json")) {
103
+ const pkg = JSON.parse(content);
104
+ return pkg.devmux;
105
+ }
106
+ return JSON.parse(content);
107
+ }
108
+ function validateConfig(config) {
109
+ if (!config || typeof config !== "object") return false;
110
+ const c = config;
111
+ if (c.version !== 1) return false;
112
+ if (typeof c.project !== "string") return false;
113
+ if (!c.services || typeof c.services !== "object") return false;
114
+ return true;
115
+ }
116
+ function loadConfig(startDir = process.cwd()) {
117
+ const configPath = findConfigFile(startDir);
118
+ if (!configPath) {
119
+ throw new Error(
120
+ "No devmux config found. Create devmux.config.json or add 'devmux' to package.json"
121
+ );
122
+ }
123
+ const config = loadConfigFromFile(configPath);
124
+ if (!validateConfig(config)) {
125
+ throw new Error(`Invalid devmux config in ${configPath}`);
126
+ }
127
+ const configRoot = dirname(configPath);
128
+ const resolvedSessionPrefix = config.sessionPrefix ?? `omo-${config.project}`;
129
+ const instanceId = resolveInstanceId();
130
+ return {
131
+ ...config,
132
+ configRoot,
133
+ resolvedSessionPrefix,
134
+ instanceId
135
+ };
136
+ }
137
+ function getSessionName(config, serviceName) {
138
+ const service = config.services[serviceName];
139
+ if (service?.sessionName) {
140
+ return service.sessionName;
141
+ }
142
+ if (config.instanceId) {
143
+ return `${config.resolvedSessionPrefix}-${config.instanceId}-${serviceName}`;
144
+ }
145
+ return `${config.resolvedSessionPrefix}-${serviceName}`;
146
+ }
147
+ function getServiceCwd(config, serviceName) {
148
+ const service = config.services[serviceName];
149
+ if (!service) {
150
+ throw new Error(`Unknown service: ${serviceName}`);
151
+ }
152
+ return resolve(config.configRoot, service.cwd);
153
+ }
154
+ function getBasePort(health, explicitPort) {
155
+ if (explicitPort !== void 0) return explicitPort;
156
+ if (!health) return void 0;
157
+ if (health.type === "port") return health.port;
158
+ if (health.type === "http") {
159
+ try {
160
+ const url = new URL(health.url);
161
+ return parseInt(url.port) || (url.protocol === "https:" ? 443 : 80);
162
+ } catch {
163
+ return void 0;
164
+ }
165
+ }
166
+ return void 0;
167
+ }
168
+ function getResolvedPort(config, serviceName) {
169
+ const service = config.services[serviceName];
170
+ if (!service) return void 0;
171
+ const basePort = getBasePort(service.health, service.port);
172
+ if (basePort === void 0) return void 0;
173
+ return resolvePort(basePort, config.instanceId);
174
+ }
175
+ var CONFIG_NAMES;
176
+ var init_loader = __esm({
177
+ "src/config/loader.ts"() {
178
+ "use strict";
179
+ init_esm_shims();
180
+ init_worktree();
181
+ init_port();
182
+ CONFIG_NAMES = [
183
+ "devmux.config.json",
184
+ ".devmuxrc.json",
185
+ ".devmuxrc"
186
+ ];
187
+ }
188
+ });
189
+
190
+ // src/tmux/driver.ts
191
+ var driver_exports = {};
192
+ __export(driver_exports, {
193
+ attachSession: () => attachSession,
194
+ hasSession: () => hasSession,
195
+ killSession: () => killSession,
196
+ listSessions: () => listSessions,
197
+ newSession: () => newSession,
198
+ setRemainOnExit: () => setRemainOnExit
199
+ });
200
+ init_esm_shims();
201
+ import { execSync as execSync2, spawn } from "child_process";
202
+ function hasSession(sessionName) {
203
+ try {
204
+ execSync2(`tmux has-session -t ${sessionName}`, {
205
+ stdio: ["pipe", "pipe", "pipe"]
206
+ });
207
+ return true;
208
+ } catch {
209
+ return false;
210
+ }
211
+ }
212
+ function listSessions(prefix) {
213
+ try {
214
+ const output = execSync2("tmux list-sessions -F #{session_name}", {
215
+ encoding: "utf-8",
216
+ stdio: ["pipe", "pipe", "pipe"]
217
+ });
218
+ const sessions = output.trim().split("\n").filter(Boolean);
219
+ if (prefix) {
220
+ return sessions.filter((s) => s.startsWith(prefix));
221
+ }
222
+ return sessions;
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+ function newSession(sessionName, cwd, command, env) {
228
+ const envPrefix = env ? Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ") + " " : "";
229
+ execSync2(
230
+ `tmux new-session -d -s "${sessionName}" -c "${cwd}" "${envPrefix}${command}"`,
231
+ { stdio: ["pipe", "pipe", "pipe"] }
232
+ );
233
+ }
234
+ function setRemainOnExit(sessionName, value) {
235
+ try {
236
+ execSync2(
237
+ `tmux set-option -t "${sessionName}" remain-on-exit ${value ? "on" : "off"}`,
238
+ { stdio: ["pipe", "pipe", "pipe"] }
239
+ );
240
+ } catch {
241
+ }
242
+ }
243
+ function killSession(sessionName) {
244
+ try {
245
+ execSync2(`tmux kill-session -t "${sessionName}"`, {
246
+ stdio: ["pipe", "pipe", "pipe"]
247
+ });
248
+ } catch {
249
+ }
250
+ }
251
+ function attachSession(sessionName) {
252
+ const child = spawn("tmux", ["attach", "-t", sessionName], {
253
+ stdio: "inherit"
254
+ });
255
+ child.on("error", () => {
256
+ });
257
+ }
258
+
259
+ // src/health/checkers.ts
260
+ var checkers_exports = {};
261
+ __export(checkers_exports, {
262
+ checkHealth: () => checkHealth,
263
+ checkHttp: () => checkHttp,
264
+ checkPort: () => checkPort,
265
+ checkTmuxPane: () => checkTmuxPane,
266
+ getHealthPort: () => getHealthPort
267
+ });
268
+ init_esm_shims();
269
+ import { createConnection } from "net";
270
+ import { execSync as execSync3 } from "child_process";
271
+ function checkPort(port, host = "127.0.0.1") {
272
+ return new Promise((resolve2) => {
273
+ const socket = createConnection({ port, host });
274
+ socket.setTimeout(1e3);
275
+ socket.on("connect", () => {
276
+ socket.destroy();
277
+ resolve2(true);
278
+ });
279
+ socket.on("timeout", () => {
280
+ socket.destroy();
281
+ resolve2(false);
282
+ });
283
+ socket.on("error", () => {
284
+ socket.destroy();
285
+ resolve2(false);
286
+ });
287
+ });
288
+ }
289
+ async function checkHttp(url, expectStatus = 200) {
290
+ try {
291
+ const response = await fetch(url, {
292
+ method: "GET",
293
+ signal: AbortSignal.timeout(5e3)
294
+ });
295
+ if (expectStatus === 200) {
296
+ return response.ok || response.status === 404;
297
+ }
298
+ return response.status === expectStatus;
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+ function checkTmuxPane(sessionName) {
304
+ try {
305
+ const output = execSync3(
306
+ `tmux list-panes -t "${sessionName}" -F "#{pane_dead}"`,
307
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
308
+ );
309
+ return output.trim() === "0";
310
+ } catch {
311
+ return false;
312
+ }
313
+ }
314
+ async function checkHealth(health, sessionName) {
315
+ if (!health) {
316
+ return checkTmuxPane(sessionName);
317
+ }
318
+ switch (health.type) {
319
+ case "port":
320
+ return checkPort(health.port, health.host);
321
+ case "http":
322
+ return checkHttp(health.url, health.expectStatus);
323
+ }
324
+ }
325
+ function getHealthPort(health) {
326
+ if (!health) return void 0;
327
+ if (health.type === "port") return health.port;
328
+ if (health.type === "http") {
329
+ try {
330
+ const url = new URL(health.url);
331
+ return parseInt(url.port) || (url.protocol === "https:" ? 443 : 80);
332
+ } catch {
333
+ return void 0;
334
+ }
335
+ }
336
+ return void 0;
337
+ }
338
+
339
+ // src/core/service.ts
340
+ init_esm_shims();
341
+ init_loader();
342
+
343
+ // src/utils/process.ts
344
+ init_esm_shims();
345
+ import fkill from "fkill";
346
+ import psList from "ps-list";
347
+ import { execSync as execSync4 } from "child_process";
348
+ async function getProcessOnPort(port) {
349
+ try {
350
+ if (process.platform === "win32") {
351
+ const output2 = execSync4(`netstat -ano | findstr :${port}`, { encoding: "utf-8" });
352
+ const lines = output2.trim().split("\n");
353
+ for (const line of lines) {
354
+ const parts = line.trim().split(/\s+/);
355
+ if (parts.length >= 5) {
356
+ const pid2 = parseInt(parts[4], 10);
357
+ if (!isNaN(pid2)) {
358
+ const proc = await getProcessInfo(pid2);
359
+ if (proc) return proc;
360
+ }
361
+ }
362
+ }
363
+ return null;
364
+ }
365
+ const output = execSync4(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
366
+ if (!output) return null;
367
+ const pid = parseInt(output.split("\n")[0], 10);
368
+ if (isNaN(pid)) return null;
369
+ return await getProcessInfo(pid);
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+ async function getProcessInfo(pid) {
375
+ try {
376
+ const processes = await psList();
377
+ const proc = processes.find((p) => p.pid === pid);
378
+ if (!proc) return null;
379
+ return {
380
+ pid: proc.pid,
381
+ name: proc.name,
382
+ cmd: proc.cmd
383
+ };
384
+ } catch {
385
+ return null;
386
+ }
387
+ }
388
+ async function killProcess(pid) {
389
+ try {
390
+ await fkill(pid, { force: true });
391
+ } catch {
392
+ }
393
+ }
394
+ async function getProcessesOnPort(port) {
395
+ const processes = [];
396
+ try {
397
+ if (process.platform === "win32") {
398
+ const output = execSync4(`netstat -ano | findstr :${port}`, { encoding: "utf-8" });
399
+ const lines = output.trim().split("\n");
400
+ const seenPids = /* @__PURE__ */ new Set();
401
+ for (const line of lines) {
402
+ const parts = line.trim().split(/\s+/);
403
+ if (parts.length >= 5) {
404
+ const pid = parseInt(parts[4], 10);
405
+ if (!isNaN(pid) && !seenPids.has(pid)) {
406
+ seenPids.add(pid);
407
+ const proc = await getProcessInfo(pid);
408
+ if (proc) processes.push(proc);
409
+ }
410
+ }
411
+ }
412
+ } else {
413
+ const output = execSync4(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
414
+ if (output) {
415
+ const pids = output.split("\n").map((p) => parseInt(p, 10)).filter((p) => !isNaN(p));
416
+ const uniquePids = [...new Set(pids)];
417
+ for (const pid of uniquePids) {
418
+ const proc = await getProcessInfo(pid);
419
+ if (proc) processes.push(proc);
420
+ }
421
+ }
422
+ }
423
+ } catch {
424
+ }
425
+ return processes;
426
+ }
427
+
428
+ // src/core/service.ts
429
+ async function ensureService(config, serviceName, options = {}, _dependencyStack = /* @__PURE__ */ new Set()) {
430
+ if (_dependencyStack.has(serviceName)) {
431
+ throw new Error(`Circular dependency detected: ${Array.from(_dependencyStack).join(" -> ")} -> ${serviceName}`);
432
+ }
433
+ const service = config.services[serviceName];
434
+ if (!service) {
435
+ throw new Error(`Unknown service: ${serviceName}`);
436
+ }
437
+ if (service.dependsOn && service.dependsOn.length > 0) {
438
+ _dependencyStack.add(serviceName);
439
+ try {
440
+ if (!options.quiet) console.log(`Checking dependencies for ${serviceName}...`);
441
+ for (const dep of service.dependsOn) {
442
+ await ensureService(config, dep, options, _dependencyStack);
443
+ }
444
+ } finally {
445
+ _dependencyStack.delete(serviceName);
446
+ }
447
+ }
448
+ const sessionName = getSessionName(config, serviceName);
449
+ const cwd = getServiceCwd(config, serviceName);
450
+ const timeout = options.timeout ?? config.defaults?.startupTimeoutSeconds ?? 30;
451
+ const log = options.quiet ? () => {
452
+ } : console.log;
453
+ const resolvedPort = getResolvedPort(config, serviceName);
454
+ const resolvedHealth = resolveHealthCheck(service.health, resolvedPort);
455
+ const env = buildServiceEnv(config, serviceName, service.env);
456
+ const isHealthy = await checkHealth(resolvedHealth, sessionName);
457
+ if (isHealthy) {
458
+ const hasTmux = hasSession(sessionName);
459
+ log(`\u2705 ${serviceName} already running`);
460
+ if (hasTmux) {
461
+ log(` \u2514\u2500 tmux session: ${sessionName}`);
462
+ } else {
463
+ log(` \u2514\u2500 (running outside tmux)`);
464
+ }
465
+ if (resolvedPort && config.instanceId) {
466
+ log(` \u2514\u2500 port: ${resolvedPort} (instance: ${config.instanceId})`);
467
+ }
468
+ return { serviceName, startedByUs: false, sessionName };
469
+ }
470
+ if (hasSession(sessionName)) {
471
+ log(`\u{1F504} Cleaning up stale session: ${sessionName}`);
472
+ killSession(sessionName);
473
+ }
474
+ log(`\u{1F680} Starting ${serviceName} in tmux session: ${sessionName}`);
475
+ if (resolvedPort && config.instanceId) {
476
+ log(` \u2514\u2500 port: ${resolvedPort} (instance: ${config.instanceId})`);
477
+ }
478
+ newSession(sessionName, cwd, service.command, env);
479
+ const remainOnExit = config.defaults?.remainOnExit ?? true;
480
+ setRemainOnExit(sessionName, remainOnExit);
481
+ log(`\u23F3 Waiting for ${serviceName} to be ready...`);
482
+ for (let i = 0; i < timeout; i++) {
483
+ if (await checkHealth(resolvedHealth, sessionName)) {
484
+ log(`\u2705 ${serviceName} ready`);
485
+ log(` \u2514\u2500 tmux session: ${sessionName}`);
486
+ return { serviceName, startedByUs: true, sessionName };
487
+ }
488
+ await sleep(1e3);
489
+ }
490
+ throw new Error(`${serviceName} failed to start within ${timeout}s`);
491
+ }
492
+ async function getStatus(config, serviceName) {
493
+ const service = config.services[serviceName];
494
+ if (!service) {
495
+ throw new Error(`Unknown service: ${serviceName}`);
496
+ }
497
+ const sessionName = getSessionName(config, serviceName);
498
+ const resolvedPort = getResolvedPort(config, serviceName);
499
+ const resolvedHealth = resolveHealthCheck(service.health, resolvedPort);
500
+ const healthy = await checkHealth(resolvedHealth, sessionName);
501
+ const hasTmux = hasSession(sessionName);
502
+ return {
503
+ name: serviceName,
504
+ healthy,
505
+ tmuxSession: hasTmux ? sessionName : null,
506
+ port: getHealthPort(service.health),
507
+ resolvedPort,
508
+ managedByDevmux: hasTmux,
509
+ instanceId: config.instanceId || void 0
510
+ };
511
+ }
512
+ async function getAllStatus(config) {
513
+ const statuses = [];
514
+ for (const serviceName of Object.keys(config.services)) {
515
+ statuses.push(await getStatus(config, serviceName));
516
+ }
517
+ return statuses;
518
+ }
519
+ async function stopService(config, serviceName, options = {}) {
520
+ const service = config.services[serviceName];
521
+ if (!service) {
522
+ throw new Error(`Unknown service: ${serviceName}`);
523
+ }
524
+ const sessionName = getSessionName(config, serviceName);
525
+ const log = options.quiet ? () => {
526
+ } : console.log;
527
+ log(`\u{1F6D1} Stopping ${serviceName}...`);
528
+ if (hasSession(sessionName)) {
529
+ killSession(sessionName);
530
+ log(` \u2514\u2500 Killed tmux session: ${sessionName}`);
531
+ }
532
+ if (options.killPorts) {
533
+ const resolvedPort = getResolvedPort(config, serviceName);
534
+ const ports = [];
535
+ if (resolvedPort) ports.push(resolvedPort);
536
+ if (service.stopPorts) {
537
+ for (const p of service.stopPorts) {
538
+ ports.push(p);
539
+ }
540
+ }
541
+ for (const port of [...new Set(ports)]) {
542
+ const processes = await getProcessesOnPort(port);
543
+ for (const proc of processes) {
544
+ await killProcess(proc.pid);
545
+ log(` \u2514\u2500 Killed process ${proc.name} (PID ${proc.pid}) on port ${port}`);
546
+ }
547
+ }
548
+ }
549
+ log(`\u2705 ${serviceName} stopped`);
550
+ }
551
+ async function stopAllServices(config, options = {}) {
552
+ for (const serviceName of Object.keys(config.services)) {
553
+ await stopService(config, serviceName, options);
554
+ }
555
+ }
556
+ async function restartService(config, serviceName, options = {}) {
557
+ stopService(config, serviceName, { killPorts: options.killPorts, quiet: options.quiet });
558
+ return ensureService(config, serviceName, { timeout: options.timeout, quiet: options.quiet });
559
+ }
560
+ function attachService(config, serviceName) {
561
+ const service = config.services[serviceName];
562
+ if (!service) {
563
+ throw new Error(`Unknown service: ${serviceName}`);
564
+ }
565
+ const sessionName = getSessionName(config, serviceName);
566
+ if (!hasSession(sessionName)) {
567
+ throw new Error(`No tmux session for ${serviceName}. Service may not be running or was started outside tmux.`);
568
+ }
569
+ console.log(`\u{1F4CE} Attaching to ${sessionName}...`);
570
+ console.log(` (detach with Ctrl+B, then D)`);
571
+ attachSession(sessionName);
572
+ }
573
+ function sleep(ms) {
574
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
575
+ }
576
+ function resolveHealthCheck(health, resolvedPort) {
577
+ if (!health) return void 0;
578
+ if (resolvedPort === void 0) return health;
579
+ if (health.type === "port") {
580
+ return { ...health, port: resolvedPort };
581
+ }
582
+ if (health.type === "http") {
583
+ try {
584
+ const url = new URL(health.url);
585
+ url.port = String(resolvedPort);
586
+ return { ...health, url: url.toString() };
587
+ } catch {
588
+ return health;
589
+ }
590
+ }
591
+ return health;
592
+ }
593
+ function buildServiceEnv(config, serviceName, userEnv) {
594
+ const resolvedPort = getResolvedPort(config, serviceName);
595
+ const env = {};
596
+ if (resolvedPort !== void 0) {
597
+ env.PORT = String(resolvedPort);
598
+ env.DEVMUX_PORT = String(resolvedPort);
599
+ }
600
+ if (config.instanceId) {
601
+ env.DEVMUX_INSTANCE_ID = config.instanceId;
602
+ }
603
+ env.DEVMUX_SERVICE = serviceName;
604
+ env.DEVMUX_PROJECT = config.project;
605
+ if (userEnv) {
606
+ for (const [key, value] of Object.entries(userEnv)) {
607
+ env[key] = value.replace(/\{\{PORT\}\}/g, String(resolvedPort ?? "")).replace(/\{\{INSTANCE\}\}/g, config.instanceId).replace(/\{\{SERVICE\}\}/g, serviceName).replace(/\{\{PROJECT\}\}/g, config.project);
608
+ }
609
+ }
610
+ return env;
611
+ }
612
+
613
+ export {
614
+ loadConfig,
615
+ getSessionName,
616
+ getServiceCwd,
617
+ loader_exports,
618
+ init_loader,
619
+ hasSession,
620
+ driver_exports,
621
+ checkHealth,
622
+ checkers_exports,
623
+ getProcessOnPort,
624
+ ensureService,
625
+ getStatus,
626
+ getAllStatus,
627
+ stopService,
628
+ stopAllServices,
629
+ restartService,
630
+ attachService
631
+ };