@nmakarov/cli-toolkit 0.79.0 → 0.81.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.
@@ -1002,6 +1002,30 @@ var init_ui_elements = __esm({
1002
1002
  }
1003
1003
  });
1004
1004
 
1005
+ // src/screen/follow-scroll.js
1006
+ function clampScroll(scrollTop, maxScroll) {
1007
+ const max = Math.max(0, Number(maxScroll) || 0);
1008
+ const top = Number(scrollTop) || 0;
1009
+ return Math.min(Math.max(0, top), max);
1010
+ }
1011
+ function isScrolledToBottom(scrollTop, maxScroll) {
1012
+ return clampScroll(scrollTop, maxScroll) >= Math.max(0, Number(maxScroll) || 0);
1013
+ }
1014
+ function nextScrollAfterUserMove(scrollTop, maxScroll, delta) {
1015
+ const next = clampScroll((Number(scrollTop) || 0) + (Number(delta) || 0), maxScroll);
1016
+ return { scrollTop: next, following: isScrolledToBottom(next, maxScroll) };
1017
+ }
1018
+ function nextScrollAfterContentChange({ following, scrollTop, maxScroll }) {
1019
+ const max = Math.max(0, Number(maxScroll) || 0);
1020
+ if (following) return { scrollTop: max, following: true };
1021
+ const next = clampScroll(scrollTop, max);
1022
+ return { scrollTop: next, following: isScrolledToBottom(next, max) };
1023
+ }
1024
+ var init_follow_scroll = __esm({
1025
+ "src/screen/follow-scroll.js"() {
1026
+ }
1027
+ });
1028
+
1005
1029
  // src/screen/scrollable-text.js
1006
1030
  import { useState as useState3, useEffect as useEffect2, useMemo, useRef as useRef2, createElement as createElement2 } from "react";
1007
1031
  import { Box as Box4, Text as Text5 } from "ink";
