@chriscode/devmux 1.0.0 → 1.2.0

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.
@@ -1,542 +0,0 @@
1
- var __defProp = Object.defineProperty;
2
- var __export = (target, all) => {
3
- for (var name in all)
4
- __defProp(target, name, { get: all[name], enumerable: true });
5
- };
6
-
7
- // src/config/loader.ts
8
- import { readFileSync, existsSync } from "fs";
9
- import { resolve, dirname } from "path";
10
- var CONFIG_NAMES = [
11
- "devmux.config.json",
12
- ".devmuxrc.json",
13
- ".devmuxrc"
14
- ];
15
- function findConfigFile(startDir) {
16
- let dir = resolve(startDir);
17
- const root = dirname(dir);
18
- while (dir !== root) {
19
- for (const name of CONFIG_NAMES) {
20
- const configPath = resolve(dir, name);
21
- if (existsSync(configPath)) {
22
- return configPath;
23
- }
24
- }
25
- const pkgPath = resolve(dir, "package.json");
26
- if (existsSync(pkgPath)) {
27
- try {
28
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
29
- if (pkg.devmux) {
30
- return pkgPath;
31
- }
32
- } catch {
33
- }
34
- }
35
- dir = dirname(dir);
36
- }
37
- return null;
38
- }
39
- function loadConfigFromFile(configPath) {
40
- const content = readFileSync(configPath, "utf-8");
41
- if (configPath.endsWith("package.json")) {
42
- const pkg = JSON.parse(content);
43
- return pkg.devmux;
44
- }
45
- return JSON.parse(content);
46
- }
47
- function validateConfig(config) {
48
- if (!config || typeof config !== "object") return false;
49
- const c = config;
50
- if (c.version !== 1) return false;
51
- if (typeof c.project !== "string") return false;
52
- if (!c.services || typeof c.services !== "object") return false;
53
- return true;
54
- }
55
- function loadConfig(startDir = process.cwd()) {
56
- const configPath = findConfigFile(startDir);
57
- if (!configPath) {
58
- throw new Error(
59
- "No devmux config found. Create devmux.config.json or add 'devmux' to package.json"
60
- );
61
- }
62
- const config = loadConfigFromFile(configPath);
63
- if (!validateConfig(config)) {
64
- throw new Error(`Invalid devmux config in ${configPath}`);
65
- }
66
- const configRoot = dirname(configPath);
67
- const resolvedSessionPrefix = config.sessionPrefix ?? `omo-${config.project}`;
68
- return {
69
- ...config,
70
- configRoot,
71
- resolvedSessionPrefix
72
- };
73
- }
74
- function getSessionName(config, serviceName) {
75
- const service = config.services[serviceName];
76
- if (service?.sessionName) {
77
- return service.sessionName;
78
- }
79
- return `${config.resolvedSessionPrefix}-${serviceName}`;
80
- }
81
- function getServiceCwd(config, serviceName) {
82
- const service = config.services[serviceName];
83
- if (!service) {
84
- throw new Error(`Unknown service: ${serviceName}`);
85
- }
86
- return resolve(config.configRoot, service.cwd);
87
- }
88
-
89
- // src/tmux/driver.ts
90
- var driver_exports = {};
91
- __export(driver_exports, {
92
- attachSession: () => attachSession,
93
- hasSession: () => hasSession,
94
- killSession: () => killSession,
95
- listSessions: () => listSessions,
96
- newSession: () => newSession,
97
- setRemainOnExit: () => setRemainOnExit
98
- });
99
- import { execSync, spawn } from "child_process";
100
- function hasSession(sessionName) {
101
- try {
102
- execSync(`tmux has-session -t ${sessionName}`, {
103
- stdio: ["pipe", "pipe", "pipe"]
104
- });
105
- return true;
106
- } catch {
107
- return false;
108
- }
109
- }
110
- function listSessions(prefix) {
111
- try {
112
- const output = execSync("tmux list-sessions -F #{session_name}", {
113
- encoding: "utf-8",
114
- stdio: ["pipe", "pipe", "pipe"]
115
- });
116
- const sessions = output.trim().split("\n").filter(Boolean);
117
- if (prefix) {
118
- return sessions.filter((s) => s.startsWith(prefix));
119
- }
120
- return sessions;
121
- } catch {
122
- return [];
123
- }
124
- }
125
- function newSession(sessionName, cwd, command, env) {
126
- const envPrefix = env ? Object.entries(env).map(([k, v]) => `${k}=${v}`).join(" ") + " " : "";
127
- execSync(
128
- `tmux new-session -d -s "${sessionName}" -c "${cwd}" "${envPrefix}${command}"`,
129
- { stdio: ["pipe", "pipe", "pipe"] }
130
- );
131
- }
132
- function setRemainOnExit(sessionName, value) {
133
- try {
134
- execSync(
135
- `tmux set-option -t "${sessionName}" remain-on-exit ${value ? "on" : "off"}`,
136
- { stdio: ["pipe", "pipe", "pipe"] }
137
- );
138
- } catch {
139
- }
140
- }
141
- function killSession(sessionName) {
142
- try {
143
- execSync(`tmux kill-session -t "${sessionName}"`, {
144
- stdio: ["pipe", "pipe", "pipe"]
145
- });
146
- } catch {
147
- }
148
- }
149
- function attachSession(sessionName) {
150
- const child = spawn("tmux", ["attach", "-t", sessionName], {
151
- stdio: "inherit"
152
- });
153
- child.on("error", () => {
154
- });
155
- }
156
-
157
- // src/health/checkers.ts
158
- var checkers_exports = {};
159
- __export(checkers_exports, {
160
- checkHealth: () => checkHealth,
161
- checkHttp: () => checkHttp,
162
- checkPort: () => checkPort,
163
- getHealthPort: () => getHealthPort
164
- });
165
- import { createConnection } from "net";
166
- function checkPort(port, host = "127.0.0.1") {
167
- return new Promise((resolve3) => {
168
- const socket = createConnection({ port, host });
169
- socket.setTimeout(1e3);
170
- socket.on("connect", () => {
171
- socket.destroy();
172
- resolve3(true);
173
- });
174
- socket.on("timeout", () => {
175
- socket.destroy();
176
- resolve3(false);
177
- });
178
- socket.on("error", () => {
179
- socket.destroy();
180
- resolve3(false);
181
- });
182
- });
183
- }
184
- async function checkHttp(url, expectStatus = 200) {
185
- try {
186
- const response = await fetch(url, {
187
- method: "GET",
188
- signal: AbortSignal.timeout(5e3)
189
- });
190
- if (expectStatus === 200) {
191
- return response.ok || response.status === 404;
192
- }
193
- return response.status === expectStatus;
194
- } catch {
195
- return false;
196
- }
197
- }
198
- async function checkHealth(health) {
199
- switch (health.type) {
200
- case "port":
201
- return checkPort(health.port, health.host);
202
- case "http":
203
- return checkHttp(health.url, health.expectStatus);
204
- case "none":
205
- return false;
206
- }
207
- }
208
- function getHealthPort(health) {
209
- if (health.type === "port") return health.port;
210
- if (health.type === "http") {
211
- try {
212
- const url = new URL(health.url);
213
- return parseInt(url.port) || (url.protocol === "https:" ? 443 : 80);
214
- } catch {
215
- return void 0;
216
- }
217
- }
218
- return void 0;
219
- }
220
-
221
- // src/core/service.ts
222
- import { execSync as execSync2 } from "child_process";
223
-
224
- // src/utils/lock.ts
225
- import { mkdirSync, rmdirSync, existsSync as existsSync2 } from "fs";
226
- import { tmpdir } from "os";
227
- import { join } from "path";
228
- function acquireLock(name) {
229
- const lockDir = join(tmpdir(), `${name}.lock`);
230
- try {
231
- mkdirSync(lockDir);
232
- return true;
233
- } catch {
234
- return false;
235
- }
236
- }
237
- function releaseLock(name) {
238
- const lockDir = join(tmpdir(), `${name}.lock`);
239
- try {
240
- rmdirSync(lockDir);
241
- } catch {
242
- }
243
- }
244
-
245
- // src/core/service.ts
246
- async function ensureService(config, serviceName, options = {}) {
247
- const service = config.services[serviceName];
248
- if (!service) {
249
- throw new Error(`Unknown service: ${serviceName}`);
250
- }
251
- const sessionName = getSessionName(config, serviceName);
252
- const cwd = getServiceCwd(config, serviceName);
253
- const timeout = options.timeout ?? config.defaults?.startupTimeoutSeconds ?? 30;
254
- const log = options.quiet ? () => {
255
- } : console.log;
256
- const isHealthy = await checkHealth(service.health);
257
- if (isHealthy) {
258
- const hasTmux = hasSession(sessionName);
259
- log(`\u2705 ${serviceName} already running`);
260
- if (hasTmux) {
261
- log(` \u2514\u2500 tmux session: ${sessionName}`);
262
- } else {
263
- log(` \u2514\u2500 (running outside tmux)`);
264
- }
265
- return { serviceName, startedByUs: false, sessionName };
266
- }
267
- if (!acquireLock(sessionName)) {
268
- log(`\u23F3 Another process is starting ${serviceName}, waiting...`);
269
- for (let i = 0; i < 10; i++) {
270
- await sleep(1e3);
271
- if (await checkHealth(service.health)) {
272
- log(`\u2705 ${serviceName} now running`);
273
- return { serviceName, startedByUs: false, sessionName };
274
- }
275
- }
276
- throw new Error(`${serviceName} failed to start (locked by another process)`);
277
- }
278
- try {
279
- if (hasSession(sessionName)) {
280
- log(`\u{1F504} Cleaning up stale session: ${sessionName}`);
281
- killSession(sessionName);
282
- }
283
- log(`\u{1F680} Starting ${serviceName} in tmux session: ${sessionName}`);
284
- newSession(sessionName, cwd, service.command, service.env);
285
- const remainOnExit = config.defaults?.remainOnExit ?? true;
286
- setRemainOnExit(sessionName, remainOnExit);
287
- log(`\u23F3 Waiting for ${serviceName} to be ready...`);
288
- for (let i = 0; i < timeout; i++) {
289
- if (await checkHealth(service.health)) {
290
- log(`\u2705 ${serviceName} ready`);
291
- log(` \u2514\u2500 tmux session: ${sessionName}`);
292
- return { serviceName, startedByUs: true, sessionName };
293
- }
294
- await sleep(1e3);
295
- }
296
- throw new Error(`${serviceName} failed to start within ${timeout}s`);
297
- } finally {
298
- releaseLock(sessionName);
299
- }
300
- }
301
- async function getStatus(config, serviceName) {
302
- const service = config.services[serviceName];
303
- if (!service) {
304
- throw new Error(`Unknown service: ${serviceName}`);
305
- }
306
- const sessionName = getSessionName(config, serviceName);
307
- const healthy = await checkHealth(service.health);
308
- const hasTmux = hasSession(sessionName);
309
- return {
310
- name: serviceName,
311
- healthy,
312
- tmuxSession: hasTmux ? sessionName : null,
313
- port: getHealthPort(service.health),
314
- managedByDevmux: hasTmux
315
- };
316
- }
317
- async function getAllStatus(config) {
318
- const statuses = [];
319
- for (const serviceName of Object.keys(config.services)) {
320
- statuses.push(await getStatus(config, serviceName));
321
- }
322
- return statuses;
323
- }
324
- function stopService(config, serviceName, options = {}) {
325
- const service = config.services[serviceName];
326
- if (!service) {
327
- throw new Error(`Unknown service: ${serviceName}`);
328
- }
329
- const sessionName = getSessionName(config, serviceName);
330
- const log = options.quiet ? () => {
331
- } : console.log;
332
- log(`\u{1F6D1} Stopping ${serviceName}...`);
333
- if (hasSession(sessionName)) {
334
- killSession(sessionName);
335
- log(` \u2514\u2500 Killed tmux session: ${sessionName}`);
336
- }
337
- if (options.killPorts) {
338
- const ports = service.stopPorts ?? [];
339
- const healthPort = getHealthPort(service.health);
340
- if (healthPort) ports.push(healthPort);
341
- for (const port of [...new Set(ports)]) {
342
- try {
343
- const pids = execSync2(`lsof -ti :${port}`, { encoding: "utf-8" }).trim();
344
- if (pids) {
345
- execSync2(`kill -9 ${pids.split("\n").join(" ")}`, { stdio: "pipe" });
346
- log(` \u2514\u2500 Killed process(es) on port ${port}`);
347
- }
348
- } catch {
349
- }
350
- }
351
- }
352
- log(`\u2705 ${serviceName} stopped`);
353
- }
354
- function stopAllServices(config, options = {}) {
355
- for (const serviceName of Object.keys(config.services)) {
356
- stopService(config, serviceName, options);
357
- }
358
- }
359
- function attachService(config, serviceName) {
360
- const service = config.services[serviceName];
361
- if (!service) {
362
- throw new Error(`Unknown service: ${serviceName}`);
363
- }
364
- const sessionName = getSessionName(config, serviceName);
365
- if (!hasSession(sessionName)) {
366
- throw new Error(`No tmux session for ${serviceName}. Service may not be running or was started outside tmux.`);
367
- }
368
- console.log(`\u{1F4CE} Attaching to ${sessionName}...`);
369
- console.log(` (detach with Ctrl+B, then D)`);
370
- attachSession(sessionName);
371
- }
372
- function sleep(ms) {
373
- return new Promise((resolve3) => setTimeout(resolve3, ms));
374
- }
375
-
376
- // src/core/run.ts
377
- import { spawn as spawn2 } from "child_process";
378
- async function runWithServices(config, command, options) {
379
- const { services, stopOnExit = true, quiet = false } = options;
380
- const log = quiet ? () => {
381
- } : console.log;
382
- const startedByUs = [];
383
- for (const serviceName of services) {
384
- const service = config.services[serviceName];
385
- if (!service) {
386
- console.error(`\u274C Unknown service: ${serviceName}`);
387
- process.exit(1);
388
- }
389
- const wasHealthy = await checkHealth(service.health);
390
- if (wasHealthy) {
391
- log(`\u2705 ${serviceName} already running (will keep on exit)`);
392
- } else {
393
- const result = await ensureService(config, serviceName, { quiet });
394
- if (result.startedByUs) {
395
- startedByUs.push(result);
396
- log(` (will stop on Ctrl+C)`);
397
- }
398
- }
399
- }
400
- log("");
401
- const cleanup = () => {
402
- if (stopOnExit && startedByUs.length > 0) {
403
- log("");
404
- log("\u{1F9F9} Cleaning up services we started...");
405
- for (const result of startedByUs) {
406
- stopService(config, result.serviceName, { killPorts: true, quiet: true });
407
- log(` \u2514\u2500 Stopped ${result.serviceName}`);
408
- }
409
- }
410
- };
411
- process.on("SIGINT", () => {
412
- cleanup();
413
- process.exit(130);
414
- });
415
- process.on("SIGTERM", () => {
416
- cleanup();
417
- process.exit(143);
418
- });
419
- process.on("exit", cleanup);
420
- const [cmd, ...args] = command;
421
- const child = spawn2(cmd, args, {
422
- stdio: "inherit",
423
- shell: true
424
- });
425
- return new Promise((resolve3) => {
426
- child.on("close", (code) => {
427
- resolve3(code ?? 0);
428
- });
429
- child.on("error", (err) => {
430
- console.error(`Failed to run command: ${err.message}`);
431
- resolve3(1);
432
- });
433
- });
434
- }
435
-
436
- // src/discovery/turbo.ts
437
- import { readFileSync as readFileSync2, existsSync as existsSync3 } from "fs";
438
- import { resolve as resolve2, relative } from "path";
439
- function loadTurboConfig(root) {
440
- const turboPath = resolve2(root, "turbo.json");
441
- if (!existsSync3(turboPath)) return null;
442
- try {
443
- return JSON.parse(readFileSync2(turboPath, "utf-8"));
444
- } catch {
445
- return null;
446
- }
447
- }
448
- function getPersistentTasks(turbo) {
449
- const tasks = turbo.tasks ?? turbo.pipeline ?? {};
450
- return Object.entries(tasks).filter(([_, task]) => task.persistent).map(([name]) => name.replace(/^\/\/#/, ""));
451
- }
452
- function findWorkspacePackages(root) {
453
- const packages = [];
454
- const rootPkg = resolve2(root, "package.json");
455
- if (!existsSync3(rootPkg)) return packages;
456
- try {
457
- const pkg = JSON.parse(
458
- readFileSync2(rootPkg, "utf-8")
459
- );
460
- const workspaces = pkg.workspaces ?? [];
461
- for (const pattern of workspaces) {
462
- const cleanPattern = pattern.replace(/\/\*$/, "");
463
- const pkgPath = resolve2(root, cleanPattern, "package.json");
464
- if (existsSync3(pkgPath)) {
465
- const subPkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
466
- packages.push({
467
- name: subPkg.name ?? cleanPattern,
468
- path: relative(root, resolve2(root, cleanPattern)) || ".",
469
- scripts: Object.keys(subPkg.scripts ?? {})
470
- });
471
- }
472
- }
473
- for (const subdir of ["app", "api", "web", "packages", "apps"]) {
474
- const pkgPath = resolve2(root, subdir, "package.json");
475
- if (existsSync3(pkgPath) && !packages.some((p) => p.path === subdir)) {
476
- const subPkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
477
- packages.push({
478
- name: subPkg.name ?? subdir,
479
- path: subdir,
480
- scripts: Object.keys(subPkg.scripts ?? {})
481
- });
482
- }
483
- }
484
- } catch {
485
- }
486
- return packages;
487
- }
488
- function discoverFromTurbo(root) {
489
- const turbo = loadTurboConfig(root);
490
- if (!turbo) return null;
491
- const persistentTasks = getPersistentTasks(turbo);
492
- if (persistentTasks.length === 0) return null;
493
- const packages = findWorkspacePackages(root);
494
- const services = {};
495
- for (const pkg of packages) {
496
- for (const task of persistentTasks) {
497
- if (pkg.scripts.includes(task)) {
498
- const serviceName = pkg.path === "." ? task : `${pkg.path.replace(/\//g, "-")}-${task}`;
499
- services[serviceName] = {
500
- cwd: pkg.path,
501
- command: `pnpm ${task}`,
502
- health: { type: "none" }
503
- };
504
- }
505
- }
506
- }
507
- if (Object.keys(services).length === 0) return null;
508
- return {
509
- version: 1,
510
- project: "my-project",
511
- services
512
- };
513
- }
514
- function formatDiscoveredConfig(config) {
515
- const lines = [
516
- "# Discovered from turbo.json",
517
- "# Review and update:",
518
- "# 1. Set 'project' name",
519
- "# 2. Add health checks (port or http) for each service",
520
- "# 3. Remove services you don't want to manage",
521
- "",
522
- JSON.stringify(config, null, 2)
523
- ];
524
- return lines.join("\n");
525
- }
526
-
527
- export {
528
- loadConfig,
529
- getSessionName,
530
- getServiceCwd,
531
- driver_exports,
532
- checkers_exports,
533
- ensureService,
534
- getStatus,
535
- getAllStatus,
536
- stopService,
537
- stopAllServices,
538
- attachService,
539
- runWithServices,
540
- discoverFromTurbo,
541
- formatDiscoveredConfig
542
- };