@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.
package/dist/index.cjs CHANGED
@@ -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 = [
@@ -1298,6 +1326,7 @@ var init_screen = __esm({
1298
1326
  init_components();
1299
1327
  init_ui_elements();
1300
1328
  init_scrollable_text();
1329
+ init_follow_scroll();
1301
1330
  init_scrollbar();
1302
1331
  init_key_bindings();
1303
1332
  init_utils();
@@ -1377,6 +1406,7 @@ __export(src_exports, {
1377
1406
  buildDetailBreadcrumb: () => buildDetailBreadcrumb,
1378
1407
  buildFooter: () => buildFooter,
1379
1408
  bumpPatchVersion: () => bumpPatchVersion,
1409
+ clampScroll: () => clampScroll,
1380
1410
  cloneRepo: () => cloneRepo,
1381
1411
  coerceRuntimeValue: () => coerceRuntimeValue,
1382
1412
  controlLaneTaskNames: () => controlLaneTaskNames,
@@ -1414,6 +1444,7 @@ __export(src_exports, {
1414
1444
  ipcFileLogsTableNameForSourceResource: () => ipcFileLogsTableNameForSourceResource,
1415
1445
  isFreshOnline: () => isFreshOnline,
1416
1446
  isPidAlive: () => isPidAlive,
1447
+ isScrolledToBottom: () => isScrolledToBottom,
1417
1448
  joiEdateType: () => joiEdateType,
1418
1449
  joiStringArrayType: () => joiStringArrayType,
1419
1450
  listAliveRunnerHeartbeats: () => listServicesRegistry,
@@ -1427,6 +1458,8 @@ __export(src_exports, {
1427
1458
  memo: () => import_react6.memo,
1428
1459
  mergeAllowedTasksWithServiceTasks: () => mergeAllowedTasksWithServiceTasks,
1429
1460
  mergeRuntimeParamSpecs: () => mergeRuntimeParamSpecs,
1461
+ nextScrollAfterContentChange: () => nextScrollAfterContentChange,
1462
+ nextScrollAfterUserMove: () => nextScrollAfterUserMove,
1430
1463
  nextTimeMatch: () => nextTimeMatch,
1431
1464
  normalizeAllowedTasks: () => normalizeAllowedTasks,
1432
1465
  npmEnv: () => npmEnv,
@@ -3972,7 +4005,7 @@ var CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3972
4005
  "57P02",
3973
4006
  "57P03"
3974
4007
  ]);
3975
- 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;
4008
+ 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;
3976
4009
  var Db = class _Db {
3977
4010
  static async init(context, options = {}) {
3978
4011
  const buildConfig = async () => {
@@ -4240,6 +4273,10 @@ var Db = class _Db {
4240
4273
  this.isConnected = false;
4241
4274
  this.queriesLog = [];
4242
4275
  this._reconnectPromise = null;
4276
+ this._closed = false;
4277
+ this._liveKnex = /* @__PURE__ */ new Set();
4278
+ this._reconnectCooldownUntil = 0;
4279
+ this._reconnectAcquireTimeoutMs = null;
4243
4280
  this.config = {
4244
4281
  testConnection: true,
4245
4282
  profile: false,
@@ -4324,6 +4361,9 @@ var Db = class _Db {
4324
4361
  return null;
4325
4362
  }
4326
4363
  async connect() {
4364
+ if (this._closed) {
4365
+ throw new ParamError("Db: Connection closed");
4366
+ }
4327
4367
  if (this.isConnected && this.knexInstance) {
4328
4368
  this.logger.warn?.("[Db] Already connected");
4329
4369
  return;
@@ -4339,22 +4379,46 @@ var Db = class _Db {
4339
4379
  connectionString: this.config.connectionString,
4340
4380
  family: 4
4341
4381
  };
4382
+ const acquireTimeout = this._reconnectAcquireTimeoutMs ?? this.config.acquireConnectionTimeout;
4342
4383
  this.knexInstance = (0, import_knex.default)({
4343
4384
  client,
4344
4385
  connection: connectionConfig,
4345
4386
  pool: this.config.pool,
4346
- acquireConnectionTimeout: this.config.acquireConnectionTimeout,
4387
+ acquireConnectionTimeout: acquireTimeout,
4347
4388
  ...this.config.ssl && { ssl: this.config.ssl }
4348
4389
  });
4390
+ this._liveKnex.add(this.knexInstance);
4391
+ this.knexInstance.on?.("error", (err) => {
4392
+ if (this._closed) return;
4393
+ this.logger.warn?.(
4394
+ `[Db] Connection error (${this.getErrorMessage(err)})`
4395
+ );
4396
+ });
4397
+ if (this._closed) {
4398
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
4399
+ this.knexInstance = null;
4400
+ throw new ParamError("Db: Connection closed");
4401
+ }
4349
4402
  if (this.config.profile) {
4350
4403
  this.attachProfiler();
4351
4404
  }
4352
4405
  if (this.config.testConnection) {
4353
4406
  await this.testConnection();
4354
4407
  }
4408
+ if (this._closed) {
4409
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
4410
+ this.knexInstance = null;
4411
+ throw new ParamError("Db: Connection closed");
4412
+ }
4355
4413
  this.isConnected = true;
4356
4414
  this.logger.debug?.(formatDbConnectMessage(this.config.name, this.config.connectionString));
4357
4415
  } catch (error) {
4416
+ const failed = this.knexInstance;
4417
+ this.knexInstance = null;
4418
+ this.isConnected = false;
4419
+ if (failed) {
4420
+ await this._destroyKnex(failed, "connect failed");
4421
+ }
4358
4422
  if (error instanceof ParamError) {
4359
4423
  throw error;
4360
4424
  }
@@ -4362,20 +4426,42 @@ var Db = class _Db {
4362
4426
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
4363
4427
  }
4364
4428
  }
4365
- async disconnect() {
4366
- if (!this.knexInstance) {
4367
- return;
4368
- }
4429
+ /**
4430
+ * Destroy a knex pool without hanging exit on stuck TCP sockets (ETIMEDOUT).
4431
+ * @param {import("knex").Knex | null | undefined} knexInst
4432
+ * @param {string} [reason]
4433
+ * @param {number} [timeoutMs]
4434
+ */
4435
+ async _destroyKnex(knexInst, reason = "destroy", timeoutMs = 3e3) {
4436
+ if (!knexInst || typeof knexInst.destroy !== "function") return;
4437
+ this._liveKnex.delete(knexInst);
4369
4438
  try {
4370
- await this.knexInstance.destroy();
4371
- this.knexInstance = null;
4372
- this.isConnected = false;
4373
- this.queriesLog = [];
4374
- this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
4439
+ await Promise.race([
4440
+ knexInst.destroy(),
4441
+ new Promise((_, reject) => {
4442
+ const t = setTimeout(
4443
+ () => reject(new Error(`Db: ${reason} timed out after ${timeoutMs}ms`)),
4444
+ timeoutMs
4445
+ );
4446
+ t.unref?.();
4447
+ })
4448
+ ]);
4375
4449
  } catch (error) {
4376
- const errorMsg = this.getErrorMessage(error);
4377
- this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
4378
- throw error;
4450
+ this.logger.debug?.(
4451
+ `[Db] ${reason}: ${this.getErrorMessage(error)}`
4452
+ );
4453
+ }
4454
+ }
4455
+ async disconnect() {
4456
+ this._closed = true;
4457
+ this.isConnected = false;
4458
+ this.knexInstance = null;
4459
+ this.queriesLog = [];
4460
+ const all = [...this._liveKnex];
4461
+ this._liveKnex.clear();
4462
+ await Promise.all(all.map((inst) => this._destroyKnex(inst, "disconnect")));
4463
+ if (all.length > 0) {
4464
+ this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
4379
4465
  }
4380
4466
  }
4381
4467
  /**
@@ -4398,27 +4484,39 @@ var Db = class _Db {
4398
4484
  }
4399
4485
  /**
4400
4486
  * Destroy the current knex pool and open a new one. Concurrent callers share one attempt.
4487
+ * No-ops once disconnect() has closed the handle.
4401
4488
  */
4402
4489
  async reconnect() {
4490
+ if (this._closed) {
4491
+ return;
4492
+ }
4493
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
4494
+ return;
4495
+ }
4403
4496
  if (this._reconnectPromise) {
4404
4497
  await this._reconnectPromise;
4405
4498
  return;
4406
4499
  }
4407
4500
  this._reconnectPromise = (async () => {
4501
+ if (this._closed) return;
4408
4502
  const old = this.knexInstance;
4409
4503
  this.isConnected = false;
4410
4504
  this.knexInstance = null;
4411
4505
  this.queriesLog = [];
4412
4506
  if (old) {
4413
- try {
4414
- await old.destroy();
4415
- } catch (error) {
4416
- this.logger.debug?.(
4417
- `[Db] destroy during reconnect: ${this.getErrorMessage(error)}`
4418
- );
4419
- }
4507
+ await this._destroyKnex(old, "destroy during reconnect");
4508
+ }
4509
+ if (this._closed) return;
4510
+ this._reconnectAcquireTimeoutMs = 3e3;
4511
+ try {
4512
+ await this.connect();
4513
+ this._reconnectCooldownUntil = 0;
4514
+ } catch (error) {
4515
+ this._reconnectCooldownUntil = Date.now() + 5e3;
4516
+ throw error;
4517
+ } finally {
4518
+ this._reconnectAcquireTimeoutMs = null;
4420
4519
  }
4421
- await this.connect();
4422
4520
  })();
4423
4521
  try {
4424
4522
  await this._reconnectPromise;
@@ -4427,10 +4525,19 @@ var Db = class _Db {
4427
4525
  }
4428
4526
  }
4429
4527
  async reconnectAfterConnectionError(error) {
4528
+ if (this._closed) {
4529
+ return;
4530
+ }
4531
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
4532
+ return;
4533
+ }
4430
4534
  this.logger.warn?.(
4431
4535
  `[Db] Connection lost (${this.getErrorMessage(error)}) \u2014 reconnecting\u2026`
4432
4536
  );
4433
- await this.reconnect();
4537
+ try {
4538
+ await this.reconnect();
4539
+ } catch {
4540
+ }
4434
4541
  }
4435
4542
  /**
4436
4543
  * Run `fn`; on a connection error, reconnect once (by default) and retry `fn`.
@@ -4443,10 +4550,13 @@ var Db = class _Db {
4443
4550
  return await fn();
4444
4551
  } catch (error) {
4445
4552
  lastError = error;
4446
- if (!this.isConnectionError(error) || attempt >= retries) {
4553
+ if (this._closed || !this.isConnectionError(error) || attempt >= retries) {
4447
4554
  throw error;
4448
4555
  }
4449
4556
  await this.reconnectAfterConnectionError(error);
4557
+ if (this._closed) {
4558
+ throw error;
4559
+ }
4450
4560
  }
4451
4561
  }
4452
4562
  throw lastError;
@@ -4470,10 +4580,13 @@ var Db = class _Db {
4470
4580
  try {
4471
4581
  return await protoThen.call(builder);
4472
4582
  } catch (error) {
4473
- if (!inst.isConnectionError(error)) {
4583
+ if (inst._closed || !inst.isConnectionError(error)) {
4474
4584
  throw error;
4475
4585
  }
4476
4586
  await inst.reconnectAfterConnectionError(error);
4587
+ if (inst._closed || !inst.knexInstance) {
4588
+ throw error;
4589
+ }
4477
4590
  if (typeof builder.clone === "function") {
4478
4591
  const retry = builder.clone();
4479
4592
  retry.client = inst.knexInstance.client;
@@ -5495,13 +5608,14 @@ var Logger = class _Logger {
5495
5608
  */
5496
5609
  progress(message, opts) {
5497
5610
  const { prefix, count, total } = opts;
5498
- const paddedTotal = String(total).length;
5611
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
5612
+ const paddedTotal = String(displayTotal).length;
5499
5613
  const paddedCount = String(count).padStart(paddedTotal, " ");
5500
5614
  const payload = {
5501
5615
  level: "progress",
5502
5616
  message,
5503
5617
  count: paddedCount,
5504
- total,
5618
+ total: displayTotal,
5505
5619
  prefix
5506
5620
  };
5507
5621
  const key = prefix ?? "";
@@ -5518,7 +5632,7 @@ var Logger = class _Logger {
5518
5632
  if (wantTimes) {
5519
5633
  let remaining = -1;
5520
5634
  if (itemsPerSec > 0) {
5521
- remaining = (total - count) / itemsPerSec;
5635
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
5522
5636
  }
5523
5637
  payload.elapsed = this.round(elapsedSeconds, 2);
5524
5638
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -5527,12 +5641,12 @@ var Logger = class _Logger {
5527
5641
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
5528
5642
  }
5529
5643
  }
5530
- if (count >= total) {
5644
+ if (count === total) {
5531
5645
  delete this.startTimes[key];
5532
5646
  delete this.startCounts[key];
5533
5647
  delete this.lastProgressTimes[key];
5534
5648
  }
5535
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
5649
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
5536
5650
  this.out(payload);
5537
5651
  if (this.options.progressThrottle && prefix) {
5538
5652
  this.lastProgressTimes[prefix] = Date.now();
@@ -5691,6 +5805,8 @@ function setup(opts = {}) {
5691
5805
  // a quick "show me the figured params and quit" that skips the flow's
5692
5806
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
5693
5807
  // cleanups are skipped); call it once components/params are resolved.
5808
+ _requestExitCode: null,
5809
+ requestExit: null,
5694
5810
  showUsedParamsIfNeeded: () => {
5695
5811
  const mode = params.getShowUsedParamsMode?.();
5696
5812
  if (mode !== "top" && mode !== "stop") return;
@@ -5701,6 +5817,9 @@ function setup(opts = {}) {
5701
5817
  }
5702
5818
  }
5703
5819
  };
5820
+ context.requestExit = (code = 0) => {
5821
+ context._requestExitCode = code;
5822
+ };
5704
5823
  logger.debug("[setup] completed successfully");
5705
5824
  return context;
5706
5825
  }
@@ -10265,6 +10384,7 @@ var TasksManager = class _TasksManager {
10265
10384
  buildDetailBreadcrumb,
10266
10385
  buildFooter,
10267
10386
  bumpPatchVersion,
10387
+ clampScroll,
10268
10388
  cloneRepo,
10269
10389
  coerceRuntimeValue,
10270
10390
  controlLaneTaskNames,
@@ -10302,6 +10422,7 @@ var TasksManager = class _TasksManager {
10302
10422
  ipcFileLogsTableNameForSourceResource,
10303
10423
  isFreshOnline,
10304
10424
  isPidAlive,
10425
+ isScrolledToBottom,
10305
10426
  joiEdateType,
10306
10427
  joiStringArrayType,
10307
10428
  listAliveRunnerHeartbeats,
@@ -10315,6 +10436,8 @@ var TasksManager = class _TasksManager {
10315
10436
  memo,
10316
10437
  mergeAllowedTasksWithServiceTasks,
10317
10438
  mergeRuntimeParamSpecs,
10439
+ nextScrollAfterContentChange,
10440
+ nextScrollAfterUserMove,
10318
10441
  nextTimeMatch,
10319
10442
  normalizeAllowedTasks,
10320
10443
  npmEnv,