pinggy 0.5.3 → 0.5.5

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
@@ -51,7 +51,7 @@ function startSpinner(name = "dots", text = "Loading") {
51
51
  }
52
52
  currentTimer = setInterval(() => {
53
53
  const frame = spinner.frames[i = ++i % spinner.frames.length];
54
- process.stdout.write(`\r${import_picocolors.default.cyan(frame)} ${text}`);
54
+ process.stdout.write(`\r${import_picocolors.default.cyan(frame)} ${text}\x1B[K`);
55
55
  }, spinner.interval);
56
56
  return () => stopSpinner();
57
57
  }
@@ -67,7 +67,7 @@ function stopSpinnerSuccess(message) {
67
67
  clearInterval(currentTimer);
68
68
  currentTimer = null;
69
69
  const finalMessage = message || currentText;
70
- process.stdout.write(`\r${import_picocolors.default.green("\u2714")} ${finalMessage}
70
+ process.stdout.write(`\r${import_picocolors.default.green("\u2714")} ${finalMessage}\x1B[K
71
71
  `);
72
72
  }
73
73
  }
@@ -76,7 +76,7 @@ function stopSpinnerFail(message) {
76
76
  clearInterval(currentTimer);
77
77
  currentTimer = null;
78
78
  const finalMessage = message || currentText;
79
- process.stdout.write(`\r${import_picocolors.default.red("\u2716")} ${finalMessage}
79
+ process.stdout.write(`\r${import_picocolors.default.red("\u2716")} ${finalMessage}\x1B[K
80
80
  `);
81
81
  }
82
82
  }
@@ -1063,6 +1063,29 @@ var init_TunnelManager = __esm({
1063
1063
  }
1064
1064
  return Promise.reject(new Error(`Either configId or tunnelId must be provided`));
1065
1065
  }
1066
+ /**
1067
+ * Stop a tunnel instance and wait for the stop to settle, capped by a
1068
+ * timeout. Failure is logged, not thrown: the caller replaces the
1069
+ * instance either way and a hung stop must not block that forever.
1070
+ */
1071
+ async stopInstanceGuarded(managed, timeoutMs = 5e3) {
1072
+ let timer;
1073
+ try {
1074
+ await Promise.race([
1075
+ managed.instance.stop(),
1076
+ new Promise((_, reject) => {
1077
+ timer = setTimeout(() => reject(new Error(`Stop timed out after ${timeoutMs}ms`)), timeoutMs);
1078
+ })
1079
+ ]);
1080
+ } catch (e) {
1081
+ logger.warn("Failed to stop tunnel instance before replacement", {
1082
+ tunnelId: managed.tunnelid,
1083
+ error: errorMessage(e)
1084
+ });
1085
+ } finally {
1086
+ if (timer) clearTimeout(timer);
1087
+ }
1088
+ }
1066
1089
  /**
1067
1090
  * Restarts a tunnel with its current configuration.
1068
1091
  * This function will stop the tunnel if it's running and start it again.
@@ -1084,6 +1107,9 @@ var init_TunnelManager = __esm({
1084
1107
  const currentServe = existingTunnel.serve;
1085
1108
  const autoReconnect = existingTunnel.autoReconnect || false;
1086
1109
  const currentOrigin = existingTunnel.origin;
1110
+ if (!existingTunnel.isStopped) {
1111
+ await this.stopInstanceGuarded(existingTunnel);
1112
+ }
1087
1113
  this.tunnelsByTunnelId.delete(tunnelid);
1088
1114
  this.tunnelsByConfigId.delete(existingTunnel.configId);
1089
1115
  this.tunnelStats.delete(tunnelid);
@@ -1118,9 +1144,26 @@ var init_TunnelManager = __esm({
1118
1144
  throw new Error(`Failed to restart tunnel: ${error instanceof Error ? error.message : "Unknown error"}`);
1119
1145
  }
1120
1146
  }
1147
+ /**
1148
+ * Carry lifecycle state from a replaced tunnel over to its replacement:
1149
+ * keep createdAt, start the replacement if the old tunnel was running,
1150
+ * otherwise keep it stopped with the original timestamps.
1151
+ */
1152
+ async applyPreservedLifecycle(tunnel, prior) {
1153
+ if (prior.createdAt) {
1154
+ tunnel.createdAt = prior.createdAt;
1155
+ }
1156
+ if (!prior.isStopped) {
1157
+ await this.startTunnel(tunnel.tunnelid);
1158
+ return;
1159
+ }
1160
+ tunnel.isStopped = true;
1161
+ tunnel.startedAt = prior.startedAt;
1162
+ tunnel.stoppedAt = prior.stoppedAt ?? (/* @__PURE__ */ new Date()).toISOString();
1163
+ }
1121
1164
  /**
1122
1165
  * Updates the configuration of an existing tunnel.
1123
- *
1166
+ *
1124
1167
  * This method handles the process of updating a tunnel's configuration while preserving
1125
1168
  * its state. If the tunnel is running, it will be stopped, updated, and restarted.
1126
1169
  * In case of failure, it attempts to restore the original configuration.
@@ -1147,10 +1190,13 @@ var init_TunnelManager = __esm({
1147
1190
  const currentServe = existingTunnel.serve;
1148
1191
  const currentAutoReconnect = existingTunnel.autoReconnect || false;
1149
1192
  const currentOrigin = existingTunnel.origin;
1193
+ const currentCreatedAt = existingTunnel.createdAt;
1194
+ const currentStartedAt = existingTunnel.startedAt;
1195
+ const currentStoppedAt = existingTunnel.stoppedAt;
1150
1196
  const requestedServe = this.resolveServePath(newConfig);
1151
1197
  try {
1152
1198
  if (!isStopped) {
1153
- void existingTunnel.instance.stop();
1199
+ await this.stopInstanceGuarded(existingTunnel);
1154
1200
  }
1155
1201
  this.tunnelsByTunnelId.delete(currentTunnelId);
1156
1202
  this.tunnelsByConfigId.delete(currentTunnelConfigId);
@@ -1162,7 +1208,6 @@ var init_TunnelManager = __esm({
1162
1208
  };
1163
1209
  const effectiveServe = requestedServe !== void 0 ? requestedServe : currentServe;
1164
1210
  const effectiveTunnelName = newTunnelName !== void 0 ? newTunnelName : currentTunnelName;
1165
- let configWithForwarding;
1166
1211
  const newTunnel = await this._createTunnelWithProcessedConfig({
1167
1212
  configId,
1168
1213
  tunnelid: currentTunnelId,
@@ -1172,9 +1217,12 @@ var init_TunnelManager = __esm({
1172
1217
  serve: effectiveServe,
1173
1218
  autoReconnect: currentAutoReconnect
1174
1219
  });
1175
- if (!isStopped) {
1176
- await this.startTunnel(newTunnel.tunnelid);
1177
- }
1220
+ await this.applyPreservedLifecycle(newTunnel, {
1221
+ isStopped,
1222
+ createdAt: currentCreatedAt,
1223
+ startedAt: currentStartedAt,
1224
+ stoppedAt: currentStoppedAt
1225
+ });
1178
1226
  logger.info("Tunnel configuration updated", {
1179
1227
  tunnelId: newTunnel.tunnelid,
1180
1228
  configId: newTunnel.configId,
@@ -1196,9 +1244,12 @@ var init_TunnelManager = __esm({
1196
1244
  serve: currentServe,
1197
1245
  autoReconnect: currentAutoReconnect
1198
1246
  });
1199
- if (!isStopped) {
1200
- await this.startTunnel(originalTunnel.tunnelid);
1201
- }
1247
+ await this.applyPreservedLifecycle(originalTunnel, {
1248
+ isStopped,
1249
+ createdAt: currentCreatedAt,
1250
+ startedAt: currentStartedAt,
1251
+ stoppedAt: currentStoppedAt
1252
+ });
1202
1253
  logger.warn("Restored original tunnel configuration after update failure", {
1203
1254
  currentTunnelId,
1204
1255
  error: errorMessage(error)
@@ -1815,6 +1866,9 @@ var init_TunnelManager = __esm({
1815
1866
  try {
1816
1867
  logger.info("Tunnel reconnection completed", { tunnelId, urls });
1817
1868
  const managedTunnel = this.tunnelsByTunnelId.get(tunnelId);
1869
+ if (managedTunnel?.isStopping || managedTunnel?.isStopped) {
1870
+ return;
1871
+ }
1818
1872
  if (managedTunnel) {
1819
1873
  managedTunnel.isStopped = false;
1820
1874
  managedTunnel.startedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -2152,6 +2206,7 @@ function pinggyOptionsToTunnelConfigV1(opts, configStoredInCli) {
2152
2206
  httpsOnly: opts.httpsOnly ?? false,
2153
2207
  originalRequestUrl: opts.originalRequestUrl ?? false,
2154
2208
  allowPreflight: opts.allowPreflight ?? false,
2209
+ haProxy: opts.haProxy,
2155
2210
  optional: opts.optional || {}
2156
2211
  };
2157
2212
  }
@@ -2341,7 +2396,7 @@ var init_remote_schema = __esm({
2341
2396
  ForwardingEntryV2Schema = import_zod.z.object({
2342
2397
  listenAddress: import_zod.z.string().optional(),
2343
2398
  address: import_zod.z.string(),
2344
- type: import_zod.z.enum([import_pinggy3.TunnelType.Http, import_pinggy3.TunnelType.Tcp, import_pinggy3.TunnelType.Udp, import_pinggy3.TunnelType.Tls, import_pinggy3.TunnelType.TlsTcp])
2399
+ type: import_zod.z.enum([import_pinggy3.TunnelType.Http, import_pinggy3.TunnelType.Tcp, import_pinggy3.TunnelType.Udp, import_pinggy3.TunnelType.Tls, import_pinggy3.TunnelType.TlsTcp]).default(import_pinggy3.TunnelType.Http)
2345
2400
  });
2346
2401
  OptionalSchema = import_zod.z.object({
2347
2402
  sniServerName: import_zod.z.string().optional(),
@@ -2385,7 +2440,9 @@ var init_remote_schema = __esm({
2385
2440
  httpsOnly: import_zod.z.boolean().optional(),
2386
2441
  originalRequestUrl: import_zod.z.boolean().optional(),
2387
2442
  allowPreflight: import_zod.z.boolean().optional(),
2388
- optional: OptionalSchema.optional()
2443
+ haProxy: import_zod.z.enum([import_pinggy3.HaProxyVersion.V1, import_pinggy3.HaProxyVersion.V2]).optional(),
2444
+ serve: import_zod.z.string().optional(),
2445
+ optional: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
2389
2446
  });
2390
2447
  TUNNEL_CONFIG_V1_KEYS = Object.keys(
2391
2448
  TunnelConfigV1Schema.shape
@@ -2525,11 +2582,11 @@ var init_handler = __esm({
2525
2582
  startPromise.catch((err) => {
2526
2583
  logger.error("No-wait startTunnel failed", { tunnelid, err: String(err) });
2527
2584
  });
2528
- return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.optional?.serve);
2585
+ return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig, config, config.configId, config.name, config.serve);
2529
2586
  }
2530
2587
  await startPromise;
2531
2588
  const tunnelPconfig = await this.tunnelManager.getTunnelConfig("", tunnelid);
2532
- return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.optional?.serve);
2589
+ return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.serve);
2533
2590
  } catch (err) {
2534
2591
  if (err instanceof TunnelAlreadyRunningError) {
2535
2592
  return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message);
@@ -2551,10 +2608,12 @@ var init_handler = __esm({
2551
2608
  if (noWait) {
2552
2609
  const existing = this.tunnelManager.getManagedTunnel(config.configid);
2553
2610
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2554
- this.tunnelManager.updateConfig(updateOpts).catch((err) => {
2555
- logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
2556
- });
2557
- return this.buildPendingTunnelResponse(existing.tunnelid, existing.tunnelConfig, config.configid, existing.tunnelName, existing.serve);
2611
+ if (!existing.isStopped) {
2612
+ this.tunnelManager.updateConfig(updateOpts).catch((err) => {
2613
+ logger.error("No-wait updateConfig failed", { configid: config.configid, err: String(err) });
2614
+ });
2615
+ return this.buildPendingTunnelResponse(existing.tunnelid, existing.tunnelConfig, config.configid, existing.tunnelName, existing.serve);
2616
+ }
2558
2617
  }
2559
2618
  const tunnel = await this.tunnelManager.updateConfig(updateOpts);
2560
2619
  if (!tunnel.instance || !tunnel.tunnelConfig)
@@ -2569,10 +2628,12 @@ var init_handler = __esm({
2569
2628
  if (noWait) {
2570
2629
  const existing = this.tunnelManager.getManagedTunnel(config.configId);
2571
2630
  if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update");
2572
- this.tunnelManager.updateConfig(config).catch((err) => {
2573
- logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
2574
- });
2575
- return this.buildPendingTunnelResponseV2(existing.tunnelid, existing.tunnelConfig, config, config.configId, existing.tunnelName, existing.serve);
2631
+ if (!existing.isStopped) {
2632
+ this.tunnelManager.updateConfig(config).catch((err) => {
2633
+ logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) });
2634
+ });
2635
+ return this.buildPendingTunnelResponseV2(existing.tunnelid, existing.tunnelConfig, config, config.configId, existing.tunnelName, existing.serve);
2636
+ }
2576
2637
  }
2577
2638
  const tunnel = await this.tunnelManager.updateConfig(config);
2578
2639
  if (!tunnel.instance || !tunnel.tunnelConfig)
@@ -2795,8 +2856,8 @@ var init_ipcClient = __esm({
2795
2856
  async stopTunnel(tunnelid) {
2796
2857
  return this.call(Route.StopTunnel, { tunnelid });
2797
2858
  }
2798
- async restartTunnel(tunnelid) {
2799
- return this.call(Route.RestartTunnel, { tunnelid });
2859
+ async restartTunnel(tunnelid, noWait) {
2860
+ return this.call(Route.RestartTunnel, { tunnelid, noWait });
2800
2861
  }
2801
2862
  // v1 operations (used by remote management via daemon)
2802
2863
  async startTunnelV1(config, mode, noWait) {
@@ -2978,6 +3039,7 @@ function printHelpMessage() {
2978
3039
  console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
2979
3040
  console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
2980
3041
  console.log(" x:passpreflight Pass CORS preflight requests unchanged");
3042
+ console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
2981
3043
  console.log(" a:Key:Val Add header");
2982
3044
  console.log(" u:Key:Val Update header");
2983
3045
  console.log(" r:Key Remove header");
@@ -3103,7 +3165,7 @@ var init_parseArgs = __esm({
3103
3165
  });
3104
3166
 
3105
3167
  // src/remote_management/websocket_printer.ts
3106
- var import_picocolors3, PENDING_START_TIMEOUT_MS, RemoteManagementWebSocketPrinter, remoteManagementWebSocketPrinter;
3168
+ var import_picocolors3, PENDING_TIMEOUT_MS, PendingTracker, RemoteManagementWebSocketPrinter, remoteManagementWebSocketPrinter;
3107
3169
  var init_websocket_printer = __esm({
3108
3170
  "src/remote_management/websocket_printer.ts"() {
3109
3171
  "use strict";
@@ -3112,13 +3174,71 @@ var init_websocket_printer = __esm({
3112
3174
  init_types();
3113
3175
  init_printer();
3114
3176
  import_picocolors3 = __toESM(require("picocolors"), 1);
3115
- PENDING_START_TIMEOUT_MS = 5 * 60 * 1e3;
3116
- RemoteManagementWebSocketPrinter = class {
3177
+ PENDING_TIMEOUT_MS = 5 * 60 * 1e3;
3178
+ PendingTracker = class {
3117
3179
  constructor() {
3118
- this.pendingStarts = /* @__PURE__ */ new Map();
3180
+ this.entries = /* @__PURE__ */ new Map();
3181
+ }
3182
+ queue(entry) {
3183
+ this.latestConfigId = entry.configId;
3184
+ this.entries.set(entry.configId, entry);
3119
3185
  }
