@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.js CHANGED
@@ -996,6 +996,30 @@ var init_ui_elements = __esm({
996
996
  }
997
997
  });
998
998
 
999
+ // src/screen/follow-scroll.js
1000
+ function clampScroll(scrollTop, maxScroll) {
1001
+ const max = Math.max(0, Number(maxScroll) || 0);
1002
+ const top = Number(scrollTop) || 0;
1003
+ return Math.min(Math.max(0, top), max);
1004
+ }
1005
+ function isScrolledToBottom(scrollTop, maxScroll) {
1006
+ return clampScroll(scrollTop, maxScroll) >= Math.max(0, Number(maxScroll) || 0);
1007
+ }
1008
+ function nextScrollAfterUserMove(scrollTop, maxScroll, delta) {
1009
+ const next = clampScroll((Number(scrollTop) || 0) + (Number(delta) || 0), maxScroll);
1010
+ return { scrollTop: next, following: isScrolledToBottom(next, maxScroll) };
1011
+ }
1012
+ function nextScrollAfterContentChange({ following, scrollTop, maxScroll }) {
1013
+ const max = Math.max(0, Number(maxScroll) || 0);
1014
+ if (following) return { scrollTop: max, following: true };
1015
+ const next = clampScroll(scrollTop, max);
1016
+ return { scrollTop: next, following: isScrolledToBottom(next, max) };
1017
+ }
1018
+ var init_follow_scroll = __esm({
1019
+ "src/screen/follow-scroll.js"() {
1020
+ }
1021
+ });
1022
+
999
1023
  // src/screen/scrollable-text.js
1000
1024
  import { useState as useState3, useEffect as useEffect2, useMemo, useRef as useRef2, createElement as createElement2 } from "react";
1001
1025
  import { Box as Box4, Text as Text5 } from "ink";
