@chriscode/devmux 1.0.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.
- package/bin/devmux.js +2 -0
- package/dist/chunk-7JJYYMUP.js +542 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +162 -0
- package/dist/index.d.ts +107 -0
- package/dist/index.js +32 -0
- package/package.json +62 -0
package/bin/devmux.js
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
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
|
+
};
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
attachService,
|
|
4
|
+
discoverFromTurbo,
|
|
5
|
+
ensureService,
|
|
6
|
+
formatDiscoveredConfig,
|
|
7
|
+
getAllStatus,
|
|
8
|
+
loadConfig,
|
|
9
|
+
runWithServices,
|
|
10
|
+
stopAllServices,
|
|
11
|
+
stopService
|
|
12
|
+
} from "./chunk-7JJYYMUP.js";
|
|
13
|
+
|
|
14
|
+
// src/cli.ts
|
|
15
|
+
import { defineCommand, runMain } from "citty";
|
|
16
|
+
var ensure = defineCommand({
|
|
17
|
+
meta: { name: "ensure", description: "Ensure a service is running (idempotent)" },
|
|
18
|
+
args: {
|
|
19
|
+
service: { type: "positional", description: "Service name", required: true },
|
|
20
|
+
timeout: { type: "string", description: "Startup timeout in seconds" }
|
|
21
|
+
},
|
|
22
|
+
async run({ args }) {
|
|
23
|
+
const config = loadConfig();
|
|
24
|
+
await ensureService(config, args.service, {
|
|
25
|
+
timeout: args.timeout ? parseInt(args.timeout) : void 0
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
var status = defineCommand({
|
|
30
|
+
meta: { name: "status", description: "Show status of all services" },
|
|
31
|
+
args: {
|
|
32
|
+
json: { type: "boolean", description: "Output as JSON" }
|
|
33
|
+
},
|
|
34
|
+
async run({ args }) {
|
|
35
|
+
const config = loadConfig();
|
|
36
|
+
const statuses = await getAllStatus(config);
|
|
37
|
+
if (args.json) {
|
|
38
|
+
console.log(JSON.stringify(statuses, null, 2));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
42
|
+
console.log(" Service Status");
|
|
43
|
+
console.log("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
44
|
+
console.log("");
|
|
45
|
+
for (const s of statuses) {
|
|
46
|
+
const icon = s.healthy ? "\u2705" : "\u274C";
|
|
47
|
+
const portInfo = s.port ? ` (port ${s.port})` : "";
|
|
48
|
+
console.log(`${icon} ${s.name}${portInfo}: ${s.healthy ? "Running" : "Not running"}`);
|
|
49
|
+
if (s.tmuxSession) {
|
|
50
|
+
console.log(` \u2514\u2500 tmux: ${s.tmuxSession}`);
|
|
51
|
+
} else if (s.healthy) {
|
|
52
|
+
console.log(` \u2514\u2500 (running outside tmux)`);
|
|
53
|
+
}
|
|
54
|
+
console.log("");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
var stop = defineCommand({
|
|
59
|
+
meta: { name: "stop", description: "Stop a service or all services" },
|
|
60
|
+
args: {
|
|
61
|
+
service: { type: "positional", description: "Service name or 'all'" },
|
|
62
|
+
force: { type: "boolean", description: "Also kill processes on ports" }
|
|
63
|
+
},
|
|
64
|
+
run({ args }) {
|
|
65
|
+
const config = loadConfig();
|
|
66
|
+
const serviceName = args.service ?? "all";
|
|
67
|
+
if (serviceName === "all") {
|
|
68
|
+
stopAllServices(config, { killPorts: args.force });
|
|
69
|
+
} else {
|
|
70
|
+
stopService(config, serviceName, { killPorts: args.force });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
var attach = defineCommand({
|
|
75
|
+
meta: { name: "attach", description: "Attach to a service's tmux session" },
|
|
76
|
+
args: {
|
|
77
|
+
service: { type: "positional", description: "Service name", required: true }
|
|
78
|
+
},
|
|
79
|
+
run({ args }) {
|
|
80
|
+
const config = loadConfig();
|
|
81
|
+
attachService(config, args.service);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
var run = defineCommand({
|
|
85
|
+
meta: { name: "run", description: "Run a command with services, cleanup on exit" },
|
|
86
|
+
args: {
|
|
87
|
+
with: { type: "string", description: "Comma-separated services to ensure", required: true },
|
|
88
|
+
"no-stop": { type: "boolean", description: "Don't stop services on exit" },
|
|
89
|
+
_: { type: "positional", description: "Command to run" }
|
|
90
|
+
},
|
|
91
|
+
async run({ args }) {
|
|
92
|
+
const config = loadConfig();
|
|
93
|
+
const services = args.with.split(",").map((s) => s.trim());
|
|
94
|
+
const command = args._;
|
|
95
|
+
if (!command || command.length === 0) {
|
|
96
|
+
console.error("\u274C No command specified");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
const exitCode = await runWithServices(config, command, {
|
|
100
|
+
services,
|
|
101
|
+
stopOnExit: !args["no-stop"]
|
|
102
|
+
});
|
|
103
|
+
process.exit(exitCode);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
var discover = defineCommand({
|
|
107
|
+
meta: { name: "discover", description: "Discover services from turbo.json" },
|
|
108
|
+
args: {
|
|
109
|
+
source: { type: "positional", description: "Source to discover from (turbo)", default: "turbo" }
|
|
110
|
+
},
|
|
111
|
+
run({ args }) {
|
|
112
|
+
if (args.source !== "turbo") {
|
|
113
|
+
console.error(`\u274C Unknown source: ${args.source}. Supported: turbo`);
|
|
114
|
+
process.exit(1);
|
|
115
|
+
}
|
|
116
|
+
const discovered = discoverFromTurbo(process.cwd());
|
|
117
|
+
if (!discovered) {
|
|
118
|
+
console.error("\u274C No services discovered. Make sure turbo.json exists with persistent tasks.");
|
|
119
|
+
process.exit(1);
|
|
120
|
+
}
|
|
121
|
+
console.log(formatDiscoveredConfig(discovered));
|
|
122
|
+
console.log("");
|
|
123
|
+
console.log("Save this as devmux.config.json and update the health checks.");
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
var init = defineCommand({
|
|
127
|
+
meta: { name: "init", description: "Initialize devmux config" },
|
|
128
|
+
run() {
|
|
129
|
+
const template = {
|
|
130
|
+
version: 1,
|
|
131
|
+
project: "my-project",
|
|
132
|
+
services: {
|
|
133
|
+
api: {
|
|
134
|
+
cwd: "api",
|
|
135
|
+
command: "pnpm dev",
|
|
136
|
+
health: { type: "port", port: 8787 }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
console.log("# devmux.config.json template");
|
|
141
|
+
console.log(JSON.stringify(template, null, 2));
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log("Save this as devmux.config.json in your project root.");
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
var main = defineCommand({
|
|
147
|
+
meta: {
|
|
148
|
+
name: "devmux",
|
|
149
|
+
version: "0.1.0",
|
|
150
|
+
description: "tmux-based service management for monorepos"
|
|
151
|
+
},
|
|
152
|
+
subCommands: {
|
|
153
|
+
ensure,
|
|
154
|
+
status,
|
|
155
|
+
stop,
|
|
156
|
+
attach,
|
|
157
|
+
run,
|
|
158
|
+
discover,
|
|
159
|
+
init
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
runMain(main);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
type HealthCheckType = {
|
|
2
|
+
type: "port";
|
|
3
|
+
port: number;
|
|
4
|
+
host?: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: "http";
|
|
7
|
+
url: string;
|
|
8
|
+
expectStatus?: number;
|
|
9
|
+
} | {
|
|
10
|
+
type: "none";
|
|
11
|
+
};
|
|
12
|
+
interface ServiceDefinition {
|
|
13
|
+
cwd: string;
|
|
14
|
+
command: string;
|
|
15
|
+
health: HealthCheckType;
|
|
16
|
+
sessionName?: string;
|
|
17
|
+
env?: Record<string, string>;
|
|
18
|
+
stopPorts?: number[];
|
|
19
|
+
}
|
|
20
|
+
interface DevMuxConfig {
|
|
21
|
+
version: 1;
|
|
22
|
+
project: string;
|
|
23
|
+
sessionPrefix?: string;
|
|
24
|
+
defaults?: {
|
|
25
|
+
startupTimeoutSeconds?: number;
|
|
26
|
+
remainOnExit?: boolean;
|
|
27
|
+
};
|
|
28
|
+
services: Record<string, ServiceDefinition>;
|
|
29
|
+
}
|
|
30
|
+
interface ResolvedConfig extends DevMuxConfig {
|
|
31
|
+
configRoot: string;
|
|
32
|
+
resolvedSessionPrefix: string;
|
|
33
|
+
}
|
|
34
|
+
interface ServiceStatus {
|
|
35
|
+
name: string;
|
|
36
|
+
healthy: boolean;
|
|
37
|
+
tmuxSession: string | null;
|
|
38
|
+
port?: number;
|
|
39
|
+
managedByDevmux: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
declare function loadConfig(startDir?: string): ResolvedConfig;
|
|
43
|
+
declare function getSessionName(config: ResolvedConfig, serviceName: string): string;
|
|
44
|
+
declare function getServiceCwd(config: ResolvedConfig, serviceName: string): string;
|
|
45
|
+
|
|
46
|
+
interface EnsureResult {
|
|
47
|
+
serviceName: string;
|
|
48
|
+
startedByUs: boolean;
|
|
49
|
+
sessionName: string;
|
|
50
|
+
}
|
|
51
|
+
declare function ensureService(config: ResolvedConfig, serviceName: string, options?: {
|
|
52
|
+
timeout?: number;
|
|
53
|
+
quiet?: boolean;
|
|
54
|
+
}): Promise<EnsureResult>;
|
|
55
|
+
declare function getStatus(config: ResolvedConfig, serviceName: string): Promise<ServiceStatus>;
|
|
56
|
+
declare function getAllStatus(config: ResolvedConfig): Promise<ServiceStatus[]>;
|
|
57
|
+
declare function stopService(config: ResolvedConfig, serviceName: string, options?: {
|
|
58
|
+
killPorts?: boolean;
|
|
59
|
+
quiet?: boolean;
|
|
60
|
+
}): void;
|
|
61
|
+
declare function stopAllServices(config: ResolvedConfig, options?: {
|
|
62
|
+
killPorts?: boolean;
|
|
63
|
+
quiet?: boolean;
|
|
64
|
+
}): void;
|
|
65
|
+
declare function attachService(config: ResolvedConfig, serviceName: string): void;
|
|
66
|
+
|
|
67
|
+
interface RunOptions {
|
|
68
|
+
services: string[];
|
|
69
|
+
stopOnExit?: boolean;
|
|
70
|
+
quiet?: boolean;
|
|
71
|
+
}
|
|
72
|
+
declare function runWithServices(config: ResolvedConfig, command: string[], options: RunOptions): Promise<number>;
|
|
73
|
+
|
|
74
|
+
declare function discoverFromTurbo(root: string): Partial<DevMuxConfig> | null;
|
|
75
|
+
declare function formatDiscoveredConfig(config: Partial<DevMuxConfig>): string;
|
|
76
|
+
|
|
77
|
+
declare function hasSession(sessionName: string): boolean;
|
|
78
|
+
declare function listSessions(prefix?: string): string[];
|
|
79
|
+
declare function newSession(sessionName: string, cwd: string, command: string, env?: Record<string, string>): void;
|
|
80
|
+
declare function setRemainOnExit(sessionName: string, value: boolean): void;
|
|
81
|
+
declare function killSession(sessionName: string): void;
|
|
82
|
+
declare function attachSession(sessionName: string): void;
|
|
83
|
+
|
|
84
|
+
declare const driver_attachSession: typeof attachSession;
|
|
85
|
+
declare const driver_hasSession: typeof hasSession;
|
|
86
|
+
declare const driver_killSession: typeof killSession;
|
|
87
|
+
declare const driver_listSessions: typeof listSessions;
|
|
88
|
+
declare const driver_newSession: typeof newSession;
|
|
89
|
+
declare const driver_setRemainOnExit: typeof setRemainOnExit;
|
|
90
|
+
declare namespace driver {
|
|
91
|
+
export { driver_attachSession as attachSession, driver_hasSession as hasSession, driver_killSession as killSession, driver_listSessions as listSessions, driver_newSession as newSession, driver_setRemainOnExit as setRemainOnExit };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
declare function checkPort(port: number, host?: string): Promise<boolean>;
|
|
95
|
+
declare function checkHttp(url: string, expectStatus?: number): Promise<boolean>;
|
|
96
|
+
declare function checkHealth(health: HealthCheckType): Promise<boolean>;
|
|
97
|
+
declare function getHealthPort(health: HealthCheckType): number | undefined;
|
|
98
|
+
|
|
99
|
+
declare const checkers_checkHealth: typeof checkHealth;
|
|
100
|
+
declare const checkers_checkHttp: typeof checkHttp;
|
|
101
|
+
declare const checkers_checkPort: typeof checkPort;
|
|
102
|
+
declare const checkers_getHealthPort: typeof getHealthPort;
|
|
103
|
+
declare namespace checkers {
|
|
104
|
+
export { checkers_checkHealth as checkHealth, checkers_checkHttp as checkHttp, checkers_checkPort as checkPort, checkers_getHealthPort as getHealthPort };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { type DevMuxConfig, type EnsureResult, type HealthCheckType, type ResolvedConfig, type RunOptions, type ServiceDefinition, type ServiceStatus, attachService, discoverFromTurbo, ensureService, formatDiscoveredConfig, getAllStatus, getServiceCwd, getSessionName, getStatus, checkers as health, loadConfig, runWithServices, stopAllServices, stopService, driver as tmux };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
attachService,
|
|
3
|
+
checkers_exports,
|
|
4
|
+
discoverFromTurbo,
|
|
5
|
+
driver_exports,
|
|
6
|
+
ensureService,
|
|
7
|
+
formatDiscoveredConfig,
|
|
8
|
+
getAllStatus,
|
|
9
|
+
getServiceCwd,
|
|
10
|
+
getSessionName,
|
|
11
|
+
getStatus,
|
|
12
|
+
loadConfig,
|
|
13
|
+
runWithServices,
|
|
14
|
+
stopAllServices,
|
|
15
|
+
stopService
|
|
16
|
+
} from "./chunk-7JJYYMUP.js";
|
|
17
|
+
export {
|
|
18
|
+
attachService,
|
|
19
|
+
discoverFromTurbo,
|
|
20
|
+
ensureService,
|
|
21
|
+
formatDiscoveredConfig,
|
|
22
|
+
getAllStatus,
|
|
23
|
+
getServiceCwd,
|
|
24
|
+
getSessionName,
|
|
25
|
+
getStatus,
|
|
26
|
+
checkers_exports as health,
|
|
27
|
+
loadConfig,
|
|
28
|
+
runWithServices,
|
|
29
|
+
stopAllServices,
|
|
30
|
+
stopService,
|
|
31
|
+
driver_exports as tmux
|
|
32
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chriscode/devmux",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "tmux-based service management for monorepos with human-agent shared awareness",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"devmux": "./bin/devmux.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup",
|
|
17
|
+
"dev": "tsup --watch",
|
|
18
|
+
"prepublishOnly": "pnpm build",
|
|
19
|
+
"type-check": "tsc --noEmit",
|
|
20
|
+
"test": "echo 'No tests yet' && exit 0"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"tmux",
|
|
24
|
+
"monorepo",
|
|
25
|
+
"dev",
|
|
26
|
+
"services",
|
|
27
|
+
"turbo",
|
|
28
|
+
"agent",
|
|
29
|
+
"opencode",
|
|
30
|
+
"claude",
|
|
31
|
+
"ai"
|
|
32
|
+
],
|
|
33
|
+
"author": "Chris Hasson",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/hassoncs/devmux.git",
|
|
38
|
+
"directory": "devmux-cli"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/hassoncs/devmux/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://github.com/hassoncs/devmux#readme",
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"citty": "^0.1.6"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^20.0.0",
|
|
52
|
+
"tsup": "^8.0.0",
|
|
53
|
+
"typescript": "^5.0.0"
|
|
54
|
+
},
|
|
55
|
+
"files": [
|
|
56
|
+
"dist",
|
|
57
|
+
"bin"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"access": "public"
|
|
61
|
+
}
|
|
62
|
+
}
|