@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.
@@ -1018,6 +1018,30 @@ var init_ui_elements = __esm({
1018
1018
  }
1019
1019
  });
1020
1020
 
1021
+ // src/screen/follow-scroll.js
1022
+ function clampScroll(scrollTop, maxScroll) {
1023
+ const max = Math.max(0, Number(maxScroll) || 0);
1024
+ const top = Number(scrollTop) || 0;
1025
+ return Math.min(Math.max(0, top), max);
1026
+ }
1027
+ function isScrolledToBottom(scrollTop, maxScroll) {
1028
+ return clampScroll(scrollTop, maxScroll) >= Math.max(0, Number(maxScroll) || 0);
1029
+ }
1030
+ function nextScrollAfterUserMove(scrollTop, maxScroll, delta) {
1031
+ const next = clampScroll((Number(scrollTop) || 0) + (Number(delta) || 0), maxScroll);
1032
+ return { scrollTop: next, following: isScrolledToBottom(next, maxScroll) };
1033
+ }
1034
+ function nextScrollAfterContentChange({ following, scrollTop, maxScroll }) {
1035
+ const max = Math.max(0, Number(maxScroll) || 0);
1036
+ if (following) return { scrollTop: max, following: true };
1037
+ const next = clampScroll(scrollTop, max);
1038
+ return { scrollTop: next, following: isScrolledToBottom(next, max) };
1039
+ }
1040
+ var init_follow_scroll = __esm({
1041
+ "src/screen/follow-scroll.js"() {
1042
+ }
1043
+ });
1044
+
1021
1045
  // src/screen/scrollable-text.js