@@ -1030,9 +1054,11 @@ function ScrollableText({
1030
1054
  showScrollbar = true,
1031
1055
  showStatus = true,
1032
1056
  bindKeys = true,
1033
- header = null
1057
+ header = null,
1058
+ followBottom = false
1034
1059
  }) {
1035
1060
  const [scrollTop, setScrollTop] = useState3(0);
1061
+ const [following, setFollowing] = useState3(() => !!followBottom);
1036
1062
  const [, bump] = useState3(0);
1037
1063
  const termRows = process.stdout.rows || 24;
1038
1064
  const viewportRows = Math.max(
@@ -1051,39 +1077,40 @@ function ScrollableText({
1051
1077
  const clamped = Math.min(Math.max(0, scrollTop), maxScroll);
1052
1078
  const visible = allLines.slice(clamped, clamped + viewportRows);
1053
1079
  const bar = needsBar ? scrollbarGlyphs(viewportRows, allLines.length, clamped) : null;
1054
- useEffect2(() => {
1055
- setScrollTop((s) => Math.min(s, maxScroll));
1056
- }, [maxScroll]);
1057
1080
  const maxScrollRef = useRef2(maxScroll);
1058
1081
  const pageSizeRef = useRef2(viewportRows);
1082
+ const scrollTopRef = useRef2(scrollTop);
1083
+ const followingRef = useRef2(following);
1059
1084
  maxScrollRef.current = maxScroll;
1060
1085
  pageSizeRef.current = viewportRows;
1086
+ scrollTopRef.current = scrollTop;
1087
+ followingRef.current = following;
1088
+ useEffect2(() => {
1089
+ const next = nextScrollAfterContentChange({
1090
+ following: followBottom && followingRef.current,
1091
+ scrollTop: scrollTopRef.current,
1092
+ maxScroll
1093
+ });
1094
+ if (next.scrollTop !== scrollTopRef.current) setScrollTop(next.scrollTop);
1095
+ if (followBottom && next.following !== followingRef.current) setFollowing(next.following);
1096
+ }, [maxScroll, followBottom]);
1097
+ const applyUserScroll = (delta) => {
1098
+ const next = nextScrollAfterUserMove(scrollTopRef.current, maxScrollRef.current, delta);
1099
+ setScrollTop(next.scrollTop);
1100
+ if (followBottom) setFollowing(next.following);
1101
+ bump((n) => n + 1);
1102
+ ctx?.update?.();
1103
+ };
1061
1104
  useEffect2(() => {
1062
1105
  if (!ctx || !bindKeys) return void 0;
1063
1106
  ctx.setKeyBinding(SCROLL_KEYS);
1064
- ctx.setAction("scrollUp", () => {
1065
- setScrollTop((s) => Math.max(0, s - 1));
1066
- bump((n) => n + 1);
1067
- ctx.update?.();
1068
- });
1069
- ctx.setAction("scrollDown", () => {
1070
- setScrollTop((s) => Math.min(maxScrollRef.current, s + 1));
1071
- bump((n) => n + 1);
1072
- ctx.update?.();
1073
- });
1074
- ctx.setAction("pageUp", () => {
1075
- setScrollTop((s) => Math.max(0, s - pageSizeRef.current));
1076
- bump((n) => n + 1);
1077
- ctx.update?.();
1078
- });
1079
- ctx.setAction("pageDown", () => {
1080
- setScrollTop((s) => Math.min(maxScrollRef.current, s + pageSizeRef.current));
1081
- bump((n) => n + 1);
1082
- ctx.update?.();
1083
- });
1107
+ ctx.setAction("scrollUp", () => applyUserScroll(-1));
1108
+ ctx.setAction("scrollDown", () => applyUserScroll(1));
1109
+ ctx.setAction("pageUp", () => applyUserScroll(-pageSizeRef.current));
1110
+ ctx.setAction("pageDown", () => applyUserScroll(pageSizeRef.current));
1084
1111
  return void 0;
1085
- }, [ctx, bindKeys]);
1086
- 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" : "");
1112
+ }, [ctx, bindKeys, followBottom]);
1113
+ 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" : "");
1087
1114
  const rowNodes = visible.map((line, i) => {
1088
1115
  const body = padEndVisible(line, textWidth);
1089
1116
  const glyph = bar ? bar[i] ?? BAR_TRACK : showScrollbar ? " " : "";
@@ -1121,6 +1148,7 @@ var init_scrollable_text = __esm({
1121
1148
  "src/screen/scrollable-text.js"() {
1122
1149
  init_components();
1123
1150
  init_scrollbar();
1151
+ init_follow_scroll();
1124
1152
  init_scrollbar();
1125
1153
  h5 = createElement2;
1126
1154
  SCROLL_KEYS = [
@@ -1279,6 +1307,7 @@ var init_screen = __esm({
1279
1307
  init_components();
1280
1308
  init_ui_elements();
1281
1309
  init_scrollable_text();
1310
+ init_follow_scroll();
1282
1311
  init_scrollbar();
1283
1312
  init_key_bindings();
1284
1313
  init_utils();
@@ -3760,7 +3789,7 @@ var CONNECTION_ERROR_CODES = /* @__PURE__ */ new Set([
3760
3789
  "57P02",
3761
3790
  "57P03"
3762
3791
  ]);
3763
- 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;
3792
+ 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;
3764
3793
  var Db = class _Db {
3765
3794
  static async init(context, options = {}) {
3766
3795
  const buildConfig = async () => {
@@ -4028,6 +4057,10 @@ var Db = class _Db {
4028
4057
  this.isConnected = false;
4029
4058
  this.queriesLog = [];
4030
4059
  this._reconnectPromise = null;
4060
+ this._closed = false;
4061
+ this._liveKnex = /* @__PURE__ */ new Set();
4062
+ this._reconnectCooldownUntil = 0;
4063
+ this._reconnectAcquireTimeoutMs = null;
4031
4064
  this.config = {
4032
4065
  testConnection: true,
4033
4066
  profile: false,
@@ -4112,6 +4145,9 @@ var Db = class _Db {
4112
4145
  return null;
4113
4146
  }
4114
4147
  async connect() {
4148
+ if (this._closed) {
4149
+ throw new ParamError("Db: Connection closed");
4150
+ }
4115
4151
  if (this.isConnected && this.knexInstance) {
4116
4152
  this.logger.warn?.("[Db] Already connected");
4117
4153
  return;
@@ -4127,22 +4163,46 @@ var Db = class _Db {
4127
4163
  connectionString: this.config.connectionString,
4128
4164
  family: 4
4129
4165
  };
4166
+ const acquireTimeout = this._reconnectAcquireTimeoutMs ?? this.config.acquireConnectionTimeout;
4130
4167
  this.knexInstance = knex({
4131
4168
  client,
4132
4169
  connection: connectionConfig,
4133
4170
  pool: this.config.pool,
4134
- acquireConnectionTimeout: this.config.acquireConnectionTimeout,
4171
+ acquireConnectionTimeout: acquireTimeout,
4135
4172
  ...this.config.ssl && { ssl: this.config.ssl }
4136
4173
  });
4174
+ this._liveKnex.add(this.knexInstance);
4175
+ this.knexInstance.on?.("error", (err) => {
4176
+ if (this._closed) return;
4177
+ this.logger.warn?.(
4178
+ `[Db] Connection error (${this.getErrorMessage(err)})`
4179
+ );
4180
+ });
4181
+ if (this._closed) {
4182
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
4183
+ this.knexInstance = null;
4184
+ throw new ParamError("Db: Connection closed");
4185
+ }
4137
4186
  if (this.config.profile) {
4138
4187
  this.attachProfiler();
4139
4188
  }
4140
4189
  if (this.config.testConnection) {
4141
4190
  await this.testConnection();
4142
4191
  }
4192
+ if (this._closed) {
4193
+ await this._destroyKnex(this.knexInstance, "connect aborted (closed)");
4194
+ this.knexInstance = null;
4195
+ throw new ParamError("Db: Connection closed");
4196
+ }
4143
4197
  this.isConnected = true;
4144
4198
  this.logger.debug?.(formatDbConnectMessage(this.config.name, this.config.connectionString));
4145
4199
  } catch (error) {
4200
+ const failed = this.knexInstance;
4201
+ this.knexInstance = null;
4202
+ this.isConnected = false;
4203
+ if (failed) {
4204
+ await this._destroyKnex(failed, "connect failed");
4205
+ }
4146
4206
  if (error instanceof ParamError) {
4147
4207
  throw error;
4148
4208
  }
@@ -4150,20 +4210,42 @@ var Db = class _Db {
4150
4210
  throw new ParamError(`Db: Connection failed - ${errorMsg}`);
4151
4211
  }
4152
4212
  }
4153
- async disconnect() {
4154
- if (!this.knexInstance) {
4155
- return;
4156
- }
4213
+ /**
4214
+ * Destroy a knex pool without hanging exit on stuck TCP sockets (ETIMEDOUT).
4215
+ * @param {import("knex").Knex | null | undefined} knexInst
4216
+ * @param {string} [reason]
4217
+ * @param {number} [timeoutMs]
4218
+ */
4219
+ async _destroyKnex(knexInst, reason = "destroy", timeoutMs = 3e3) {
4220
+ if (!knexInst || typeof knexInst.destroy !== "function") return;
4221
+ this._liveKnex.delete(knexInst);
4157
4222
  try {
4158
- await this.knexInstance.destroy();
4159
- this.knexInstance = null;
4160
- this.isConnected = false;
4161
- this.queriesLog = [];
4162
- this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
4223
+ await Promise.race([
4224
+ knexInst.destroy(),
4225
+ new Promise((_, reject) => {
4226
+ const t = setTimeout(
4227
+ () => reject(new Error(`Db: ${reason} timed out after ${timeoutMs}ms`)),
4228
+ timeoutMs
4229
+ );
4230
+ t.unref?.();
4231
+ })
4232
+ ]);
4163
4233
  } catch (error) {
4164
- const errorMsg = this.getErrorMessage(error);
4165
- this.logger.error?.(`[Db] Error disconnecting: ${errorMsg}`);
4166
- throw error;
4234
+ this.logger.debug?.(
4235
+ `[Db] ${reason}: ${this.getErrorMessage(error)}`
4236
+ );
4237
+ }
4238
+ }
4239
+ async disconnect() {
4240
+ this._closed = true;
4241
+ this.isConnected = false;
4242
+ this.knexInstance = null;
4243
+ this.queriesLog = [];
4244
+ const all = [...this._liveKnex];
4245
+ this._liveKnex.clear();
4246
+ await Promise.all(all.map((inst) => this._destroyKnex(inst, "disconnect")));
4247
+ if (all.length > 0) {
4248
+ this.logger.debug?.(formatDbDisconnectMessage(this.config.name, this.config.connectionString));
4167
4249
  }
4168
4250
  }
4169
4251
  /**
@@ -4186,27 +4268,39 @@ var Db = class _Db {
4186
4268
  }
4187
4269
  /**
4188
4270
  * Destroy the current knex pool and open a new one. Concurrent callers share one attempt.
4271
+ * No-ops once disconnect() has closed the handle.
4189
4272
  */
4190
4273
  async reconnect() {
4274
+ if (this._closed) {
4275
+ return;
4276
+ }
4277
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
4278
+ return;
4279
+ }
4191
4280
  if (this._reconnectPromise) {
4192
4281
  await this._reconnectPromise;
4193
4282
  return;
4194
4283
  }
4195
4284
  this._reconnectPromise = (async () => {
4285
+ if (this._closed) return;
4196
4286
  const old = this.knexInstance;
4197
4287
  this.isConnected = false;
4198
4288
  this.knexInstance = null;
4199
4289
  this.queriesLog = [];
4200
4290
  if (old) {
4201
- try {
4202
- await old.destroy();
4203
- } catch (error) {
4204
- this.logger.debug?.(
4205
- `[Db] destroy during reconnect: ${this.getErrorMessage(error)}`
4206
- );
4207
- }
4291
+ await this._destroyKnex(old, "destroy during reconnect");
4292
+ }
4293
+ if (this._closed) return;
4294
+ this._reconnectAcquireTimeoutMs = 3e3;
4295
+ try {
4296
+ await this.connect();
4297
+ this._reconnectCooldownUntil = 0;
4298
+ } catch (error) {
4299
+ this._reconnectCooldownUntil = Date.now() + 5e3;
4300
+ throw error;
4301
+ } finally {
4302
+ this._reconnectAcquireTimeoutMs = null;
4208
4303
  }
4209
- await this.connect();
4210
4304
  })();
4211
4305
  try {
4212
4306
  await this._reconnectPromise;
@@ -4215,10 +4309,19 @@ var Db = class _Db {
4215
4309
  }
4216
4310
  }
4217
4311
  async reconnectAfterConnectionError(error) {
4312
+ if (this._closed) {
4313
+ return;
4314
+ }
4315
+ if (this._reconnectCooldownUntil && Date.now() < this._reconnectCooldownUntil) {
4316
+ return;
4317
+ }
4218
4318
  this.logger.warn?.(
4219
4319
  `[Db] Connection lost (${this.getErrorMessage(error)}) \u2014 reconnecting\u2026`
4220
4320
  );
4221
- await this.reconnect();
4321
+ try {
4322
+ await this.reconnect();
4323
+ } catch {
4324
+ }
4222
4325
  }
4223
4326
  /**
4224
4327
  * Run `fn`; on a connection error, reconnect once (by default) and retry `fn`.
@@ -4231,10 +4334,13 @@ var Db = class _Db {
4231
4334
  return await fn();
4232
4335
  } catch (error) {
4233
4336
  lastError = error;
4234
- if (!this.isConnectionError(error) || attempt >= retries) {
4337
+ if (this._closed || !this.isConnectionError(error) || attempt >= retries) {
4235
4338
  throw error;
4236
4339
  }
4237
4340
  await this.reconnectAfterConnectionError(error);
4341
+ if (this._closed) {
4342
+ throw error;
4343
+ }
4238
4344
  }
4239
4345
  }
4240
4346
  throw lastError;
@@ -4258,10 +4364,13 @@ var Db = class _Db {
4258
4364
  try {
4259
4365
  return await protoThen.call(builder);
4260
4366
  } catch (error) {
4261
- if (!inst.isConnectionError(error)) {
4367
+ if (inst._closed || !inst.isConnectionError(error)) {
4262
4368
  throw error;
4263
4369
  }
4264
4370
  await inst.reconnectAfterConnectionError(error);
4371
+ if (inst._closed || !inst.knexInstance) {
4372
+ throw error;
4373
+ }
4265
4374
  if (typeof builder.clone === "function") {
4266
4375
  const retry = builder.clone();
4267
4376
  retry.client = inst.knexInstance.client;
@@ -5309,13 +5418,14 @@ var Logger = class _Logger {
5309
5418
  */
5310
5419
  progress(message, opts) {
5311
5420
  const { prefix, count, total } = opts;
5312
- const paddedTotal = String(total).length;
5421
+ const displayTotal = Math.max(Number(total) || 0, Number(count) || 0);
5422
+ const paddedTotal = String(displayTotal).length;
5313
5423
  const paddedCount = String(count).padStart(paddedTotal, " ");
5314
5424
  const payload = {
5315
5425
  level: "progress",
5316
5426
  message,
5317
5427
  count: paddedCount,
5318
- total,
5428
+ total: displayTotal,
5319
5429
  prefix
5320
5430
  };
5321
5431
  const key = prefix ?? "";
@@ -5332,7 +5442,7 @@ var Logger = class _Logger {
5332
5442
  if (wantTimes) {
5333
5443
  let remaining = -1;
5334
5444
  if (itemsPerSec > 0) {
5335
- remaining = (total - count) / itemsPerSec;
5445
+ remaining = Math.max(0, (displayTotal - count) / itemsPerSec);
5336
5446
  }
5337
5447
  payload.elapsed = this.round(elapsedSeconds, 2);
5338
5448
  payload.remaining = remaining >= 0 ? this.round(remaining, 2) : remaining;
@@ -5341,12 +5451,12 @@ var Logger = class _Logger {
5341
5451
  payload.rate = itemsPerSec >= 0 ? this.round(itemsPerSec, 2) : itemsPerSec;
5342
5452
  }
5343
5453
  }
5344
- if (count >= total) {
5454
+ if (count === total) {
5345
5455
  delete this.startTimes[key];
5346
5456
  delete this.startCounts[key];
5347
5457
  delete this.lastProgressTimes[key];
5348
5458
  }
5349
- if (this.shouldOutputProgress(prefix ?? "", count, total)) {
5459
+ if (this.shouldOutputProgress(prefix ?? "", count, displayTotal)) {
5350
5460
  this.out(payload);
5351
5461
  if (this.options.progressThrottle && prefix) {
5352
5462
  this.lastProgressTimes[prefix] = Date.now();
@@ -5505,6 +5615,8 @@ function setup(opts = {}) {
5505
5615
  // a quick "show me the figured params and quit" that skips the flow's
5506
5616
  // actual work. Like --stopAfter=init, this is a hard exit(0) (registered
5507
5617
  // cleanups are skipped); call it once components/params are resolved.
5618
+ _requestExitCode: null,
5619
+ requestExit: null,
5508
5620
  showUsedParamsIfNeeded: () => {
5509
5621
  const mode = params.getShowUsedParamsMode?.();
5510
5622
  if (mode !== "top" && mode !== "stop") return;
@@ -5515,6 +5627,9 @@ function setup(opts = {}) {
5515
5627
  }
5516
5628
  }
5517
5629
  };
5630
+ context.requestExit = (code = 0) => {
5631
+ context._requestExitCode = code;
5632
+ };
5518
5633
  logger.debug("[setup] completed successfully");
5519
5634
  return context;
5520
5635
  }
@@ -10078,6 +10193,7 @@ export {
10078
10193
  buildDetailBreadcrumb,
10079
10194
  buildFooter,
10080
10195
  bumpPatchVersion,
10196
+ clampScroll,
10081
10197
  cloneRepo,
10082
10198
  coerceRuntimeValue,
10083
10199
  controlLaneTaskNames,
@@ -10115,6 +10231,7 @@ export {
10115
10231
  ipcFileLogsTableNameForSourceResource,
10116
10232
  isFreshOnline,
10117
10233
  isPidAlive,
10234
+ isScrolledToBottom,
10118
10235
  joiEdateType,
10119
10236
  joiStringArrayType,
10120
10237
  listServicesRegistry as listAliveRunnerHeartbeats,
@@ -10128,6 +10245,8 @@ export {
10128
10245
  memo,
10129
10246
  mergeAllowedTasksWithServiceTasks,
10130
10247
  mergeRuntimeParamSpecs,
10248
+ nextScrollAfterContentChange,
10249
+ nextScrollAfterUserMove,
10131
10250
  nextTimeMatch,
10132
10251
  normalizeAllowedTasks,
10133
10252
  npmEnv,