@@ -1036,9 +1060,11 @@ function ScrollableText({
1036
1060
  showScrollbar = true,
1037
1061
  showStatus = true,
1038
1062
  bindKeys = true,
1039
- header = null
1063
+ header = null,
1064
+ followBottom = false
1040
1065
  }) {
1041
1066
  const [scrollTop, setScrollTop] = useState3(0);
1067
+ const [following, setFollowing] = useState3(() => !!followBottom);
1042
1068
  const [, bump] = useState3(0);
1043
1069
  const termRows = process.stdout.rows || 24;
1044
1070
  const viewportRows = Math.max(
@@ -1057,39 +1083,40 @@ function ScrollableText({
1057
1083
  const clamped = Math.min(Math.max(0, scrollTop), maxScroll);
1058
1084
  const visible = allLines.slice(clamped, clamped + viewportRows);
1059
1085
  const bar = needsBar ? scrollbarGlyphs(viewportRows, allLines.length, clamped) : null;
1060
- useEffect2(() => {
1061
- setScrollTop((s) => Math.min(s, maxScroll));
1062
- }, [maxScroll]);
1063
1086
  const maxScrollRef = useRef2(maxScroll);
1064
1087
  const pageSizeRef = useRef2(viewportRows);
1088
+ const scrollTopRef = useRef2(scrollTop);
1089
+ const followingRef = useRef2(following);
1065
1090
  maxScrollRef.current = maxScroll;
1066
1091
  pageSizeRef.current = viewportRows;
1092
+ scrollTopRef.current = scrollTop;
1093
+ followingRef.current = following;
1094
+ useEffect2(() => {
1095
+ const next = nextScrollAfterContentChange({
1096
+ following: followBottom && followingRef.current,
1097
+ scrollTop: scrollTopRef.current,
1098
+ maxScroll
1099
+ });
1100
+ if (next.scrollTop !== scrollTopRef.current) setScrollTop(next.scrollTop);
1101
+ if (followBottom && next.following !== followingRef.current) setFollowing(next.following);
1102
+ }, [maxScroll, followBottom]);
1103
+ const applyUserScroll = (delta) => {
1104
+ const next = nextScrollAfterUserMove(scrollTopRef.current, maxScrollRef.current, delta);
1105
+ setScrollTop(next.scrollTop);
1106
+ if (followBottom) setFollowing(next.following);
1107
+ bump((n) => n + 1);
1108
+ ctx?.update?.();
1109
+ };
1067
1110
  useEffect2(() => {
1068
1111
  if (!ctx || !bindKeys) return void 0;
1069
1112
  ctx.setKeyBinding(SCROLL_KEYS);
1070
- ctx.setAction("scrollUp", () => {
1071
- setScrollTop((s) => Math.max(0, s - 1));
1072
- bump((n) => n + 1);
1073
- ctx.update?.();
1074
- });
1075
- ctx.setAction("scrollDown", () => {
1076
- setScrollTop((s) => Math.min(maxScrollRef.current, s + 1));
1077
- bump((n) => n + 1);
1078
- ctx.update?.();
1079
- });
1080
- ctx.setAction("pageUp", () => {
1081
- setScrollTop((s) => Math.max(0, s - pageSizeRef.current));
1082
- bump((n) => n + 1);
1083
- ctx.update?.();
1084
- });
1085
- ctx.setAction("pageDown", () => {
1086
- setScrollTop((s) => Math.min(maxScrollRef.current, s + pageSizeRef.current));
1087
- bump((n) => n + 1);
1088
- ctx.update?.();
1089
- });
1113
+ ctx.setAction("scrollUp", () => applyUserScroll(-1));
1114
+ ctx.setAction("scrollDown", () => applyUserScroll(1));
1115
+ ctx.setAction("pageUp", () => applyUserScroll(-pageSizeRef.current));
1116
+ ctx.setAction("pageDown", () => applyUserScroll(pageSizeRef.current));
1090
1117
  return void 0;
1091
- }, [ctx, bindKeys]);
1092
- const status = allLines.length === 0 ? "empty" : `lines ${clamped + 1}-${Math.min(clamped + visible.length, allLines.length)} of ${allLines.length}` + (needsBar ? " \xB7 \u2325\u2191/\u2193 or PgUp/Dn page" : "");
1118
+ }, [ctx, bindKeys, followBottom]);
1119
+ const status = allLines.length === 0 ? "empty" : `lines ${clamped + 1}-${Math.min(clamped + visible.length, allLines.length)} of ${allLines.length}` + (needsBar ? " \xB7 \u2325\u2191/\u2193 or PgUp/Dn page" : "") + (followBottom && following ? " \xB7 follow" : followBottom ? " \xB7 follow off" : "");
1093
1120
  const rowNodes = visible.map((line, i) => {
1094
1121
  const body = padEndVisible(line, textWidth);
1095
1122
  const glyph = bar ? bar[i] ?? BAR_TRACK : showScrollbar ? " " : "";
@@ -1127,6 +1154,7 @@ var init_scrollable_text = __esm({
1127
1154
  "src/screen/scrollable-text.js"() {
1128
1155
  init_components();
1129
1156
  init_scrollbar();
1157
+ init_follow_scroll();
1130
1158
  init_scrollbar();
1131
1159
  h5 = createElement2;
1132
1160
  SCROLL_KEYS = [
@@ -1295,10 +1323,14 @@ __export(screen_exports, {
1295
1323
  buildBreadcrumb: () => buildBreadcrumb,
1296
1324
  buildDetailBreadcrumb: () => buildDetailBreadcrumb,
1297
1325
  buildFooter: () => buildFooter,
1326
+ clampScroll: () => clampScroll,
1298
1327
  formatBindingKey: () => formatBindingKey,
1299
1328
  h: () => createElement3,
1329
+ isScrolledToBottom: () => isScrolledToBottom,
1300
1330
  load: () => load,
1301
1331
  memo: () => memo,
1332
+ nextScrollAfterContentChange: () => nextScrollAfterContentChange,
1333
+ nextScrollAfterUserMove: () => nextScrollAfterUserMove,
1302
1334
  organizeFooterMessages: () => organizeFooterMessages,
1303
1335
  scrollbarGlyphs: () => scrollbarGlyphs,
1304
1336
  showListScreen: () => showListScreen,
@@ -1335,6 +1367,7 @@ var init_screen = __esm({
1335
1367
  init_components();
1336
1368
  init_ui_elements();
1337
1369
  init_scrollable_text();
1370
+ init_follow_scroll();
1338
1371
  init_scrollbar();
1339
1372
  init_key_bindings();
1340
1373
  init_utils();
@@ -2654,13 +2687,14 @@ var Logger = class _Logger {
2654
2687
  */
2655
2688
  progress(message, opts) {
2656
2689
  const { prefix, count, total } = opts;
2657
- const paddedTotal = String(total).length;
2690
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
2691
+ const paddedTotal = String(displayTotal).length;
2658
2692
  const paddedCount = String(count).padStart(paddedTotal, " ");
2659
2693
  const payload = {
2660
2694
  level: "progress",
2661
2695
  message,
2662
2696
  count: paddedCount,
2663
- total,
2697
+ total: displayTotal,
2664
2698
  prefix
2665
2699
  };
2666
2700
  const key = prefix ?? "";
@@ -2677,7 +2711,7 @@ var Logger = class _Logger {
2677
2711
  if (wantTimes) {
2678
2712
  let remaining = -1;
2679
2713
  if (itemsPerSec > 0) {
2680
- remaining = (total - count) / itemsPerSec;
2714
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
2681
2715
  }
2682
2716
  payload.elapsed = this.round(elapsedSeconds, 2);
2683
2717
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -2686,12 +2720,12 @@ var Logger = class _Logger {
2686
2720
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
2687
2721
  }
2688
2722
  }
2689
- if (count >= total) {
2723
+ if (count === total) {
2690
2724
  delete this.startTimes[key];
2691
2725
  delete this.startCounts[key];
2692
2726
  delete this.lastProgressTimes[key];
2693
2727
  }
2694
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
2728
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
2695
2729
  this.out(payload);
2696
2730
  if (this.options.progressThrottle && prefix) {
2697
2731
  this.lastProgressTimes[prefix] = Date.now();
@@ -2850,6 +2884,8 @@ function setup(opts = {}) {
2850
2884
  // a quick "show me the figured params and quit" that skips the flow's
2851
2885
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
2852
2886
  // cleanups are skipped); call it once components/params are resolved.
2887
+ _requestExitCode: null,
2888
+ requestExit: null,
2853
2889
  showUsedParamsIfNeeded: () => {
2854
2890
  const mode = params.getShowUsedParamsMode?.();
2855
2891
  if (mode !== "top" && mode !== "stop") return;
@@ -2860,6 +2896,9 @@ function setup(opts = {}) {
2860
2896
  }
2861
2897
  }
2862
2898
  };
2899
+ context.requestExit = (code = 0) => {
2900
+ context._requestExitCode = code;
2901
+ };
2863
2902
  logger.debug("[setup] completed successfully");
2864
2903
  return context;
2865
2904
  }
@@ -2895,9 +2934,25 @@ async function init(flow2, opts = {}) {
2895
2934
  if (cleanupRan) return;
2896
2935
  cleanupRan = true;
2897
2936
  const fns = [...ctx.cleanupFunctions].reverse();
2937
+ const budgetMs = 5e3;
2938
+ const started = Date.now();
2898
2939
  for (const fn of fns) {
2940
+ const left = budgetMs - (Date.now() - started);
2941
+ if (left <= 0) {
2942
+ ctx.logger.warn("[cleanup] budget exhausted \u2014 skipping remaining cleanup");
2943
+ break;
2944
+ }
2899
2945
  try {
2900
- await fn(ctx);
2946
+ await Promise.race([
2947
+ Promise.resolve(fn(ctx)),
2948
+ new Promise((_, reject) => {
2949
+ const t = setTimeout(
2950
+ () => reject(new Error(`cleanup timed out after ${left}ms`)),
2951
+ left
2952
+ );
2953
+ t.unref?.();
2954
+ })
2955
+ ]);
2901
2956
  } catch (error) {
2902
2957
  ctx.logger.warn("[cleanup] error in cleanup function:", error);
2903
2958
  }
@@ -2993,6 +3048,8 @@ async function init(flow2, opts = {}) {
2993
3048
  process.exit(process.exitCode);
2994
3049
  } else if (stop || kill) {
2995
3050
  process.exit(0);
3051
+ } else if (context._requestExitCode != null) {
3052
+ process.exit(context._requestExitCode);
2996
3053
  }
2997
3054
  }
2998
3055
  }
@@ -3116,7 +3173,7 @@ var CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3116
3173
  "57P02",
3117
3174
  "57P03"
3118
3175
  ]);
3119
- var CONNECTION_ERROR_MESSAGE_RE = /connection (terminated|ended|closed|destroyed|reset|refused|not open)|Connection terminated unexpectedly|Client has encountered a connection error|server closed the connection|Cannot use a pool after calling end|This socket has been ended|connect ECONNRESET/i;
3176
+ var CONNECTION_ERROR_MESSAGE_RE = /connection (terminated|ended|closed|destroyed|reset|refused|not open)|Connection (terminated|ended) unexpectedly|Client has encountered a connection error|server closed the connection|Cannot use a pool after calling end|This socket has been ended|connect ECONNRESET/i;
3120
3177
  var Db = class _Db {
3121
3178
  static async init(context, options = {}) {
3122
3179
  const buildConfig = async () => {
@@ -3384,6 +3441,10 @@ var Db = class _Db {
3384
3441
  this.isConnected = false;
3385
3442
  this.queriesLog = [];
3386
3443
  this._reconnectPromise = null;
3444
+ this._closed = false;
3445
+ this._liveKnex = /* @__PURE__ */ new Set();
3446
+ this._reconnectCooldownUntil = 0;
3447
+ this._reconnectAcquireTimeoutMs = null;
3387
3448
  this.config = {
3388
3449
  testConnection: true,
3389
3450
  profile: false,
@@ -3468,6 +3529,9 @@ var Db = class _Db {
3468
3529
  return null;
3469
3530
  }
3470
3531
  async connect() {
3532
+ if (this._closed) {
3533
+ throw new ParamError("Db: Connection closed");
3534
+ }
3471
3535
  if (this.isConnected && this.knexInstance) {
3472
3536
  this.logger.warn?.("[Db] Already connected");
3473
3537
  return;
@@ -3483,22 +3547,46 @@ var Db = class _Db {
3483
3547
  connectionString: this.config.connectionString,
3484
3548
  family: 4
3485
3549
  };
3550
+ const acquireTimeout = this._reconnectAcquireTimeoutMs ?? this.config.acquireConnectionTimeout;
3486
3551
  this.knexInstance = knex({
3487
3552
  client,
3488
3553
  connection: connectionConfig,
3489
3554
  pool: this.config.pool,
3490
- acquireConnectionTimeout: this.config.acquireConnectionTimeout,
3555
+ acquireConnectionTimeout: acquireTimeout,
3491
3556
  ...this.config.ssl && { ssl: this.config.ssl }
3492
3557
  });
3558
+ this._liveKnex.add(this.knexInstance);
3559
+ this.knexInstance.on?.("error", (err) => {
3560
+ if (this._closed) return;
3561
+ this.logger.warn?.(
3562
+ `[Db] Connection error (${this.getErrorMessage(err)})`
3563
+ );
3564
+ });
3565
+ if (this._closed) {
3566
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
3567
+ this.knexInstance = null;
3568
+ throw new ParamError("Db: Connection closed");
3569
+ }
3493
3570
  if (this.config.profile) {
3494
3571
  this.attachProfiler();
3495
3572
  }
3496
3573
  if (this.config.testConnection) {
3497
3574
  await this.testConnection();
3498
3575
  }
3576
+ if (this._closed) {
3577
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
3578
+ this.knexInstance = null;
3579
+ throw new ParamError("Db: Connection closed");
3580
+ }
3499
3581
  this.isConnected = true;
3500
3582
  this.logger.debug?.(formatDbConnectMessage(this.config.name, this.config.connectionString));
3501
3583
  } catch (error) {
3584
+ const failed = this.knexInstance;
3585
+ this.knexInstance = null;
3586
+ this.isConnected = false;
3587
+ if (failed) {
3588
+ await this._destroyKnex(failed, "connect failed");
3589
+ }
3502
3590
  if (error instanceof ParamError) {
3503
3591
  throw error;
3504
3592
  }
@@ -3506,20 +3594,42 @@ var Db = class _Db {
3506
3594
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
3507
3595
  }
3508
3596
  }
3509
- async disconnect() {
3510
- if (!this.knexInstance) {
3511
- return;
3512
- }
3597
+ /**
3598
+ * Destroy a knex pool without hanging exit on stuck TCP sockets (ETIMEDOUT).
3599
+ * @param {import("knex").Knex | null | undefined} knexInst
3600
+ * @param {string} [reason]
3601
+ * @param {number} [timeoutMs]
3602
+ */
3603
+ async _destroyKnex(knexInst, reason = "destroy", timeoutMs = 3e3) {
3604
+ if (!knexInst || typeof knexInst.destroy !== "function") return;
3605
+ this._liveKnex.delete(knexInst);
3513
3606
  try {
3514
- await this.knexInstance.destroy();
3515
- this.knexInstance = null;
3516
- this.isConnected = false;
3517
- this.queriesLog = [];
3518
- this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
3607
+ await Promise.race([
3608
+ knexInst.destroy(),
3609
+ new Promise((_, reject) => {
3610
+ const t = setTimeout(
3611
+ () => reject(new Error(`Db: ${reason} timed out after ${timeoutMs}ms`)),
3612
+ timeoutMs
3613
+ );
3614
+ t.unref?.();
3615
+ })
3616
+ ]);
3519
3617
  } catch (error) {
3520
- const errorMsg = this.getErrorMessage(error);
3521
- this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
3522
- throw error;
3618
+ this.logger.debug?.(
3619
+ `[Db] ${reason}: ${this.getErrorMessage(error)}`
3620
+ );
3621
+ }
3622
+ }
3623
+ async disconnect() {
3624
+ this._closed = true;
3625
+ this.isConnected = false;
3626
+ this.knexInstance = null;
3627
+ this.queriesLog = [];
3628
+ const all = [...this._liveKnex];
3629
+ this._liveKnex.clear();
3630
+ await Promise.all(all.map((inst) => this._destroyKnex(inst, "disconnect")));
3631
+ if (all.length > 0) {
3632
+ this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
3523
3633
  }
3524
3634
  }
3525
3635
  /**
@@ -3542,27 +3652,39 @@ var Db = class _Db {
3542
3652
  }
3543
3653
  /**
3544
3654
  * Destroy the current knex pool and open a new one. Concurrent callers share one attempt.
3655
+ * No-ops once disconnect() has closed the handle.
3545
3656
  */
3546
3657
  async reconnect() {
3658
+ if (this._closed) {
3659
+ return;
3660
+ }
3661
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
3662
+ return;
3663
+ }
3547
3664
  if (this._reconnectPromise) {
3548
3665
  await this._reconnectPromise;
3549
3666
  return;
3550
3667
  }
3551
3668
  this._reconnectPromise = (async () => {
3669
+ if (this._closed) return;
3552
3670
  const old = this.knexInstance;
3553
3671
  this.isConnected = false;
3554
3672
  this.knexInstance = null;
3555
3673
  this.queriesLog = [];
3556
3674
  if (old) {
3557
- try {
3558
- await old.destroy();
3559
- } catch (error) {
3560
- this.logger.debug?.(
3561
- `[Db] destroy during reconnect: ${this.getErrorMessage(error)}`
3562
- );
3563
- }
3675
+ await this._destroyKnex(old, "destroy during reconnect");
3676
+ }
3677
+ if (this._closed) return;
3678
+ this._reconnectAcquireTimeoutMs = 3e3;
3679
+ try {
3680
+ await this.connect();
3681
+ this._reconnectCooldownUntil = 0;
3682
+ } catch (error) {
3683
+ this._reconnectCooldownUntil = Date.now() + 5e3;
3684
+ throw error;
3685
+ } finally {
3686
+ this._reconnectAcquireTimeoutMs = null;
3564
3687
  }
3565
- await this.connect();
3566
3688
  })();
3567
3689
  try {
3568
3690
  await this._reconnectPromise;
@@ -3571,10 +3693,19 @@ var Db = class _Db {
3571
3693
  }
3572
3694
  }
3573
3695
  async reconnectAfterConnectionError(error) {
3696
+ if (this._closed) {
3697
+ return;
3698
+ }
3699
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
3700
+ return;
3701
+ }
3574
3702
  this.logger.warn?.(
3575
3703
  `[Db] Connection lost (${this.getErrorMessage(error)}) \u2014 reconnecting\u2026`
3576
3704
  );
3577
- await this.reconnect();
3705
+ try {
3706
+ await this.reconnect();
3707
+ } catch {
3708
+ }
3578
3709
  }
3579
3710
  /**
3580
3711
  * Run `fn`; on a connection error, reconnect once (by default) and retry `fn`.
@@ -3587,10 +3718,13 @@ var Db = class _Db {
3587
3718
  return await fn();
3588
3719
  } catch (error) {
3589
3720
  lastError = error;
3590
- if (!this.isConnectionError(error) || attempt >= retries) {
3721
+ if (this._closed || !this.isConnectionError(error) || attempt >= retries) {
3591
3722
  throw error;
3592
3723
  }
3593
3724
  await this.reconnectAfterConnectionError(error);
3725
+ if (this._closed) {
3726
+ throw error;
3727
+ }
3594
3728
  }
3595
3729
  }
3596
3730
  throw lastError;
@@ -3614,10 +3748,13 @@ var Db = class _Db {
3614
3748
  try {
3615
3749
  return await protoThen.call(builder);
3616
3750
  } catch (error) {
3617
- if (!inst.isConnectionError(error)) {
3751
+ if (inst._closed || !inst.isConnectionError(error)) {
3618
3752
  throw error;
3619
3753
  }
3620
3754
  await inst.reconnectAfterConnectionError(error);
3755
+ if (inst._closed || !inst.knexInstance) {
3756
+ throw error;
3757
+ }
3621
3758
  if (typeof builder.clone === "function") {
3622
3759
  const retry = builder.clone();
3623
3760
  retry.client = inst.knexInstance.client;