@gachlab/devup 0.8.1 → 0.9.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/CHANGELOG.md +27 -0
- package/README.md +1 -0
- package/dist/config/cli.d.ts +2 -1
- package/dist/config/cli.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +153 -1
- package/dist/index.js.map +1 -1
- package/dist/process/port-conflicts.d.ts +33 -0
- package/dist/process/port-conflicts.d.ts.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ All notable changes to `@gachlab/devup` are documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.9.0] — 2026-05-22
|
|
9
|
+
|
|
10
|
+
Pre-boot port-conflict resolution. When devup detects another process already holding a port it needs, it now offers to take it over instead of silently marking the service as crashed.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Pre-boot port conflict scan.** Before mounting the TUI or starting the daemon, devup checks every API-typed service's port. Any conflict is shown to the user with the PID and process name (via `lsof`):
|
|
14
|
+
```
|
|
15
|
+
⚠ Port conflicts detected on the following services:
|
|
16
|
+
|
|
17
|
+
:3002 authorization-api pid=12345 process=node
|
|
18
|
+
:3013 files-api pid=99999 process=docker-proxy
|
|
19
|
+
|
|
20
|
+
Kill these processes and continue? [y/N]:
|
|
21
|
+
```
|
|
22
|
+
Three flavours of resolution:
|
|
23
|
+
- **Interactive TTY** → y/N prompt (default).
|
|
24
|
+
- **`--kill-port-conflicts` flag** → auto-kill, no prompt. Required for `devup up -d`, `--once`, and any non-TTY context (CI).
|
|
25
|
+
- **Non-interactive without the flag** → fail fast with the conflict list as instructions.
|
|
26
|
+
- **`devup up -d` runs the scan in the parent** before forking the daemon child, so the user gets the prompt (or the error) in their terminal, not buried in `~/.devup/<project>.boot-error`.
|
|
27
|
+
|
|
28
|
+
### Internals
|
|
29
|
+
- New `src/process/port-conflicts.ts` houses the scan, the lsof-based holder lookup (`findPortHolder`), the resolution flow (`resolvePortConflicts`), and the SIGTERM-then-SIGKILL helper (`killHolder`). Linux + macOS only — Windows holder detection (netstat / Get-NetTCPConnection) is deferred until daemon mode itself supports Windows.
|
|
30
|
+
- Test suite: 372 → 384 (+12). New: 4 scan tests, 2 killHolder tests, 4 resolution-flow tests, 1 CLI flag test, 1 lsof-output parser test.
|
|
31
|
+
|
|
32
|
+
### Notes
|
|
33
|
+
- Webs are intentionally skipped from the scan — their dev servers (Vite, Angular CLI, etc.) often handle port reuse themselves, and the user's editor / preview tabs hold web ports far more often than stale dev processes do.
|
|
34
|
+
|
|
8
35
|
## [0.8.1] — 2026-05-22
|
|
9
36
|
|
|
10
37
|
Patch focused on two real-world footguns surfaced as soon as 0.8.0 hit users.
|
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Built with TypeScript 6, Ink (React for terminals), and zero test dependencies (
|
|
|
41
41
|
- **Subcommands** — `devup logs <svc> [--follow]`, `devup install`, `devup status`, `devup help` work without launching the TUI.
|
|
42
42
|
- **CI-ready** — `--dry-run` prints the resolved boot plan; `--once` boots, waits for readiness, exits `0/1` without a TUI.
|
|
43
43
|
- **Daemon mode** — `devup up -d` boots the stack detached (like `docker compose up -d`) so you can keep using the same terminal. `devup down` stops it. Linux + macOS.
|
|
44
|
+
- **Port-conflict takeover** — when something else is already on a configured port, devup shows you the holder (PID + process name) and offers to kill it. `--kill-port-conflicts` for non-interactive runs.
|
|
44
45
|
- **`devup ctl`** — CLI client for the control plane: `ping`, `status [--follow]`, `logs <svc> [--follow]`, `restart`, `stop`.
|
|
45
46
|
- **Reverse-proxy config** — generate Traefik, Nginx, or Caddy config from running services; health-aware.
|
|
46
47
|
- **Unix-socket control plane** — local JSON-RPC at `~/.devup/sock-<project>.sock` (chmod 0600); `status`, `restart`, `stop`, `logs.tail`, `logs.follow`, `status.follow`, `ping`.
|
package/dist/config/cli.d.ts
CHANGED
|
@@ -18,8 +18,9 @@ export interface CliArgs {
|
|
|
18
18
|
logFile: boolean;
|
|
19
19
|
logDir?: string;
|
|
20
20
|
watchConfig: boolean;
|
|
21
|
+
killPortConflicts: boolean;
|
|
21
22
|
}
|
|
22
|
-
export declare const USAGE = "devup \u2014 terminal UI dev stack runner\n\nUsage: devup [options]\n\nService selection:\n --only apis | webs Start only APIs or only webs\n --services a,b,c Start only the named services\n --profile <name> Start the services in a named profile (see ROADMAP)\n --skip a,b,c Start everything except these\n --config <path> Use a custom config file\n\nLazy mode:\n --lazy Enable lazy mode (default)\n --no-lazy Start every service immediately\n --timeout <minutes> Idle timeout for lazy services. Default: 10\n\nReverse proxy:\n --proxy Enable proxy config generation\n --proxy-host <host> Override the target host (Docker/local)\n --proxy-conf <path> Override the generated config file path\n --proxy-tls Enable TLS in the generated config (default)\n --no-proxy-tls Disable TLS\n --proxy-entrypoint <n> Override entrypoint name (Traefik only)\n\nCI / scripting:\n --dry-run Print the resolved boot plan and exit\n --once Boot, wait for readiness, exit 0/1 (no TUI)\n --once-timeout <s> Max seconds to wait in --once mode. Default: 90\n\nLog files:\n --no-log-file Disable persistent log files\n --log-dir <path> Override log root (default: ~/.devup/logs)\n\nHot reload:\n --watch-config Watch devup.config.* and apply add/remove/restart\n service changes without exiting the TUI\n\nOther:\n -h, --help Show this help and exit\n -v, --version Show version and exit\n\nSee https://github.com/gachlab/devup for the full documentation.";
|
|
23
|
+
export declare const USAGE = "devup \u2014 terminal UI dev stack runner\n\nUsage: devup [options]\n\nService selection:\n --only apis | webs Start only APIs or only webs\n --services a,b,c Start only the named services\n --profile <name> Start the services in a named profile (see ROADMAP)\n --skip a,b,c Start everything except these\n --config <path> Use a custom config file\n\nLazy mode:\n --lazy Enable lazy mode (default)\n --no-lazy Start every service immediately\n --timeout <minutes> Idle timeout for lazy services. Default: 10\n\nReverse proxy:\n --proxy Enable proxy config generation\n --proxy-host <host> Override the target host (Docker/local)\n --proxy-conf <path> Override the generated config file path\n --proxy-tls Enable TLS in the generated config (default)\n --no-proxy-tls Disable TLS\n --proxy-entrypoint <n> Override entrypoint name (Traefik only)\n\nCI / scripting:\n --dry-run Print the resolved boot plan and exit\n --once Boot, wait for readiness, exit 0/1 (no TUI)\n --once-timeout <s> Max seconds to wait in --once mode. Default: 90\n\nLog files:\n --no-log-file Disable persistent log files\n --log-dir <path> Override log root (default: ~/.devup/logs)\n\nHot reload:\n --watch-config Watch devup.config.* and apply add/remove/restart\n service changes without exiting the TUI\n\nPort conflicts:\n --kill-port-conflicts Kill any processes already holding a configured\n port before boot. Interactive prompt without it;\n required for non-TTY (daemon, --once, CI)\n\nOther:\n -h, --help Show this help and exit\n -v, --version Show version and exit\n\nSee https://github.com/gachlab/devup for the full documentation.";
|
|
23
24
|
export declare function parseCliArgs(argv: string[]): CliArgs;
|
|
24
25
|
export declare function filterServices(services: ServiceConfig[], args: CliArgs, config?: Pick<DevStackConfig, 'profiles'>): ServiceConfig[];
|
|
25
26
|
//# sourceMappingURL=cli.d.ts.map
|
package/dist/config/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/config/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,OAAO;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/config/cli.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAEhE,MAAM,WAAW,OAAO;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,OAAO,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,OAAO,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;IACrB,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAKD,eAAO,MAAM,KAAK,i6DA8C+C,CAAC;AAElE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CA8CpD;AAED,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,aAAa,EAAE,EACzB,IAAI,EAAE,OAAO,EACb,MAAM,CAAC,EAAE,IAAI,CAAC,cAAc,EAAE,UAAU,CAAC,GACxC,aAAa,EAAE,CA6BjB"}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAwBA,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChG,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAClE,YAAY,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -296,6 +296,11 @@ Hot reload:
|
|
|
296
296
|
--watch-config Watch devup.config.* and apply add/remove/restart
|
|
297
297
|
service changes without exiting the TUI
|
|
298
298
|
|
|
299
|
+
Port conflicts:
|
|
300
|
+
--kill-port-conflicts Kill any processes already holding a configured
|
|
301
|
+
port before boot. Interactive prompt without it;
|
|
302
|
+
required for non-TTY (daemon, --once, CI)
|
|
303
|
+
|
|
299
304
|
Other:
|
|
300
305
|
-h, --help Show this help and exit
|
|
301
306
|
-v, --version Show version and exit
|
|
@@ -313,7 +318,8 @@ function parseCliArgs(argv) {
|
|
|
313
318
|
once: false,
|
|
314
319
|
onceTimeout: DEFAULT_ONCE_TIMEOUT,
|
|
315
320
|
logFile: true,
|
|
316
|
-
watchConfig: false
|
|
321
|
+
watchConfig: false,
|
|
322
|
+
killPortConflicts: false
|
|
317
323
|
};
|
|
318
324
|
for (let i = 0; i < argv.length; i++) {
|
|
319
325
|
const arg = argv[i];
|
|
@@ -390,6 +396,9 @@ function parseCliArgs(argv) {
|
|
|
390
396
|
case "--watch-config":
|
|
391
397
|
args.watchConfig = true;
|
|
392
398
|
break;
|
|
399
|
+
case "--kill-port-conflicts":
|
|
400
|
+
args.killPortConflicts = true;
|
|
401
|
+
break;
|
|
393
402
|
}
|
|
394
403
|
}
|
|
395
404
|
return args;
|
|
@@ -2525,6 +2534,122 @@ function runHelp(argv, opts = {}) {
|
|
|
2525
2534
|
return 0;
|
|
2526
2535
|
}
|
|
2527
2536
|
|
|
2537
|
+
// src/process/port-conflicts.ts
|
|
2538
|
+
import { exec } from "child_process";
|
|
2539
|
+
import { promisify } from "util";
|
|
2540
|
+
import { setTimeout as sleep2 } from "timers/promises";
|
|
2541
|
+
var execAsync = promisify(exec);
|
|
2542
|
+
var isUnix = process.platform === "linux" || process.platform === "darwin";
|
|
2543
|
+
async function findPortHolder(port) {
|
|
2544
|
+
if (!isUnix) return null;
|
|
2545
|
+
try {
|
|
2546
|
+
const { stdout } = await execAsync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -F pcn`);
|
|
2547
|
+
return parseLsof(stdout);
|
|
2548
|
+
} catch {
|
|
2549
|
+
return null;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
function parseLsof(stdout) {
|
|
2553
|
+
let pid = null;
|
|
2554
|
+
let cmd = "";
|
|
2555
|
+
for (const line of stdout.split("\n")) {
|
|
2556
|
+
if (!line) continue;
|
|
2557
|
+
const tag = line[0];
|
|
2558
|
+
const value = line.slice(1);
|
|
2559
|
+
if (tag === "p") {
|
|
2560
|
+
if (pid != null) return { pid, command: cmd || "unknown" };
|
|
2561
|
+
pid = Number(value);
|
|
2562
|
+
} else if (tag === "c") {
|
|
2563
|
+
cmd = value;
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (pid != null) return { pid, command: cmd || "unknown" };
|
|
2567
|
+
return null;
|
|
2568
|
+
}
|
|
2569
|
+
async function scanPortConflicts(services) {
|
|
2570
|
+
const apis = services.filter((s) => s.type === "api");
|
|
2571
|
+
const conflicts = [];
|
|
2572
|
+
for (const svc of apis) {
|
|
2573
|
+
const bindable = await isPortBindable(svc.port);
|
|
2574
|
+
if (bindable) continue;
|
|
2575
|
+
const holder = await findPortHolder(svc.port);
|
|
2576
|
+
conflicts.push({ service: svc.name, port: svc.port, holder });
|
|
2577
|
+
}
|
|
2578
|
+
return conflicts;
|
|
2579
|
+
}
|
|
2580
|
+
async function killHolder(pid, graceMs = 3e3) {
|
|
2581
|
+
try {
|
|
2582
|
+
process.kill(pid, "SIGTERM");
|
|
2583
|
+
} catch {
|
|
2584
|
+
return false;
|
|
2585
|
+
}
|
|
2586
|
+
const deadline = Date.now() + graceMs;
|
|
2587
|
+
while (Date.now() < deadline) {
|
|
2588
|
+
if (!pidAlive2(pid)) return true;
|
|
2589
|
+
await sleep2(100);
|
|
2590
|
+
}
|
|
2591
|
+
try {
|
|
2592
|
+
process.kill(pid, "SIGKILL");
|
|
2593
|
+
} catch {
|
|
2594
|
+
}
|
|
2595
|
+
await sleep2(100);
|
|
2596
|
+
return !pidAlive2(pid);
|
|
2597
|
+
}
|
|
2598
|
+
function pidAlive2(pid) {
|
|
2599
|
+
try {
|
|
2600
|
+
process.kill(pid, 0);
|
|
2601
|
+
return true;
|
|
2602
|
+
} catch {
|
|
2603
|
+
return false;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
async function resolvePortConflicts(conflicts, opts) {
|
|
2607
|
+
if (!conflicts.length) return true;
|
|
2608
|
+
const { autoKill, out, prompt, isInteractive = process.stdin.isTTY ?? false } = opts;
|
|
2609
|
+
out("\u26A0 Port conflicts detected on the following services:");
|
|
2610
|
+
out("");
|
|
2611
|
+
const maxName = Math.max(...conflicts.map((c) => c.service.length), 8);
|
|
2612
|
+
for (const c of conflicts) {
|
|
2613
|
+
const holder = c.holder ? `pid=${c.holder.pid} process=${c.holder.command}` : `(unable to identify holder${isUnix ? "" : " \u2014 Windows not supported"})`;
|
|
2614
|
+
out(` :${String(c.port).padEnd(6)} ${c.service.padEnd(maxName)} ${holder}`);
|
|
2615
|
+
}
|
|
2616
|
+
out("");
|
|
2617
|
+
if (autoKill) {
|
|
2618
|
+
return await killAll(conflicts, out);
|
|
2619
|
+
}
|
|
2620
|
+
if (!isInteractive || !prompt) {
|
|
2621
|
+
out("Re-run with --kill-port-conflicts to take them over, or stop them yourself.");
|
|
2622
|
+
return false;
|
|
2623
|
+
}
|
|
2624
|
+
const confirmed = await prompt();
|
|
2625
|
+
if (!confirmed) {
|
|
2626
|
+
out("Aborted \u2014 no processes killed.");
|
|
2627
|
+
return false;
|
|
2628
|
+
}
|
|
2629
|
+
return await killAll(conflicts, out);
|
|
2630
|
+
}
|
|
2631
|
+
async function killAll(conflicts, out) {
|
|
2632
|
+
let allOk = true;
|
|
2633
|
+
for (const c of conflicts) {
|
|
2634
|
+
if (!c.holder) {
|
|
2635
|
+
out(`\u2717 :${c.port} ${c.service}: holder unknown, cannot kill \u2014 skipped`);
|
|
2636
|
+
allOk = false;
|
|
2637
|
+
continue;
|
|
2638
|
+
}
|
|
2639
|
+
const ok = await killHolder(c.holder.pid);
|
|
2640
|
+
if (ok) {
|
|
2641
|
+
out(`\u2713 :${c.port} ${c.service}: killed pid=${c.holder.pid}`);
|
|
2642
|
+
} else {
|
|
2643
|
+
out(`\u2717 :${c.port} ${c.service}: pid=${c.holder.pid} survived SIGKILL`);
|
|
2644
|
+
allOk = false;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
return allOk;
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// src/index.ts
|
|
2651
|
+
import { createInterface as createInterface6 } from "readline";
|
|
2652
|
+
|
|
2528
2653
|
// src/platform/detect.ts
|
|
2529
2654
|
async function detectPlatform() {
|
|
2530
2655
|
switch (process.platform) {
|
|
@@ -3981,6 +4106,20 @@ ${formatValidationWarnings(warnings)}`);
|
|
|
3981
4106
|
if (cliArgs.logFile) {
|
|
3982
4107
|
logSink = new LogSink({ projectName: config.name, rootDir: cliArgs.logDir });
|
|
3983
4108
|
}
|
|
4109
|
+
if (process.env.DEVUP_DAEMON_CHILD !== "1") {
|
|
4110
|
+
const conflicts = await scanPortConflicts(services);
|
|
4111
|
+
if (conflicts.length) {
|
|
4112
|
+
const resolved = await resolvePortConflicts(conflicts, {
|
|
4113
|
+
autoKill: cliArgs.killPortConflicts,
|
|
4114
|
+
out: (msg) => process.stderr.write(msg + "\n"),
|
|
4115
|
+
prompt: () => askYesNo("Kill these processes and continue? [y/N]: ")
|
|
4116
|
+
});
|
|
4117
|
+
if (!resolved) {
|
|
4118
|
+
await logSink?.close();
|
|
4119
|
+
process.exit(1);
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
}
|
|
3984
4123
|
if (cliArgs.once) {
|
|
3985
4124
|
const code = await runOnce({
|
|
3986
4125
|
config,
|
|
@@ -4041,6 +4180,19 @@ ${formatValidationWarnings(warnings)}`);
|
|
|
4041
4180
|
);
|
|
4042
4181
|
await waitUntilExit();
|
|
4043
4182
|
}
|
|
4183
|
+
function askYesNo(question) {
|
|
4184
|
+
return new Promise((resolve4) => {
|
|
4185
|
+
if (!process.stdin.isTTY) {
|
|
4186
|
+
resolve4(false);
|
|
4187
|
+
return;
|
|
4188
|
+
}
|
|
4189
|
+
const rl = createInterface6({ input: process.stdin, output: process.stderr });
|
|
4190
|
+
rl.question(question, (answer) => {
|
|
4191
|
+
rl.close();
|
|
4192
|
+
resolve4(/^y(es)?$/i.test(answer.trim()));
|
|
4193
|
+
});
|
|
4194
|
+
});
|
|
4195
|
+
}
|
|
4044
4196
|
main().catch((e) => {
|
|
4045
4197
|
console.error(e);
|
|
4046
4198
|
process.exit(1);
|