@gachlab/devup 0.8.0 → 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 CHANGED
@@ -5,6 +5,47 @@ 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
+
35
+ ## [0.8.1] — 2026-05-22
36
+
37
+ Patch focused on two real-world footguns surfaced as soon as 0.8.0 hit users.
38
+
39
+ ### Fixed
40
+ - **Spawner port pre-flight was unreliable.** The check used `checkPort` — a connect-based test designed for health checks — which can miss bindings (e.g. a server bound but not yet accepting connections, or listening on a different address family). When the check missed, the service crashed with a raw `EADDRINUSE` Node stack trace dumped to the logs panel. Replaced with `isPortBindable()` — a bind-based test using `net.createServer().listen()` — which catches every case that would actually conflict.
41
+ - **Pre-flight failures now record a crashed state.** Previously the spawner returned silently when the port was occupied, leaving no entry in the services list. Now the failed service appears in the stats panel as `crashed` with the `⚠ port N already in use` line in its log, matching the behaviour of other pre-flight failures.
42
+
43
+ ### Added
44
+ - **TUI refuses to start when a daemon is already running for the project.** Running `devup` (TUI) on top of `devup up -d` would race for the same ports — every API would fail with EADDRINUSE. The TUI now detects the PID file before mounting Ink and exits with a clear pointer to `devup down` and the `devup ctl` family.
45
+
46
+ ### Internals
47
+ - New public `isPortBindable(port, host?)` in `src/process/health.ts`. `checkPort` keeps its connect-based semantics for health checks and `waitForPort`. Three new tests cover free / occupied / bound-but-not-accepting cases.
48
+
8
49
  ## [0.8.0] — 2026-05-22
9
50
 
10
51
  **Headless devup.** The control plane grows up: streaming events, a CLI client that speaks it end-to-end, and the long-requested daemon mode so the stack can be left running while you keep working in the same terminal — `docker compose up -d` for Node monorepos.
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":"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;
@@ -459,6 +468,23 @@ function checkPort(port, host = "127.0.0.1", timeoutMs = 2e3) {
459
468
  socket.connect(port, host);
460
469
  });
461
470
  }
471
+ async function isPortBindable(port) {
472
+ for (const host of ["0.0.0.0", "::"]) {
473
+ if (!await tryBind(port, host)) return false;
474
+ }
475
+ return true;
476
+ }
477
+ function tryBind(port, host) {
478
+ return new Promise((resolve4) => {
479
+ const server = net.createServer();
480
+ server.once("error", (err) => {
481
+ if (err.code === "EADDRINUSE" || err.code === "EACCES") resolve4(false);
482
+ else resolve4(true);
483
+ });
484
+ server.once("listening", () => server.close(() => resolve4(true)));
485
+ server.listen(port, host);
486
+ });
487
+ }
462
488
  function checkHttp(port, opts = {}) {
463
489
  const path = opts.path ?? "/";
464
490
  const host = opts.host ?? "127.0.0.1";
@@ -1026,9 +1052,10 @@ var Spawner = class {
1026
1052
  async start(svc, colorIdx, isRestart = false) {
1027
1053
  const cwd = join4(this.baseCwd, svc.cwd);
1028
1054
  if (svc.type === "api") {
1029
- const occupied = await checkPort(svc.port);
1030
- if (occupied && !isRestart) {
1055
+ const bindable = await isPortBindable(svc.port);
1056
+ if (!bindable && !isRestart) {
1031
1057
  this.log(svc.name, `\u26A0 port ${svc.port} already in use \u2014 skipping`, colorIdx);
1058
+ this.recordCrashedState(svc, colorIdx);
1032
1059
  return;
1033
1060
  }
1034
1061
  }
@@ -2507,6 +2534,122 @@ function runHelp(argv, opts = {}) {
2507
2534
  return 0;
2508
2535
  }
2509
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
+
2510
2653
  // src/platform/detect.ts
2511
2654
  async function detectPlatform() {
2512
2655
  switch (process.platform) {
@@ -3963,6 +4106,20 @@ ${formatValidationWarnings(warnings)}`);
3963
4106
  if (cliArgs.logFile) {
3964
4107
  logSink = new LogSink({ projectName: config.name, rootDir: cliArgs.logDir });
3965
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
+ }
3966
4123
  if (cliArgs.once) {
3967
4124
  const code = await runOnce({
3968
4125
  config,
@@ -3996,6 +4153,16 @@ ${formatValidationWarnings(warnings)}`);
3996
4153
  proxyOpts
3997
4154
  }));
3998
4155
  }
4156
+ const daemonStatus = isDaemonRunning(config.name);
4157
+ if (daemonStatus.pid && !daemonStatus.stale) {
4158
+ console.error(`\u274C A devup daemon is already running for "${config.name}" (pid=${daemonStatus.pid}).`);
4159
+ console.error("");
4160
+ console.error("Stop it first with `devup down`, or interact via the control plane:");
4161
+ console.error(" devup ctl status");
4162
+ console.error(" devup ctl logs <svc> --follow");
4163
+ console.error(" devup ctl restart <svc>");
4164
+ process.exit(1);
4165
+ }
3999
4166
  const isInteractive = process.stdin.isTTY ?? false;
4000
4167
  const { waitUntilExit } = render(
4001
4168
  React7.createElement(App, {
@@ -4013,6 +4180,19 @@ ${formatValidationWarnings(warnings)}`);
4013
4180
  );
4014
4181
  await waitUntilExit();
4015
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
+ }
4016
4196
  main().catch((e) => {
4017
4197
  console.error(e);
4018
4198
  process.exit(1);