3120
- setTunnelHandler(tunnelHandler) {
3121
- this.tunnelHandler = tunnelHandler;
3186
+ /** Store an entry without claiming the latest cursor. */
3187
+ upsert(entry) {
3188
+ this.entries.set(entry.configId, entry);
3189
+ }
3190
+ get(configId) {
3191
+ return this.entries.get(configId);
3192
+ }
3193
+ find(configId, tunnelId) {
3194
+ const byConfigId = this.entries.get(configId);
3195
+ if (byConfigId) {
3196
+ return byConfigId;
3197
+ }
3198
+ if (!tunnelId) {
3199
+ return void 0;
3200
+ }
3201
+ for (const entry of this.entries.values()) {
3202
+ if (entry.tunnelId === tunnelId) {
3203
+ return entry;
3204
+ }
3205
+ }
3206
+ return void 0;
3207
+ }
3208
+ delete(configId) {
3209
+ this.entries.delete(configId);
3210
+ if (this.latestConfigId === configId) {
3211
+ this.latestConfigId = void 0;
3212
+ }
3213
+ }
3214
+ get latest() {
3215
+ return this.latestConfigId;
3216
+ }
3217
+ get hasLatest() {
3218
+ return this.entries.size > 0 && this.latestConfigId !== void 0;
3219
+ }
3220
+ isLatest(configId) {
3221
+ return configId === this.latestConfigId;
3222
+ }
3223
+ cleanupExpired(onTimeout) {
3224
+ const now = Date.now();
3225
+ for (const [configId, entry] of this.entries) {
3226
+ if (now - entry.queuedAt <= PENDING_TIMEOUT_MS) {
3227
+ continue;
3228
+ }
3229
+ const wasLatest = this.isLatest(configId);
3230
+ this.delete(configId);
3231
+ onTimeout(entry, wasLatest);
3232
+ }
3233
+ }
3234
+ };
3235
+ RemoteManagementWebSocketPrinter = class {
3236
+ constructor() {
3237
+ this.pendingStarts = new PendingTracker();
3238
+ this.pendingActions = new PendingTracker();
3239
+ // tunnelId -> details, fed by monitorList. Stop/restart requests only carry
3240
+ // a tunnelId; without this the spinner shows the raw id.
3241
+ this.tunnelDetailsCache = /* @__PURE__ */ new Map();
3122
3242
  }
3123
3243
  queueStart(config) {
3124
3244
  this.cleanupExpiredPendingStarts();
@@ -3127,24 +3247,23 @@ var init_websocket_printer = __esm({
3127
3247
  configName: this.getConfigNameFromRequest(config),
3128
3248
  queuedAt: Date.now()
3129
3249
  };
3130
- this.latestPendingConfigId = entry.configId;
3131
- this.pendingStarts.set(entry.configId, entry);
3250
+ this.pendingStarts.queue(entry);
3132
3251
  printer_default.startSpinner("Starting tunnel with config name: " + entry.configName);
3133
3252
  }
3134
3253
  failQueuedStart(config, reason) {
3135
3254
  const configId = this.getConfigIdFromRequest(config);
3136
3255
  const pending = this.pendingStarts.get(configId);
3137
3256
  const configName = pending?.configName || this.getConfigNameFromRequest(config);
3257
+ const wasLatest = this.pendingStarts.isLatest(configId);
3138
3258
  this.pendingStarts.delete(configId);
3139
- if (this.latestPendingConfigId === configId) {
3140
- this.latestPendingConfigId = void 0;
3259
+ if (wasLatest) {
3141
3260
  printer_default.stopSpinnerFail(`Failed to start tunnel with config name: ${configName}. ${reason}`);
3142
3261
  }
3143
3262
  }
3144
3263
  handleStartResult(config, result) {
3145
3264
  this.cleanupExpiredPendingStarts();
3146
3265
  const requestedConfigId = this.getConfigIdFromRequest(config);
3147
- if (this.latestPendingConfigId && requestedConfigId !== this.latestPendingConfigId) {
3266
+ if (this.pendingStarts.latest && !this.pendingStarts.isLatest(requestedConfigId)) {
3148
3267
  this.pendingStarts.delete(requestedConfigId);
3149
3268
  return;
3150
3269
  }
@@ -3152,20 +3271,20 @@ var init_websocket_printer = __esm({
3152
3271
  this.failQueuedStart(config, result.message);
3153
3272
  return;
3154
3273
  }
3155
- const configId = this.getConfigIdFromTunnel(result);
3156
3274
  const pending = this.pendingStarts.get(requestedConfigId) || {
3157
3275
  configId: requestedConfigId,
3158
3276
  configName: this.getConfigNameFromRequest(config),
3159
3277
  queuedAt: Date.now()
3160
3278
  };
3161
3279
  pending.tunnelId = result.tunnelid;
3162
- this.pendingStarts.set(requestedConfigId, pending);
3280
+ this.pendingStarts.upsert(pending);
3163
3281
  if (result.remoteurls.length > 0) {
3164
3282
  this.completePendingStart(pending, result.remoteurls);
3165
3283
  }
3166
3284
  }
3167
3285
  printStopRequested(tunnelId) {
3168
3286
  const details = this.resolveTunnelDetails(tunnelId);
3287
+ this.clearPendingAction(details.configId, tunnelId);
3169
3288
  printer_default.startSpinner("Stopping tunnel with config name: " + details.configName);
3170
3289
  }
3171
3290
  handleStopResult(tunnelId, result) {
@@ -3179,6 +3298,7 @@ var init_websocket_printer = __esm({
3179
3298
  }
3180
3299
  printRestartRequested(tunnelId) {
3181
3300
  const details = this.resolveTunnelDetails(tunnelId);
3301
+ this.clearPendingAction(details.configId, tunnelId);
3182
3302
  printer_default.startSpinner("Restarting tunnel with config name: " + details.configName);
3183
3303
  }
3184
3304
  handleRestartResult(tunnelId, result) {
@@ -3188,48 +3308,101 @@ var init_websocket_printer = __esm({
3188
3308
  printer_default.stopSpinnerFail("Failed to restart tunnel with config name: " + details.configName);
3189
3309
  return;
3190
3310
  }
3191
- printer_default.stopSpinnerSuccess("Restarted tunnel with config name: " + details.configName);
3192
- if (result.remoteurls?.length > 0) {
3193
- printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3194
- (result.remoteurls ?? []).forEach(
3195
- (url) => printer_default.print(" " + import_picocolors3.default.magentaBright(url))
3196
- );
3311
+ this.queuePendingAction("restart", details.configId, details.configName, result.tunnelid);
3312
+ }
3313
+ printUpdateConfigRequested(config) {
3314
+ const configName = this.getConfigNameFromRequest(config);
3315
+ this.clearPendingAction(this.getConfigIdFromRequest(config));
3316
+ printer_default.startSpinner("Updating tunnel configuration for: " + configName);
3317
+ }
3318
+ handleUpdateConfigResult(config, result) {
3319
+ const configId = this.getConfigIdFromRequest(config);
3320
+ const configName = this.getConfigNameFromRequest(config);
3321
+ if (isErrorResponse(result)) {
3322
+ printer_default.stopSpinnerFail(`Failed to update tunnel configuration for: ${configName}. ${result.message}`);
3323
+ return;
3324
+ }
3325
+ if (result.status.state === "exited" /* Exited */) {
3326
+ printer_default.stopSpinnerSuccess(`Tunnel configuration updated: ${configName}.`);
3327
+ return;
3197
3328
  }
3329
+ this.queuePendingAction("update", configId, configName, result.tunnelid);
3330
+ }
3331
+ clearPendingAction(configId, tunnelId) {
3332
+ const entry = this.pendingActions.find(configId, tunnelId);
3333
+ if (!entry) {
3334
+ return;
3335
+ }
3336
+ this.pendingActions.delete(entry.configId);
3337
+ }
3338
+ queuePendingAction(action, configId, configName, tunnelId) {
3339
+ this.cleanupExpiredPendingActions();
3340
+ this.pendingActions.queue({ action, configId, configName, queuedAt: Date.now(), tunnelId });
3341
+ }
3342
+ printActionComplete(action, configName, urls) {
3343
+ const message = action === "restart" ? `Restarted tunnel with config name: ${configName}` : `Tunnel configuration updated and restarted: ${configName}`;
3344
+ printer_default.stopSpinnerSuccess(message);
3345
+ printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3346
+ urls.forEach((url) => printer_default.print(" " + import_picocolors3.default.magentaBright(url)));
3198
3347
  }
3199
3348
  monitorList(result) {
3200
3349
  this.cleanupExpiredPendingStarts();
3201
- if (!Array.isArray(result) || this.pendingStarts.size === 0 || !this.latestPendingConfigId) {
3350
+ this.cleanupExpiredPendingActions();
3351
+ if (!Array.isArray(result)) {
3202
3352
  return;
3203
3353
  }
3204
3354
  for (const tunnel of result) {
3205
- const pending = this.findPendingStart(tunnel);
3206
- if (!pending) {
3207
- continue;
3355
+ this.tunnelDetailsCache.set(tunnel.tunnelid, {
3356
+ configId: this.getConfigIdFromTunnel(tunnel),
3357
+ configName: this.getConfigNameFromTunnel(tunnel)
3358
+ });
3359
+ }
3360
+ this.resolvePendingFromList(
3361
+ result,
3362
+ this.pendingStarts,
3363
+ (entry, urls) => this.completePendingStart(entry, urls),
3364
+ (entry, reason) => printer_default.stopSpinnerFail(`Tunnel start did not complete for config name: ${entry.configName}. ${reason}`)
3365
+ );
3366
+ this.resolvePendingFromList(
3367
+ result,
3368
+ this.pendingActions,
3369
+ (entry, urls) => {
3370
+ this.pendingActions.delete(entry.configId);
3371
+ this.printActionComplete(entry.action, entry.configName, urls);
3372
+ },
3373
+ (entry, reason) => {
3374
+ const verb = entry.action === "restart" ? "Restart" : "Configuration update";
3375
+ printer_default.stopSpinnerFail(`${verb} did not complete for config name: ${entry.configName}. ${reason}`);
3208
3376
  }
3209
- if (pending.configId !== this.latestPendingConfigId) {
3377
+ );
3378
+ }
3379
+ resolvePendingFromList(tunnels, tracker, onComplete, onExited) {
3380
+ if (!tracker.hasLatest) {
3381
+ return;
3382
+ }
3383
+ for (const tunnel of tunnels) {
3384
+ const entry = tracker.find(this.getConfigIdFromTunnel(tunnel), tunnel.tunnelid);
3385
+ if (!entry || !tracker.isLatest(entry.configId)) {
3210
3386
  continue;
3211
3387
  }
3212
- pending.tunnelId = tunnel.tunnelid;
3213
- this.pendingStarts.set(pending.configId, pending);
3388
+ entry.tunnelId = tunnel.tunnelid;
3389
+ tracker.upsert(entry);
3214
3390
  if (tunnel.remoteurls.length > 0) {
3215
- this.completePendingStart(pending, tunnel.remoteurls);
3391
+ onComplete(entry, tunnel.remoteurls);
3216
3392
  continue;
3217
3393
  }
3218
3394
  if (tunnel.status.state === "exited" /* Exited */) {
3219
- const reason = tunnel.status.errormsg || "Tunnel exited before a public URL was assigned";
3220
- this.pendingStarts.delete(pending.configId);
3221
- this.latestPendingConfigId = void 0;
3222
- printer_default.stopSpinnerFail(`Tunnel start did not complete for config name: ${pending.configName}. ${reason}`);
3395
+ tracker.delete(entry.configId);
3396
+ onExited(entry, tunnel.status.errormsg || "Tunnel exited before a public URL was assigned");
3223
3397
  }
3224
3398
  }
3225
3399
  }
3226
3400
  completePendingStart(entry, urls) {
3227
- if (this.latestPendingConfigId && entry.configId !== this.latestPendingConfigId) {
3401
+ if (this.pendingStarts.latest && !this.pendingStarts.isLatest(entry.configId)) {
3228
3402
  this.pendingStarts.delete(entry.configId);
3229
3403
  return;
3230
3404
  }
3231
3405
  this.pendingStarts.delete(entry.configId);
3232
- this.latestPendingConfigId = void 0;
3233
3406
  printer_default.stopSpinnerSuccess(`Tunnel started with config name: ${entry.configName}.`);
3234
3407
  printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3235
3408
  (urls ?? []).forEach(
@@ -3237,28 +3410,27 @@ var init_websocket_printer = __esm({
3237
3410
  );
3238
3411
  }
3239
3412
  cleanupExpiredPendingStarts() {
3240
- const now = Date.now();
3241
- for (const [configId, entry] of this.pendingStarts.entries()) {
3242
- if (now - entry.queuedAt <= PENDING_START_TIMEOUT_MS) {
3243
- continue;
3413
+ this.pendingStarts.cleanupExpired((entry, wasLatest) => {
3414
+ const message = `Timed out while waiting for tunnel URL for config name: ${entry.configName}`;
3415
+ if (wasLatest) {
3416
+ printer_default.stopSpinnerFail(message);
3417
+ } else {
3418
+ printer_default.warn(message);
3244
3419
  }
3245
- this.pendingStarts.delete(configId);
3246
- printer_default.warn(`Timed out while waiting for tunnel URL for config name: ${entry.configName}`);
3247
- logger.warn("Pending websocket start entry expired", { configId, tunnelId: entry.tunnelId });
3248
- }
3420
+ logger.warn("Pending websocket start entry expired", { configId: entry.configId, tunnelId: entry.tunnelId });
3421
+ });
3249
3422
  }
3250
- findPendingStart(tunnel) {
3251
- const configId = this.getConfigIdFromTunnel(tunnel);
3252
- const byConfigId = this.pendingStarts.get(configId);
3253
- if (byConfigId) {
3254
- return byConfigId;
3255
- }
3256
- for (const entry of this.pendingStarts.values()) {
3257
- if (entry.tunnelId === tunnel.tunnelid) {
3258
- return entry;
3423
+ cleanupExpiredPendingActions() {
3424
+ this.pendingActions.cleanupExpired((entry, wasLatest) => {
3425
+ const verb = entry.action === "restart" ? "restart" : "configuration update";
3426
+ const message = `Timed out while waiting for tunnel URL after ${verb} for config name: ${entry.configName}`;
3427
+ if (wasLatest) {
3428
+ printer_default.stopSpinnerFail(message);
3429
+ } else {
3430
+ printer_default.warn(message);
3259
3431
  }
3260
- }
3261
- return void 0;
3432
+ logger.warn("Pending websocket action entry expired", { configId: entry.configId, tunnelId: entry.tunnelId, action: entry.action });
3433
+ });
3262
3434
  }
3263
3435
  resolveTunnelDetails(tunnelId, result) {
3264
3436
  if (result && !isErrorResponse(result)) {
@@ -3267,7 +3439,7 @@ var init_websocket_printer = __esm({
3267
3439
  configName: this.getConfigNameFromTunnel(result)
3268
3440
  };
3269
3441
  }
3270
- return {
3442
+ return this.tunnelDetailsCache.get(tunnelId) ?? {
3271
3443
  configId: tunnelId,
3272
3444
  configName: tunnelId
3273
3445
  };
@@ -3346,7 +3518,6 @@ var init_websocket_handlers = __esm({
3346
3518
  WebSocketCommandHandler = class {
3347
3519
  constructor(handler) {
3348
3520
  this.tunnelHandler = handler ?? new TunnelOperations();
3349
- remoteManagementWebSocketPrinter.setTunnelHandler(this.tunnelHandler);
3350
3521
  }
3351
3522
  safeParse(text) {
3352
3523
  if (!text) return void 0;
@@ -3413,8 +3584,10 @@ var init_websocket_handlers = __esm({
3413
3584
  }
3414
3585
  }
3415
3586
  async handleStopReq(req, raw) {
3587
+ let tunnelID;
3416
3588
  try {
3417
3589
  const dc = StopSchema.parse(raw);
3590
+ tunnelID = dc.tunnelID;
3418
3591
  remoteManagementWebSocketPrinter.printStopRequested(dc.tunnelID);
3419
3592
  const result = await this.tunnelHandler.handleStop(dc.tunnelID);
3420
3593
  remoteManagementWebSocketPrinter.handleStopResult(dc.tunnelID, result);
@@ -3424,6 +3597,9 @@ var init_websocket_handlers = __esm({
3424
3597
  printer_default.warn("Validation failed for stop request");
3425
3598
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3426
3599
  }
3600
+ if (tunnelID) {
3601
+ remoteManagementWebSocketPrinter.handleStopResult(tunnelID, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3602
+ }
3427
3603
  printer_default.warn(`Error in handleStopReq error: ${String(e)}`);
3428
3604
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3429
3605
  }
@@ -3443,8 +3619,10 @@ var init_websocket_handlers = __esm({
3443
3619
  }
3444
3620
  }
3445
3621
  async handleRestartReq(req, raw) {
3622
+ let tunnelID;
3446
3623
  try {
3447
3624
  const dc = RestartSchema.parse(raw);
3625
+ tunnelID = dc.tunnelID;
3448
3626
  remoteManagementWebSocketPrinter.printRestartRequested(dc.tunnelID);
3449
3627
  const result = await this.tunnelHandler.handleRestart(dc.tunnelID, true);
3450
3628
  remoteManagementWebSocketPrinter.handleRestartResult(dc.tunnelID, result);
@@ -3454,34 +3632,51 @@ var init_websocket_handlers = __esm({
3454
3632
  printer_default.warn("Validation failed for restart request");
3455
3633
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3456
3634
  }
3635
+ if (tunnelID) {
3636
+ remoteManagementWebSocketPrinter.handleRestartResult(tunnelID, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3637
+ }
3457
3638
  printer_default.warn(`Error in handleRestartReq error: ${String(e)}`);
3458
3639
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3459
3640
  }
3460
3641
  }
3461
3642
  async handleUpdateConfigReq(req, raw) {
3643
+ let parsedConfig;
3462
3644
  try {
3463
3645
  const dc = UpdateConfigSchema.parse(raw);
3646
+ parsedConfig = dc.tunnelConfig;
3647
+ remoteManagementWebSocketPrinter.printUpdateConfigRequested(dc.tunnelConfig);
3464
3648
  const result = await this.tunnelHandler.handleUpdateConfig(dc.tunnelConfig, true);
3649
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(dc.tunnelConfig, result);
3465
3650
  return this.wrapResponse(result, req);
3466
3651
  } catch (e) {
3467
3652
  if (e instanceof import_zod2.default.ZodError) {
3468
3653
  printer_default.warn("Validation failed for updateconfig request");
3469
3654
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3470
3655
  }
3656
+ if (parsedConfig) {
3657
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(parsedConfig, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3658
+ }
3471
3659
  printer_default.warn(`Error in handleUpdateConfigReq error: ${String(e)}`);
3472
3660
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3473
3661
  }
3474
3662
  }
3475
3663
  async handleUpdateConfigV2Req(req, raw) {
3664
+ let parsedConfig;
3476
3665
  try {
3477
3666
  const dc = UpdateConfigV2Schema.parse(raw);
3667
+ parsedConfig = dc.tunnelConfig;
3668
+ remoteManagementWebSocketPrinter.printUpdateConfigRequested(dc.tunnelConfig);
3478
3669
  const result = await this.tunnelHandler.handleUpdateConfigV2(dc.tunnelConfig, true);
3670
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(dc.tunnelConfig, result);
3479
3671
  return this.wrapResponse(result, req);
3480
3672
  } catch (e) {
3481
3673
  if (e instanceof import_zod2.default.ZodError) {
3482
3674
  printer_default.warn("Validation failed for update-config-v2 request");
3483
3675
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3484
3676
  }
3677
+ if (parsedConfig) {
3678
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(parsedConfig, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3679
+ }
3485
3680
  printer_default.warn(`Error in handleUpdateConfigV2Req error: ${String(e)}`);
3486
3681
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3487
3682
  }
@@ -3916,10 +4111,20 @@ function parseExtendedOptions(options, config, localServerTls) {
3916
4111
  case "fullrequesturl":
3917
4112
  config.originalRequestUrl = true;
3918
4113
  break;
4114
+ case "haproxy":
4115
+ case "haproxy:v1":
4116
+ config.haProxy = import_pinggy4.HaProxyVersion.V1;
4117
+ break;
4118
+ case "haproxy:v2":
4119
+ config.haProxy = import_pinggy4.HaProxyVersion.V2;
4120
+ break;
3919
4121
  default: {
3920
4122
  if (value && (value.startsWith("localServerTls") || value.startsWith("localservertls"))) {
3921
4123
  const parts = value.split(/:(.+)/);
3922
4124
  localServerTls = parts[1] ? parts[1] : "";
4125
+ } else if (value && value.startsWith("haproxy:")) {
4126
+ printer_default.warn(`Invalid HAProxy version in "${opt}". Use x:haproxy, x:haproxy:v1 or x:haproxy:v2`);
4127
+ logger.warn(`Warning: Invalid HAProxy version in "${opt}". Use x:haproxy, x:haproxy:v1 or x:haproxy:v2`);
3923
4128
  } else {
3924
4129
  printer_default.warn(`Unknown extended option "${value}"`);
3925
4130
  logger.warn(`Warning: Unknown extended option "${value}"`);
@@ -4021,11 +4226,12 @@ function isValidIpV6Cidr(input) {
4021
4226
  }
4022
4227
  return false;
4023
4228
  }
4024
- var import_net;
4229
+ var import_pinggy4, import_net;
4025
4230
  var init_extendedOptions = __esm({
4026
4231
  "src/cli/extendedOptions.ts"() {
4027
4232
  "use strict";
4028
4233
  init_cjs_shims();
4234
+ import_pinggy4 = require("@pinggy/pinggy");
4029
4235
  import_net = require("net");
4030
4236
  init_logger();
4031
4237
  init_printer();
@@ -4062,7 +4268,7 @@ function parseUserAndDomain(str) {
4062
4268
  const [user, domain] = str.split("@", 2);
4063
4269
  if (isValidServerAddress(domain)) {
4064
4270
  let processKeyword2 = function(keyword) {
4065
- if ([import_pinggy4.TunnelType.Http, import_pinggy4.TunnelType.Tcp, import_pinggy4.TunnelType.Tls, import_pinggy4.TunnelType.Udp, import_pinggy4.TunnelType.TlsTcp].includes(keyword)) {
4271
+ if ([import_pinggy5.TunnelType.Http, import_pinggy5.TunnelType.Tcp, import_pinggy5.TunnelType.Tls, import_pinggy5.TunnelType.Udp, import_pinggy5.TunnelType.TlsTcp].includes(keyword)) {
4066
4272
  type = keyword;
4067
4273
  } else if (keyword === "force") {
4068
4274
  forceFlag = true;
@@ -4150,7 +4356,7 @@ function parseUsers(positionalArgs, explicitToken) {
4150
4356
  }
4151
4357
  function parseType(finalConfig, values, inferredType) {
4152
4358
  const t = inferredType || values.type;
4153
- if (t === import_pinggy4.TunnelType.Http || t === import_pinggy4.TunnelType.Tcp || t === import_pinggy4.TunnelType.Tls || t === import_pinggy4.TunnelType.Udp || t === import_pinggy4.TunnelType.TlsTcp) {
4359
+ if (t === import_pinggy5.TunnelType.Http || t === import_pinggy5.TunnelType.Tcp || t === import_pinggy5.TunnelType.Tls || t === import_pinggy5.TunnelType.Udp || t === import_pinggy5.TunnelType.TlsTcp) {
4154
4360
  return t;
4155
4361
  }
4156
4362
  }
@@ -4248,7 +4454,7 @@ function parseAdditionalForwarding(forwarding) {
4248
4454
  }
4249
4455
  const firstPart = parsed[0];
4250
4456
  const [hostPart] = firstPart.split("@");
4251
- let protocol = import_pinggy4.TunnelType.Http;
4457
+ let protocol = import_pinggy5.TunnelType.Http;
4252
4458
  let remoteDomainRaw;
4253
4459
  let remotePort = 0;
4254
4460
  if (hostPart.includes("//")) {
@@ -4281,7 +4487,7 @@ function parseAdditionalForwarding(forwarding) {
4281
4487
  if (!isValidServerAddress(remoteDomainRaw)) {
4282
4488
  return new Error("invalid remote domain");
4283
4489
  }
4284
- protocol = import_pinggy4.TunnelType.Http;
4490
+ protocol = import_pinggy5.TunnelType.Http;
4285
4491
  remotePort = 0;
4286
4492
  }
4287
4493
  const localDomain = removeIPv6Brackets(parsed[2] || "localhost");
@@ -4311,7 +4517,7 @@ function parseReverseTunnelAddr(finalConfig, values, primaryType) {
4311
4517
  if (parsed instanceof Error) return parsed;
4312
4518
  forwardingData.push({
4313
4519
  address: `${parsed.localDomain}:${parsed.localPort}`,
4314
- type: primaryType || import_pinggy4.TunnelType.Http
4520
+ type: primaryType || import_pinggy5.TunnelType.Http
4315
4521
  });
4316
4522
  } else if (slicedForwarding.length === 4) {
4317
4523
  const parsed = parseAdditionalForwarding(forwarding);
@@ -4377,7 +4583,7 @@ function normalizeForwardingToArray(finalConfig, type) {
4377
4583
  if (typeof fwd !== "string") {
4378
4584
  return;
4379
4585
  }
4380
- finalConfig.forwarding = fwd.trim() === "" ? [] : [{ type: type || import_pinggy4.TunnelType.Http, address: fwd.trim() }];
4586
+ finalConfig.forwarding = fwd.trim() === "" ? [] : [{ type: type || import_pinggy5.TunnelType.Http, address: fwd.trim() }];
4381
4587
  }
4382
4588
  function parseArgs2(finalConfig, remainingPositionals) {
4383
4589
  let localserverTls = "";
@@ -4504,7 +4710,7 @@ function buildFinalConfig(values, positionals, baseConfig) {
4504
4710
  storeJson(finalConfig, saveconf);
4505
4711
  return finalConfig;
4506
4712
  }
4507
- var import_pinggy4, import_fs6, import_path6, import_net2, domainRegex, KEYWORDS, VALID_PROTOCOLS;
4713
+ var import_pinggy5, import_fs6, import_path6, import_net2, domainRegex, KEYWORDS, VALID_PROTOCOLS;
4508
4714
  var init_buildConfig = __esm({
4509
4715
  "src/cli/buildConfig.ts"() {
4510
4716
  "use strict";
@@ -4513,21 +4719,21 @@ var init_buildConfig = __esm({
4513
4719
  init_extendedOptions();
4514
4720
  init_logger();
4515
4721
  init_util();
4516
- import_pinggy4 = require("@pinggy/pinggy");
4722
+ import_pinggy5 = require("@pinggy/pinggy");
4517
4723
  import_fs6 = __toESM(require("fs"), 1);
4518
4724
  import_path6 = __toESM(require("path"), 1);
4519
4725
  import_net2 = require("net");
4520
4726
  domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
4521
4727
  KEYWORDS = /* @__PURE__ */ new Set([
4522
- import_pinggy4.TunnelType.Http,
4523
- import_pinggy4.TunnelType.Tcp,
4524
- import_pinggy4.TunnelType.Tls,
4525
- import_pinggy4.TunnelType.Udp,
4526
- import_pinggy4.TunnelType.TlsTcp,
4728
+ import_pinggy5.TunnelType.Http,
4729
+ import_pinggy5.TunnelType.Tcp,
4730
+ import_pinggy5.TunnelType.Tls,
4731
+ import_pinggy5.TunnelType.Udp,
4732
+ import_pinggy5.TunnelType.TlsTcp,
4527
4733
  "force",
4528
4734
  "qr"
4529
4735
  ]);
4530
- VALID_PROTOCOLS = [import_pinggy4.TunnelType.Http, import_pinggy4.TunnelType.Tcp, import_pinggy4.TunnelType.Udp, import_pinggy4.TunnelType.Tls, import_pinggy4.TunnelType.TlsTcp];
4736
+ VALID_PROTOCOLS = [import_pinggy5.TunnelType.Http, import_pinggy5.TunnelType.Tcp, import_pinggy5.TunnelType.Udp, import_pinggy5.TunnelType.Tls, import_pinggy5.TunnelType.TlsTcp];
4531
4737
  }
4532
4738
  });
4533
4739
 
@@ -5111,7 +5317,7 @@ var init_webDebuggerConnection = __esm({
5111
5317
  });
5112
5318
 
5113
5319
  // src/tui/ink/asciArt.ts
5114
- var asciiArtPinggyLogo;
5320
+ var asciiArtPinggyLogo, asciiArtPinggyLogoCompact, asciiArtPinggyLogoMedium;
5115
5321
  var init_asciArt = __esm({
5116
5322
  "src/tui/ink/asciArt.ts"() {
5117
5323
  "use strict";
@@ -5123,6 +5329,13 @@ var init_asciArt = __esm({
5123
5329
  \u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u255A\u2588\u2588\u2554\u255D
5124
5330
  \u2588\u2588\u2551 \u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2551
5125
5331
  \u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u255D `;
5332
+ asciiArtPinggyLogoCompact = "P I N G G Y";
5333
+ asciiArtPinggyLogoMedium = [
5334
+ " ___ ___ _ _ ___ _____ __",
5335
+ "| _ \\_ _| \\| |/ __|/ __\\ \\ / /",
5336
+ "| _/| || .` | (_ | (_ |\\ V /",
5337
+ "|_| |___|_|\\_|\\___|\\___| |_|"
5338
+ ].join("\n");
5126
5339
  }
5127
5340
  });
5128
5341
 
@@ -5135,6 +5348,54 @@ function colorizeGradient(text) {
5135
5348
  return `{${color}-fg}${line}{/${color}-fg}`;
5136
5349
  }).join("\n");
5137
5350
  }
5351
+ function colorizeGradientHorizontal(text) {
5352
+ const colors = ["red", "yellow", "green", "cyan", "blue", "magenta"];
5353
+ let colorIndex = 0;
5354
+ return [...text].map((char) => {
5355
+ if (char === " ") return char;
5356
+ const color = colors[colorIndex % colors.length];
5357
+ colorIndex++;
5358
+ return `{${color}-fg}${char}{/${color}-fg}`;
5359
+ }).join("");
5360
+ }
5361
+ function selectLogo(width, height) {
5362
+ const widthTier = width >= FULL_LOGO_THRESHOLD ? 2 : width >= MEDIUM_LOGO_THRESHOLD ? 1 : 0;
5363
+ const heightTier = height >= FULL_LOGO_HEIGHT_THRESHOLD ? 2 : height >= MEDIUM_LOGO_HEIGHT_THRESHOLD ? 1 : 0;
5364
+ const tier = Math.min(widthTier, heightTier);
5365
+ if (tier === 2) {
5366
+ return { content: colorizeGradient(asciiArtPinggyLogo), boxHeight: 7 };
5367
+ }
5368
+ if (tier === 1) {
5369
+ return { content: colorizeGradient(asciiArtPinggyLogoMedium), boxHeight: 5 };
5370
+ }
5371
+ return {
5372
+ content: `{bold}${colorizeGradientHorizontal(asciiArtPinggyLogoCompact)}{/bold}`,
5373
+ boxHeight: 1
5374
+ };
5375
+ }
5376
+ function wrapText(text, width) {
5377
+ const lines = [];
5378
+ let current = "";
5379
+ for (const word of text.split(/\s+/).filter(Boolean)) {
5380
+ const candidate = current ? `${current} ${word}` : word;
5381
+ if (candidate.length <= width) {
5382
+ current = candidate;
5383
+ continue;
5384
+ }
5385
+ if (current) {
5386
+ lines.push(current);
5387
+ current = "";
5388
+ }
5389
+ let rest = word;
5390
+ while (rest.length > width) {
5391
+ lines.push(rest.slice(0, width));
5392
+ rest = rest.slice(width);
5393
+ }
5394
+ current = rest;
5395
+ }
5396
+ if (current) lines.push(current);
5397
+ return lines.length ? lines : [""];
5398
+ }
5138
5399
  function createWarningUI(screen) {
5139
5400
  return import_blessed.default.box({
5140
5401
  parent: screen,
@@ -5161,21 +5422,26 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
5161
5422
  height: "100%",
5162
5423
  padding: 1
5163
5424
  });
5425
+ const { content: logoContent, boxHeight: logoHeight } = selectLogo(
5426
+ screen.width,
5427
+ screen.height
5428
+ );
5164
5429
  const logoBox = import_blessed.default.box({
5165
5430
  parent: mainContainer,
5166
5431
  top: 0,
5167
5432
  left: 0,
5168
5433
  width: "100%",
5169
- height: 7,
5170
- content: colorizeGradient(asciiArtPinggyLogo),
5434
+ height: logoHeight,
5435
+ content: logoContent,
5171
5436
  tags: true
5172
5437
  });
5438
+ const contentTop = logoHeight + 1;
5173
5439
  const contentBox = import_blessed.default.box({
5174
5440
  parent: mainContainer,
5175
- top: 8,
5441
+ top: contentTop,
5176
5442
  left: 0,
5177
5443
  width: "100%-2",
5178
- height: "100%-10",
5444
+ height: `100%-${contentTop + 2}`,
5179
5445
  padding: 0,
5180
5446
  border: {
5181
5447
  type: "line"
@@ -5188,20 +5454,24 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
5188
5454
  });
5189
5455
  let greetHeight = 0;
5190
5456
  if (greet) {
5191
- const greetBox = import_blessed.default.box({
5457
+ const contentInnerWidth = screen.width - 6;
5458
+ const greetWidth = Math.max(20, Math.floor(contentInnerWidth * 0.6));
5459
+ const greetLines = wrapText(greet, greetWidth);
5460
+ const greetBoxHeight = greetLines.length + 1;
5461
+ import_blessed.default.box({
5192
5462
  parent: contentBox,
5193
5463
  top: 0,
5194
5464
  left: "center",
5195
- width: "60%",
5196
- height: 4,
5197
- content: `{bold}${greet}{/bold}`,
5465
+ width: greetWidth,
5466
+ height: greetBoxHeight,
5467
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
5198
5468
  tags: true,
5199
5469
  align: "center",
5200
5470
  style: {
5201
5471
  fg: "green"
5202
5472
  }
5203
5473
  });
5204
- greetHeight = 4;
5474
+ greetHeight = greetBoxHeight;
5205
5475
  }
5206
5476
  const upperSectionTop = greetHeight > 0 ? greetHeight : 0;
5207
5477
  const upperSection = import_blessed.default.box({
@@ -5295,21 +5565,22 @@ function createSimpleUI(screen, urls, greet) {
5295
5565
  });
5296
5566
  let currentTop = 0;
5297
5567
  if (greet) {
5568
+ const greetWidth = Math.max(20, Math.floor((screen.width - 2) * 0.9));
5569
+ const greetLines = wrapText(greet, greetWidth);
5298
5570
  import_blessed.default.box({
5299
5571
  parent: mainContainer,
5300
5572
  top: currentTop,
5301
5573
  left: "center",
5302
- width: "90%",
5303
- height: "shrink",
5304
- content: `{bold}${greet}{/bold}`,
5574
+ width: greetWidth,
5575
+ height: greetLines.length,
5576
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
5305
5577
  tags: true,
5306
5578
  align: "center",
5307
5579
  style: {
5308
5580
  fg: "green"
5309
5581
  }
5310
5582
  });
5311
- const lines = Math.ceil(greet.length / (screen.width * 0.9));
5312
- currentTop += Math.max(lines, 1) + 1;
5583
+ currentTop += greetLines.length + 1;
5313
5584
  }
5314
5585
  const urlsBox = import_blessed.default.box({
5315
5586
  parent: mainContainer,
@@ -5348,7 +5619,7 @@ function createSimpleUI(screen, urls, greet) {
5348
5619
  footerBox
5349
5620
  };
5350
5621
  }
5351
- var import_blessed, MIN_WIDTH_WARNING, SIMPLE_LAYOUT_THRESHOLD;
5622
+ var import_blessed, MIN_WIDTH_WARNING, SIMPLE_LAYOUT_THRESHOLD, MEDIUM_LOGO_THRESHOLD, FULL_LOGO_THRESHOLD, MEDIUM_LOGO_HEIGHT_THRESHOLD, FULL_LOGO_HEIGHT_THRESHOLD;
5352
5623
  var init_UIComponents = __esm({
5353
5624
  "src/tui/blessed/components/UIComponents.ts"() {
5354
5625
  "use strict";
@@ -5357,6 +5628,10 @@ var init_UIComponents = __esm({
5357
5628
  init_asciArt();
5358
5629
  MIN_WIDTH_WARNING = 60;
5359
5630
  SIMPLE_LAYOUT_THRESHOLD = 80;
5631
+ MEDIUM_LOGO_THRESHOLD = 100;
5632
+ FULL_LOGO_THRESHOLD = 120;
5633
+ MEDIUM_LOGO_HEIGHT_THRESHOLD = 26;
5634
+ FULL_LOGO_HEIGHT_THRESHOLD = 34;
5360
5635
  }
5361
5636
  });
5362
5637
 
@@ -8322,7 +8597,7 @@ var init_ipcServer = __esm({
8322
8597
  },
8323
8598
  [Route.RestartTunnel]: async (req) => {
8324
8599
  if (!req.tunnelid) throw new Error("Missing 'tunnelid' field");
8325
- return await this.ops.handleRestart(req.tunnelid);
8600
+ return await this.ops.handleRestart(req.tunnelid, req.noWait);
8326
8601
  },
8327
8602
  [Route.UpdateConfig]: async (req) => {
8328
8603
  if (!req.config) throw new Error("Missing 'config' field");
@@ -9817,9 +10092,9 @@ var init_tunnelClient = __esm({
9817
10092
  this.assertClient();
9818
10093
  return this.ipc.getTunnel(tunnelId);
9819
10094
  }
9820
- async handleRestart(tunnelId) {
10095
+ async handleRestart(tunnelId, noWait) {
9821
10096
  this.assertClient();
9822
- return this.ipc.restartTunnel(tunnelId);
10097
+ return this.ipc.restartTunnel(tunnelId, noWait);
9823
10098
  }
9824
10099
  async shutdown() {
9825
10100
  this.assertClient();