1022
1046
  function wrapTextLines(text, cols) {
1023
1047
  const w = Math.max(1, Math.floor(Number(cols) || 1));
@@ -1050,9 +1074,11 @@ function ScrollableText({
1050
1074
  showScrollbar = true,
1051
1075
  showStatus = true,
1052
1076
  bindKeys = true,
1053
- header = null
1077
+ header = null,
1078
+ followBottom = false
1054
1079
  }) {
1055
1080
  const [scrollTop, setScrollTop] = (0, import_react5.useState)(0);
1081
+ const [following, setFollowing] = (0, import_react5.useState)(() => !!followBottom);
1056
1082
  const [, bump] = (0, import_react5.useState)(0);
1057
1083
  const termRows = process.stdout.rows || 24;
1058
1084
  const viewportRows = Math.max(
@@ -1071,39 +1097,40 @@ function ScrollableText({
1071
1097
  const clamped = Math.min(Math.max(0, scrollTop), maxScroll);
1072
1098
  const visible = allLines.slice(clamped, clamped + viewportRows);
1073
1099
  const bar = needsBar ? scrollbarGlyphs(viewportRows, allLines.length, clamped) : null;
1074
- (0, import_react5.useEffect)(() => {
1075
- setScrollTop((s) => Math.min(s, maxScroll));
1076
- }, [maxScroll]);
1077
1100
  const maxScrollRef = (0, import_react5.useRef)(maxScroll);
1078
1101
  const pageSizeRef = (0, import_react5.useRef)(viewportRows);
1102
+ const scrollTopRef = (0, import_react5.useRef)(scrollTop);
1103
+ const followingRef = (0, import_react5.useRef)(following);
1079
1104
  maxScrollRef.current = maxScroll;
1080
1105
  pageSizeRef.current = viewportRows;
1106
+ scrollTopRef.current = scrollTop;
1107
+ followingRef.current = following;
1108
+ (0, import_react5.useEffect)(() => {
1109
+ const next = nextScrollAfterContentChange({
1110
+ following: followBottom && followingRef.current,
1111
+ scrollTop: scrollTopRef.current,
1112
+ maxScroll
1113
+ });
1114
+ if (next.scrollTop !== scrollTopRef.current) setScrollTop(next.scrollTop);
1115
+ if (followBottom && next.following !== followingRef.current) setFollowing(next.following);
1116
+ }, [maxScroll, followBottom]);
1117
+ const applyUserScroll = (delta) => {
1118
+ const next = nextScrollAfterUserMove(scrollTopRef.current, maxScrollRef.current, delta);
1119
+ setScrollTop(next.scrollTop);
1120
+ if (followBottom) setFollowing(next.following);
1121
+ bump((n) => n + 1);
1122
+ ctx?.update?.();
1123
+ };
1081
1124
  (0, import_react5.useEffect)(() => {
1082
1125
  if (!ctx || !bindKeys) return void 0;
1083
1126
  ctx.setKeyBinding(SCROLL_KEYS);
1084
- ctx.setAction("scrollUp", () => {
1085
- setScrollTop((s) => Math.max(0, s - 1));
1086
- bump((n) => n + 1);
1087
- ctx.update?.();
1088
- });
1089
- ctx.setAction("scrollDown", () => {
1090
- setScrollTop((s) => Math.min(maxScrollRef.current, s + 1));
1091
- bump((n) => n + 1);
1092
- ctx.update?.();
1093
- });
1094
- ctx.setAction("pageUp", () => {
1095
- setScrollTop((s) => Math.max(0, s - pageSizeRef.current));
1096
- bump((n) => n + 1);
1097
- ctx.update?.();
1098
- });
1099
- ctx.setAction("pageDown", () => {
1100
- setScrollTop((s) => Math.min(maxScrollRef.current, s + pageSizeRef.current));
1101
- bump((n) => n + 1);
1102
- ctx.update?.();
1103
- });
1127
+ ctx.setAction("scrollUp", () => applyUserScroll(-1));
1128
+ ctx.setAction("scrollDown", () => applyUserScroll(1));
1129
+ ctx.setAction("pageUp", () => applyUserScroll(-pageSizeRef.current));
1130
+ ctx.setAction("pageDown", () => applyUserScroll(pageSizeRef.current));
1104
1131
  return void 0;
1105
- }, [ctx, bindKeys]);
1106
- 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" : "");
1132
+ }, [ctx, bindKeys, followBottom]);
1133
+ 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" : "");
1107
1134
  const rowNodes = visible.map((line, i) => {
1108
1135
  const body = padEndVisible(line, textWidth);
1109
1136
  const glyph = bar ? bar[i] ?? BAR_TRACK : showScrollbar ? " " : "";
@@ -1143,6 +1170,7 @@ var init_scrollable_text = __esm({
1143
1170
  import_ink5 = require("ink");
1144
1171
  init_components();
1145
1172
  init_scrollbar();
1173
+ init_follow_scroll();
1146
1174
  init_scrollbar();
1147
1175
  h5 = import_react5.createElement;
1148
1176
  SCROLL_KEYS = [
@@ -1311,10 +1339,14 @@ __export(screen_exports, {
1311
1339
  buildBreadcrumb: () => buildBreadcrumb,
1312
1340
  buildDetailBreadcrumb: () => buildDetailBreadcrumb,
1313
1341
  buildFooter: () => buildFooter,
1342
+ clampScroll: () => clampScroll,
1314
1343
  formatBindingKey: () => formatBindingKey,
1315
1344
  h: () => import_react6.createElement,
1345
+ isScrolledToBottom: () => isScrolledToBottom,
1316
1346
  load: () => load,
1317
1347
  memo: () => import_react6.memo,
1348
+ nextScrollAfterContentChange: () => nextScrollAfterContentChange,
1349
+ nextScrollAfterUserMove: () => nextScrollAfterUserMove,
1318
1350
  organizeFooterMessages: () => organizeFooterMessages,
1319
1351
  scrollbarGlyphs: () => scrollbarGlyphs,
1320
1352
  showListScreen: () => showListScreen,
@@ -1351,6 +1383,7 @@ var init_screen = __esm({
1351
1383
  init_components();
1352
1384
  init_ui_elements();
1353
1385
  init_scrollable_text();
1386
+ init_follow_scroll();
1354
1387
  init_scrollbar();
1355
1388
  init_key_bindings();
1356
1389
  init_utils();
@@ -2670,13 +2703,14 @@ var Logger = class _Logger {
2670
2703
  */
2671
2704
  progress(message, opts) {
2672
2705
  const { prefix, count, total } = opts;
2673
- const paddedTotal = String(total).length;
2706
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
2707
+ const paddedTotal = String(displayTotal).length;
2674
2708
  const paddedCount = String(count).padStart(paddedTotal, " ");
2675
2709
  const payload = {
2676
2710
  level: "progress",
2677
2711
  message,
2678
2712
  count: paddedCount,
2679
- total,
2713
+ total: displayTotal,
2680
2714
  prefix
2681
2715
  };
2682
2716
  const key = prefix ?? "";
@@ -2693,7 +2727,7 @@ var Logger = class _Logger {
2693
2727
  if (wantTimes) {
2694
2728
  let remaining = -1;
2695
2729
  if (itemsPerSec > 0) {
2696
- remaining = (total - count) / itemsPerSec;
2730
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
2697
2731
  }
2698
2732
  payload.elapsed = this.round(elapsedSeconds, 2);
2699
2733
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -2702,12 +2736,12 @@ var Logger = class _Logger {
2702
2736
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
2703
2737
  }
2704
2738
  }
2705
- if (count >= total) {
2739
+ if (count === total) {
2706
2740
  delete this.startTimes[key];
2707
2741
  delete this.startCounts[key];
2708
2742
  delete this.lastProgressTimes[key];
2709
2743
  }
2710
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
2744
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
2711
2745
  this.out(payload);
2712
2746
  if (this.options.progressThrottle && prefix) {
2713
2747
  this.lastProgressTimes[prefix] = Date.now();
@@ -2866,6 +2900,8 @@ function setup(opts = {}) {
2866
2900
  // a quick "show me the figured params and quit" that skips the flow's
2867
2901
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
2868
2902
  // cleanups are skipped); call it once components/params are resolved.
2903
+ _requestExitCode: null,
2904
+ requestExit: null,
2869
2905
  showUsedParamsIfNeeded: () => {
2870
2906
  const mode = params.getShowUsedParamsMode?.();
2871
2907
  if (mode !== "top" && mode !== "stop") return;
@@ -2876,6 +2912,9 @@ function setup(opts = {}) {
2876
2912
  }
2877
2913
  }
2878
2914
  };
2915
+ context.requestExit = (code = 0) => {
2916
+ context._requestExitCode = code;
2917
+ };
2879
2918
  logger.debug("[setup] completed successfully");
2880
2919
  return context;
2881
2920
  }
@@ -2911,9 +2950,25 @@ async function init(flow2, opts = {}) {
2911
2950
  if (cleanupRan) return;
2912
2951
  cleanupRan = true;
2913
2952
  const fns = [...ctx.cleanupFunctions].reverse();
2953
+ const budgetMs = 5e3;
2954
+ const started = Date.now();
2914
2955
  for (const fn of fns) {
2956
+ const left = budgetMs - (Date.now() - started);
2957
+ if (left <= 0) {
2958
+ ctx.logger.warn("[cleanup] budget exhausted \u2014 skipping remaining cleanup");
2959
+ break;
2960
+ }
2915
2961
  try {
2916
- await fn(ctx);
2962
+ await Promise.race([
2963
+ Promise.resolve(fn(ctx)),
2964
+ new Promise((_, reject) => {
2965
+ const t = setTimeout(
2966
+ () => reject(new Error(`cleanup timed out after ${left}ms`)),
2967
+ left
2968
+ );
2969
+ t.unref?.();
2970
+ })
2971
+ ]);
2917
2972
  } catch (error) {
2918
2973
  ctx.logger.warn("[cleanup] error in cleanup function:", error);
2919
2974
  }
@@ -3009,6 +3064,8 @@ async function init(flow2, opts = {}) {
3009
3064
  process.exit(process.exitCode);
3010
3065
  } else if (stop || kill) {
3011
3066
  process.exit(0);
3067
+ } else if (context._requestExitCode != null) {
3068
+ process.exit(context._requestExitCode);
3012
3069
  }
3013
3070
  }
3014
3071
  }
@@ -3132,7 +3189,7 @@ var CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3132
3189
  "57P02",
3133
3190
  "57P03"
3134
3191
  ]);
3135
- 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;
3192
+ 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;
3136
3193
  var Db = class _Db {
3137
3194
  static async init(context, options = {}) {
3138
3195
  const buildConfig = async () => {
@@ -3400,6 +3457,10 @@ var Db = class _Db {
3400
3457
  this.isConnected = false;
3401
3458
  this.queriesLog = [];
3402
3459
  this._reconnectPromise = null;
3460
+ this._closed = false;
3461
+ this._liveKnex = /* @__PURE__ */ new Set();
3462
+ this._reconnectCooldownUntil = 0;
3463
+ this._reconnectAcquireTimeoutMs = null;
3403
3464
  this.config = {
3404
3465
  testConnection: true,
3405
3466
  profile: false,
@@ -3484,6 +3545,9 @@ var Db = class _Db {
3484
3545
  return null;
3485
3546
  }
3486
3547
  async connect() {
3548
+ if (this._closed) {
3549
+ throw new ParamError("Db: Connection closed");
3550
+ }
3487
3551
  if (this.isConnected && this.knexInstance) {
3488
3552
  this.logger.warn?.("[Db] Already connected");
3489
3553
  return;
@@ -3499,22 +3563,46 @@ var Db = class _Db {
3499
3563
  connectionString: this.config.connectionString,
3500
3564
  family: 4
3501
3565
  };
3566
+ const acquireTimeout = this._reconnectAcquireTimeoutMs ?? this.config.acquireConnectionTimeout;
3502
3567
  this.knexInstance = (0, import_knex.default)({
3503
3568
  client,
3504
3569
  connection: connectionConfig,
3505
3570
  pool: this.config.pool,
3506
- acquireConnectionTimeout: this.config.acquireConnectionTimeout,
3571
+ acquireConnectionTimeout: acquireTimeout,
3507
3572
  ...this.config.ssl && { ssl: this.config.ssl }
3508
3573
  });
3574
+ this._liveKnex.add(this.knexInstance);
3575
+ this.knexInstance.on?.("error", (err) => {
3576
+ if (this._closed) return;
3577
+ this.logger.warn?.(
3578
+ `[Db] Connection error (${this.getErrorMessage(err)})`
3579
+ );
3580
+ });
3581
+ if (this._closed) {
3582
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
3583
+ this.knexInstance = null;
3584
+ throw new ParamError("Db: Connection closed");
3585
+ }
3509
3586
  if (this.config.profile) {
3510
3587
  this.attachProfiler();
3511
3588
  }
3512
3589
  if (this.config.testConnection) {
3513
3590
  await this.testConnection();
3514
3591
  }
3592
+ if (this._closed) {
3593
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
3594
+ this.knexInstance = null;
3595
+ throw new ParamError("Db: Connection closed");
3596
+ }
3515
3597
  this.isConnected = true;
3516
3598
  this.logger.debug?.(formatDbConnectMessage(this.config.name, this.config.connectionString));
3517
3599
  } catch (error) {
3600
+ const failed = this.knexInstance;
3601
+ this.knexInstance = null;
3602
+ this.isConnected = false;
3603
+ if (failed) {
3604
+ await this._destroyKnex(failed, "connect failed");
3605
+ }
3518
3606
  if (error instanceof ParamError) {
3519
3607
  throw error;
3520
3608
  }
@@ -3522,20 +3610,42 @@ var Db = class _Db {
3522
3610
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
3523
3611
  }
3524
3612
  }
3525
- async disconnect() {
3526
- if (!this.knexInstance) {
3527
- return;
3528
- }
3613
+ /**
3614
+ * Destroy a knex pool without hanging exit on stuck TCP sockets (ETIMEDOUT).
3615
+ * @param {import("knex").Knex | null | undefined} knexInst
3616
+ * @param {string} [reason]
3617
+ * @param {number} [timeoutMs]
3618
+ */
3619
+ async _destroyKnex(knexInst, reason = "destroy", timeoutMs = 3e3) {
3620
+ if (!knexInst || typeof knexInst.destroy !== "function") return;
3621
+ this._liveKnex.delete(knexInst);
3529
3622
  try {
3530
- await this.knexInstance.destroy();
3531
- this.knexInstance = null;
3532
- this.isConnected = false;
3533
- this.queriesLog = [];
3534
- this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
3623
+ await Promise.race([
3624
+ knexInst.destroy(),
3625
+ new Promise((_, reject) => {
3626
+ const t = setTimeout(
3627
+ () => reject(new Error(`Db: ${reason} timed out after ${timeoutMs}ms`)),
3628
+ timeoutMs
3629
+ );
3630
+ t.unref?.();
3631
+ })
3632
+ ]);
3535
3633
  } catch (error) {
3536
- const errorMsg = this.getErrorMessage(error);
3537
- this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
3538
- throw error;
3634
+ this.logger.debug?.(
3635
+ `[Db] ${reason}: ${this.getErrorMessage(error)}`
3636
+ );
3637
+ }
3638
+ }
3639
+ async disconnect() {
3640
+ this._closed = true;
3641
+ this.isConnected = false;
3642
+ this.knexInstance = null;
3643
+ this.queriesLog = [];
3644
+ const all = [...this._liveKnex];
3645
+ this._liveKnex.clear();
3646
+ await Promise.all(all.map((inst) => this._destroyKnex(inst, "disconnect")));
3647
+ if (all.length > 0) {
3648
+ this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
3539
3649
  }
3540
3650
  }
3541
3651
  /**
@@ -3558,27 +3668,39 @@ var Db = class _Db {
3558
3668
  }
3559
3669
  /**
3560
3670
  * Destroy the current knex pool and open a new one. Concurrent callers share one attempt.
3671
+ * No-ops once disconnect() has closed the handle.
3561
3672
  */
3562
3673
  async reconnect() {
3674
+ if (this._closed) {
3675
+ return;
3676
+ }
3677
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
3678
+ return;
3679
+ }
3563
3680
  if (this._reconnectPromise) {
3564
3681
  await this._reconnectPromise;
3565
3682
  return;
3566
3683
  }
3567
3684
  this._reconnectPromise = (async () => {
3685
+ if (this._closed) return;
3568
3686
  const old = this.knexInstance;
3569
3687
  this.isConnected = false;
3570
3688
  this.knexInstance = null;
3571
3689
  this.queriesLog = [];
3572
3690
  if (old) {
3573
- try {
3574
- await old.destroy();
3575
- } catch (error) {
3576
- this.logger.debug?.(
3577
- `[Db] destroy during reconnect: ${this.getErrorMessage(error)}`
3578
- );
3579
- }
3691
+ await this._destroyKnex(old, "destroy during reconnect");
3692
+ }
3693
+ if (this._closed) return;
3694
+ this._reconnectAcquireTimeoutMs = 3e3;
3695
+ try {
3696
+ await this.connect();
3697
+ this._reconnectCooldownUntil = 0;
3698
+ } catch (error) {
3699
+ this._reconnectCooldownUntil = Date.now() + 5e3;
3700
+ throw error;
3701
+ } finally {
3702
+ this._reconnectAcquireTimeoutMs = null;
3580
3703
  }
3581
- await this.connect();
3582
3704
  })();
3583
3705
  try {
3584
3706
  await this._reconnectPromise;
@@ -3587,10 +3709,19 @@ var Db = class _Db {
3587
3709
  }
3588
3710
  }
3589
3711
  async reconnectAfterConnectionError(error) {
3712
+ if (this._closed) {
3713
+ return;
3714
+ }
3715
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
3716
+ return;
3717
+ }
3590
3718
  this.logger.warn?.(
3591
3719
  `[Db] Connection lost (${this.getErrorMessage(error)}) \u2014 reconnecting\u2026`
3592
3720
  );
3593
- await this.reconnect();
3721
+ try {
3722
+ await this.reconnect();
3723
+ } catch {
3724
+ }
3594
3725
  }
3595
3726
  /**
3596
3727
  * Run `fn`; on a connection error, reconnect once (by default) and retry `fn`.
@@ -3603,10 +3734,13 @@ var Db = class _Db {
3603
3734
  return await fn();
3604
3735
  } catch (error) {
3605
3736
  lastError = error;
3606
- if (!this.isConnectionError(error) || attempt >= retries) {
3737
+ if (this._closed || !this.isConnectionError(error) || attempt >= retries) {
3607
3738
  throw error;
3608
3739
  }
3609
3740
  await this.reconnectAfterConnectionError(error);
3741
+ if (this._closed) {
3742
+ throw error;
3743
+ }
3610
3744
  }
3611
3745
  }
3612
3746
  throw lastError;
@@ -3630,10 +3764,13 @@ var Db = class _Db {
3630
3764
  try {
3631
3765
  return await protoThen.call(builder);
3632
3766
  } catch (error) {
3633
- if (!inst.isConnectionError(error)) {
3767
+ if (inst._closed || !inst.isConnectionError(error)) {
3634
3768
  throw error;
3635
3769
  }
3636
3770
  await inst.reconnectAfterConnectionError(error);
3771
+ if (inst._closed || !inst.knexInstance) {
3772
+ throw error;
3773
+ }
3637
3774
  if (typeof builder.clone === "function") {
3638
3775
  const retry = builder.clone();
3639
3776
  retry.client = inst.knexInstance.client;