@gachlab/devup 0.8.1 → 0.9.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,41 @@ 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.1] — 2026-05-22
9
+
10
+ Hotfix for two issues reported moments after 0.9.0 hit:
11
+
12
+ ### Fixed
13
+ - **The y/N prompt no longer no-ops silently.** The conflict list would print, then the process would just continue without waiting for input on some terminals (IDE integrated terminals, multiplexers, custom shells where `process.stdin.isTTY` is misreported). Replaced `readline.question` with direct stdin handling. TTY detection now also accepts stderr / stdout being a TTY when stdin isn't — covers more real environments.
14
+ - **Daemon-already-running guard moved before the port scan.** Running `devup` (TUI) or `devup up -d` while a daemon was already up for the same project caused the scan to list the daemon's own services as conflicts, prompt the user to kill them, restart them (because the daemon's auto-restarter kicked in), then bail with "daemon already running". Now the daemon check runs first and short-circuits cleanly — no churn, single clear error.
15
+
16
+ ## [0.9.0] — 2026-05-22
17
+
18
+ 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.
19
+
20
+ ### Added
21
+ - **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`):
22
+ ```
23
+ ⚠ Port conflicts detected on the following services:
24
+
25
+ :3002 authorization-api pid=12345 process=node
26
+ :3013 files-api pid=99999 process=docker-proxy
27
+
28
+ Kill these processes and continue? [y/N]:
29
+ ```
30
+ Three flavours of resolution:
31
+ - **Interactive TTY** → y/N prompt (default).
32
+ - **`--kill-port-conflicts` flag** → auto-kill, no prompt. Required for `devup up -d`, `--once`, and any non-TTY context (CI).
33
+ - **Non-interactive without the flag** → fail fast with the conflict list as instructions.
34
+ - **`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`.
35
+
36
+ ### Internals
37
+ - 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.
38
+ - 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.
39
+
40
+ ### Notes
41
+ - 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.
42
+
8
43
  ## [0.8.1] — 2026-05-22
9
44
 
10
45
  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`.
@@ -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
@@ -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;CACtB;AAKD,eAAO,MAAM,KAAK,+qDAyC+C,CAAC;AAElE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CA4CpD;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"}
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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAsBA,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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAuBA,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,119 @@ 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
+
2528
2650
  // src/platform/detect.ts
2529
2651
  async function detectPlatform() {
2530
2652
  switch (process.platform) {
@@ -3981,6 +4103,33 @@ ${formatValidationWarnings(warnings)}`);
3981
4103
  if (cliArgs.logFile) {
3982
4104
  logSink = new LogSink({ projectName: config.name, rootDir: cliArgs.logDir });
3983
4105
  }
4106
+ if (process.env.DEVUP_DAEMON_CHILD !== "1") {
4107
+ const daemonStatus = isDaemonRunning(config.name);
4108
+ if (daemonStatus.pid && !daemonStatus.stale) {
4109
+ console.error(`\u274C A devup daemon is already running for "${config.name}" (pid=${daemonStatus.pid}).`);
4110
+ console.error("");
4111
+ console.error("Stop it first with `devup down`, or interact via the control plane:");
4112
+ console.error(" devup ctl status");
4113
+ console.error(" devup ctl logs <svc> --follow");
4114
+ console.error(" devup ctl restart <svc>");
4115
+ await logSink?.close();
4116
+ process.exit(1);
4117
+ }
4118
+ }
4119
+ if (process.env.DEVUP_DAEMON_CHILD !== "1") {
4120
+ const conflicts = await scanPortConflicts(services);
4121
+ if (conflicts.length) {
4122
+ const resolved = await resolvePortConflicts(conflicts, {
4123
+ autoKill: cliArgs.killPortConflicts,
4124
+ out: (msg) => process.stderr.write(msg + "\n"),
4125
+ prompt: () => askYesNo("Kill these processes and continue? [y/N]: ")
4126
+ });
4127
+ if (!resolved) {
4128
+ await logSink?.close();
4129
+ process.exit(1);
4130
+ }
4131
+ }
4132
+ }
3984
4133
  if (cliArgs.once) {
3985
4134
  const code = await runOnce({
3986
4135
  config,
@@ -4014,16 +4163,6 @@ ${formatValidationWarnings(warnings)}`);
4014
4163
  proxyOpts
4015
4164
  }));
4016
4165
  }
4017
- const daemonStatus = isDaemonRunning(config.name);
4018
- if (daemonStatus.pid && !daemonStatus.stale) {
4019
- console.error(`\u274C A devup daemon is already running for "${config.name}" (pid=${daemonStatus.pid}).`);
4020
- console.error("");
4021
- console.error("Stop it first with `devup down`, or interact via the control plane:");
4022
- console.error(" devup ctl status");
4023
- console.error(" devup ctl logs <svc> --follow");
4024
- console.error(" devup ctl restart <svc>");
4025
- process.exit(1);
4026
- }
4027
4166
  const isInteractive = process.stdin.isTTY ?? false;
4028
4167
  const { waitUntilExit } = render(
4029
4168
  React7.createElement(App, {
@@ -4041,6 +4180,33 @@ ${formatValidationWarnings(warnings)}`);
4041
4180
  );
4042
4181
  await waitUntilExit();
4043
4182
  }
4183
+ function askYesNo(question) {
4184
+ return new Promise((resolve4) => {
4185
+ const isTTY = Boolean(process.stdin.isTTY || process.stderr.isTTY || process.stdout.isTTY);
4186
+ if (!isTTY) {
4187
+ resolve4(false);
4188
+ return;
4189
+ }
4190
+ process.stderr.write(question);
4191
+ process.stdin.resume();
4192
+ process.stdin.setEncoding("utf8");
4193
+ const cleanup = () => {
4194
+ process.stdin.removeListener("data", onData);
4195
+ process.stdin.removeListener("end", onEnd);
4196
+ process.stdin.pause();
4197
+ };
4198
+ const onData = (data) => {
4199
+ cleanup();
4200
+ resolve4(/^y(es)?$/i.test(String(data).trim()));
4201
+ };
4202
+ const onEnd = () => {
4203
+ cleanup();
4204
+ resolve4(false);
4205
+ };
4206
+ process.stdin.once("data", onData);
4207
+ process.stdin.once("end", onEnd);
4208
+ });
4209
+ }
4044
4210
  main().catch((e) => {
4045
4211
  console.error(e);
4046
4212
  process.exit(1);