pinggy 0.5.2 → 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)
@@ -2717,7 +2778,7 @@ var init_handler = __esm({
2717
2778
  });
2718
2779
 
2719
2780
  // src/daemon/ipc/ipcRoutes.ts
2720
- var Route, SessionMode;
2781
+ var Route, DaemonHost, ShutdownStatus, SessionMode;
2721
2782
  var init_ipcRoutes = __esm({
2722
2783
  "src/daemon/ipc/ipcRoutes.ts"() {
2723
2784
  "use strict";
@@ -2741,6 +2802,14 @@ var init_ipcRoutes = __esm({
2741
2802
  SetTunnelLogging: "POST /config/tunnel-logging",
2742
2803
  GetLogPaths: "GET /logs/paths"
2743
2804
  };
2805
+ DaemonHost = {
2806
+ CLI: "cli",
2807
+ APP: "app"
2808
+ };
2809
+ ShutdownStatus = {
2810
+ ShuttingDown: "shutting_down",
2811
+ RefusedInApp: "refused_in_app"
2812
+ };
2744
2813
  SessionMode = {
2745
2814
  Foreground: "foreground",
2746
2815
  Detached: "detached"
@@ -2787,8 +2856,8 @@ var init_ipcClient = __esm({
2787
2856
  async stopTunnel(tunnelid) {
2788
2857
  return this.call(Route.StopTunnel, { tunnelid });
2789
2858
  }
2790
- async restartTunnel(tunnelid) {
2791
- return this.call(Route.RestartTunnel, { tunnelid });
2859
+ async restartTunnel(tunnelid, noWait) {
2860
+ return this.call(Route.RestartTunnel, { tunnelid, noWait });
2792
2861
  }
2793
2862
  // v1 operations (used by remote management via daemon)
2794
2863
  async startTunnelV1(config, mode, noWait) {
@@ -2970,6 +3039,7 @@ function printHelpMessage() {
2970
3039
  console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
2971
3040
  console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
2972
3041
  console.log(" x:passpreflight Pass CORS preflight requests unchanged");
3042
+ console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
2973
3043
  console.log(" a:Key:Val Add header");
2974
3044
  console.log(" u:Key:Val Update header");
2975
3045
  console.log(" r:Key Remove header");
@@ -3095,7 +3165,7 @@ var init_parseArgs = __esm({
3095
3165
  });
3096
3166
 
3097
3167
  // src/remote_management/websocket_printer.ts
3098
- var import_picocolors3, PENDING_START_TIMEOUT_MS, RemoteManagementWebSocketPrinter, remoteManagementWebSocketPrinter;
3168
+ var import_picocolors3, PENDING_TIMEOUT_MS, PendingTracker, RemoteManagementWebSocketPrinter, remoteManagementWebSocketPrinter;
3099
3169
  var init_websocket_printer = __esm({
3100
3170
  "src/remote_management/websocket_printer.ts"() {
3101
3171
  "use strict";
@@ -3104,13 +3174,71 @@ var init_websocket_printer = __esm({
3104
3174
  init_types();
3105
3175
  init_printer();
3106
3176
  import_picocolors3 = __toESM(require("picocolors"), 1);
3107
- PENDING_START_TIMEOUT_MS = 5 * 60 * 1e3;
3108
- RemoteManagementWebSocketPrinter = class {
3177
+ PENDING_TIMEOUT_MS = 5 * 60 * 1e3;
3178
+ PendingTracker = class {
3109
3179
  constructor() {
3110
- this.pendingStarts = /* @__PURE__ */ new Map();
3180
+ this.entries = /* @__PURE__ */ new Map();
3111
3181
  }
3112
- setTunnelHandler(tunnelHandler) {
3113
- this.tunnelHandler = tunnelHandler;
3182
+ queue(entry) {
3183
+ this.latestConfigId = entry.configId;
3184
+ this.entries.set(entry.configId, entry);
3185
+ }
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();
3114
3242
  }
3115
3243
  queueStart(config) {
3116
3244
  this.cleanupExpiredPendingStarts();
@@ -3119,24 +3247,23 @@ var init_websocket_printer = __esm({
3119
3247
  configName: this.getConfigNameFromRequest(config),
3120
3248
  queuedAt: Date.now()
3121
3249
  };
3122
- this.latestPendingConfigId = entry.configId;
3123
- this.pendingStarts.set(entry.configId, entry);
3250
+ this.pendingStarts.queue(entry);
3124
3251
  printer_default.startSpinner("Starting tunnel with config name: " + entry.configName);
3125
3252
  }
3126
3253
  failQueuedStart(config, reason) {
3127
3254
  const configId = this.getConfigIdFromRequest(config);
3128
3255
  const pending = this.pendingStarts.get(configId);
3129
3256
  const configName = pending?.configName || this.getConfigNameFromRequest(config);
3257
+ const wasLatest = this.pendingStarts.isLatest(configId);
3130
3258
  this.pendingStarts.delete(configId);
3131
- if (this.latestPendingConfigId === configId) {
3132
- this.latestPendingConfigId = void 0;
3259
+ if (wasLatest) {
3133
3260
  printer_default.stopSpinnerFail(`Failed to start tunnel with config name: ${configName}. ${reason}`);
3134
3261
  }
3135
3262
  }
3136
3263
  handleStartResult(config, result) {
3137
3264
  this.cleanupExpiredPendingStarts();
3138
3265
  const requestedConfigId = this.getConfigIdFromRequest(config);
3139
- if (this.latestPendingConfigId && requestedConfigId !== this.latestPendingConfigId) {
3266
+ if (this.pendingStarts.latest && !this.pendingStarts.isLatest(requestedConfigId)) {
3140
3267
  this.pendingStarts.delete(requestedConfigId);
3141
3268
  return;
3142
3269
  }
@@ -3144,20 +3271,20 @@ var init_websocket_printer = __esm({
3144
3271
  this.failQueuedStart(config, result.message);
3145
3272
  return;
3146
3273
  }
3147
- const configId = this.getConfigIdFromTunnel(result);
3148
3274
  const pending = this.pendingStarts.get(requestedConfigId) || {
3149
3275
  configId: requestedConfigId,
3150
3276
  configName: this.getConfigNameFromRequest(config),
3151
3277
  queuedAt: Date.now()
3152
3278
  };
3153
3279
  pending.tunnelId = result.tunnelid;
3154
- this.pendingStarts.set(requestedConfigId, pending);
3280
+ this.pendingStarts.upsert(pending);
3155
3281
  if (result.remoteurls.length > 0) {
3156
3282
  this.completePendingStart(pending, result.remoteurls);
3157
3283
  }
3158
3284
  }
3159
3285
  printStopRequested(tunnelId) {
3160
3286
  const details = this.resolveTunnelDetails(tunnelId);
3287
+ this.clearPendingAction(details.configId, tunnelId);
3161
3288
  printer_default.startSpinner("Stopping tunnel with config name: " + details.configName);
3162
3289
  }
3163
3290
  handleStopResult(tunnelId, result) {
@@ -3171,6 +3298,7 @@ var init_websocket_printer = __esm({
3171
3298
  }
3172
3299
  printRestartRequested(tunnelId) {
3173
3300
  const details = this.resolveTunnelDetails(tunnelId);
3301
+ this.clearPendingAction(details.configId, tunnelId);
3174
3302
  printer_default.startSpinner("Restarting tunnel with config name: " + details.configName);
3175
3303
  }
3176
3304
  handleRestartResult(tunnelId, result) {
@@ -3180,48 +3308,101 @@ var init_websocket_printer = __esm({
3180
3308
  printer_default.stopSpinnerFail("Failed to restart tunnel with config name: " + details.configName);
3181
3309
  return;
3182
3310
  }
3183
- printer_default.stopSpinnerSuccess("Restarted tunnel with config name: " + details.configName);
3184
- if (result.remoteurls?.length > 0) {
3185
- printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3186
- (result.remoteurls ?? []).forEach(
3187
- (url) => printer_default.print(" " + import_picocolors3.default.magentaBright(url))
3188
- );
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;
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;
3189
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)));
3190
3347
  }
3191
3348
  monitorList(result) {
3192
3349
  this.cleanupExpiredPendingStarts();
3193
- if (!Array.isArray(result) || this.pendingStarts.size === 0 || !this.latestPendingConfigId) {
3350
+ this.cleanupExpiredPendingActions();
3351
+ if (!Array.isArray(result)) {
3194
3352
  return;
3195
3353
  }
3196
3354
  for (const tunnel of result) {
3197
- const pending = this.findPendingStart(tunnel);
3198
- if (!pending) {
3199
- 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}`);
3200
3376
  }
3201
- 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)) {
3202
3386
  continue;
3203
3387
  }
3204
- pending.tunnelId = tunnel.tunnelid;
3205
- this.pendingStarts.set(pending.configId, pending);
3388
+ entry.tunnelId = tunnel.tunnelid;
3389
+ tracker.upsert(entry);
3206
3390
  if (tunnel.remoteurls.length > 0) {
3207
- this.completePendingStart(pending, tunnel.remoteurls);
3391
+ onComplete(entry, tunnel.remoteurls);
3208
3392
  continue;
3209
3393
  }
3210
3394
  if (tunnel.status.state === "exited" /* Exited */) {
3211
- const reason = tunnel.status.errormsg || "Tunnel exited before a public URL was assigned";
3212
- this.pendingStarts.delete(pending.configId);
3213
- this.latestPendingConfigId = void 0;
3214
- 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");
3215
3397
  }
3216
3398
  }
3217
3399
  }
3218
3400
  completePendingStart(entry, urls) {
3219
- if (this.latestPendingConfigId && entry.configId !== this.latestPendingConfigId) {
3401
+ if (this.pendingStarts.latest && !this.pendingStarts.isLatest(entry.configId)) {
3220
3402
  this.pendingStarts.delete(entry.configId);
3221
3403
  return;
3222
3404
  }
3223
3405
  this.pendingStarts.delete(entry.configId);
3224
- this.latestPendingConfigId = void 0;
3225
3406
  printer_default.stopSpinnerSuccess(`Tunnel started with config name: ${entry.configName}.`);
3226
3407
  printer_default.info(import_picocolors3.default.cyanBright("Remote URLs:"));
3227
3408
  (urls ?? []).forEach(
@@ -3229,28 +3410,27 @@ var init_websocket_printer = __esm({
3229
3410
  );
3230
3411
  }
3231
3412
  cleanupExpiredPendingStarts() {
3232
- const now = Date.now();
3233
- for (const [configId, entry] of this.pendingStarts.entries()) {
3234
- if (now - entry.queuedAt <= PENDING_START_TIMEOUT_MS) {
3235
- 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);
3236
3419
  }
3237
- this.pendingStarts.delete(configId);
3238
- printer_default.warn(`Timed out while waiting for tunnel URL for config name: ${entry.configName}`);
3239
- logger.warn("Pending websocket start entry expired", { configId, tunnelId: entry.tunnelId });
3240
- }
3420
+ logger.warn("Pending websocket start entry expired", { configId: entry.configId, tunnelId: entry.tunnelId });
3421
+ });
3241
3422
  }
3242
- findPendingStart(tunnel) {
3243
- const configId = this.getConfigIdFromTunnel(tunnel);
3244
- const byConfigId = this.pendingStarts.get(configId);
3245
- if (byConfigId) {
3246
- return byConfigId;
3247
- }
3248
- for (const entry of this.pendingStarts.values()) {
3249
- if (entry.tunnelId === tunnel.tunnelid) {
3250
- 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);
3251
3431
  }
3252
- }
3253
- return void 0;
3432
+ logger.warn("Pending websocket action entry expired", { configId: entry.configId, tunnelId: entry.tunnelId, action: entry.action });
3433
+ });
3254
3434
  }
3255
3435
  resolveTunnelDetails(tunnelId, result) {
3256
3436
  if (result && !isErrorResponse(result)) {
@@ -3259,7 +3439,7 @@ var init_websocket_printer = __esm({
3259
3439
  configName: this.getConfigNameFromTunnel(result)
3260
3440
  };
3261
3441
  }
3262
- return {
3442
+ return this.tunnelDetailsCache.get(tunnelId) ?? {
3263
3443
  configId: tunnelId,
3264
3444
  configName: tunnelId
3265
3445
  };
@@ -3338,7 +3518,6 @@ var init_websocket_handlers = __esm({
3338
3518
  WebSocketCommandHandler = class {
3339
3519
  constructor(handler) {
3340
3520
  this.tunnelHandler = handler ?? new TunnelOperations();
3341
- remoteManagementWebSocketPrinter.setTunnelHandler(this.tunnelHandler);
3342
3521
  }
3343
3522
  safeParse(text) {
3344
3523
  if (!text) return void 0;
@@ -3405,8 +3584,10 @@ var init_websocket_handlers = __esm({
3405
3584
  }
3406
3585
  }
3407
3586
  async handleStopReq(req, raw) {
3587
+ let tunnelID;
3408
3588
  try {
3409
3589
  const dc = StopSchema.parse(raw);
3590
+ tunnelID = dc.tunnelID;
3410
3591
  remoteManagementWebSocketPrinter.printStopRequested(dc.tunnelID);
3411
3592
  const result = await this.tunnelHandler.handleStop(dc.tunnelID);
3412
3593
  remoteManagementWebSocketPrinter.handleStopResult(dc.tunnelID, result);
@@ -3416,6 +3597,9 @@ var init_websocket_handlers = __esm({
3416
3597
  printer_default.warn("Validation failed for stop request");
3417
3598
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3418
3599
  }
3600
+ if (tunnelID) {
3601
+ remoteManagementWebSocketPrinter.handleStopResult(tunnelID, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3602
+ }
3419
3603
  printer_default.warn(`Error in handleStopReq error: ${String(e)}`);
3420
3604
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3421
3605
  }
@@ -3435,8 +3619,10 @@ var init_websocket_handlers = __esm({
3435
3619
  }
3436
3620
  }
3437
3621
  async handleRestartReq(req, raw) {
3622
+ let tunnelID;
3438
3623
  try {
3439
3624
  const dc = RestartSchema.parse(raw);
3625
+ tunnelID = dc.tunnelID;
3440
3626
  remoteManagementWebSocketPrinter.printRestartRequested(dc.tunnelID);
3441
3627
  const result = await this.tunnelHandler.handleRestart(dc.tunnelID, true);
3442
3628
  remoteManagementWebSocketPrinter.handleRestartResult(dc.tunnelID, result);
@@ -3446,34 +3632,51 @@ var init_websocket_handlers = __esm({
3446
3632
  printer_default.warn("Validation failed for restart request");
3447
3633
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3448
3634
  }
3635
+ if (tunnelID) {
3636
+ remoteManagementWebSocketPrinter.handleRestartResult(tunnelID, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3637
+ }
3449
3638
  printer_default.warn(`Error in handleRestartReq error: ${String(e)}`);
3450
3639
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3451
3640
  }
3452
3641
  }
3453
3642
  async handleUpdateConfigReq(req, raw) {
3643
+ let parsedConfig;
3454
3644
  try {
3455
3645
  const dc = UpdateConfigSchema.parse(raw);
3646
+ parsedConfig = dc.tunnelConfig;
3647
+ remoteManagementWebSocketPrinter.printUpdateConfigRequested(dc.tunnelConfig);
3456
3648
  const result = await this.tunnelHandler.handleUpdateConfig(dc.tunnelConfig, true);
3649
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(dc.tunnelConfig, result);
3457
3650
  return this.wrapResponse(result, req);
3458
3651
  } catch (e) {
3459
3652
  if (e instanceof import_zod2.default.ZodError) {
3460
3653
  printer_default.warn("Validation failed for updateconfig request");
3461
3654
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3462
3655
  }
3656
+ if (parsedConfig) {
3657
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(parsedConfig, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3658
+ }
3463
3659
  printer_default.warn(`Error in handleUpdateConfigReq error: ${String(e)}`);
3464
3660
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3465
3661
  }
3466
3662
  }
3467
3663
  async handleUpdateConfigV2Req(req, raw) {
3664
+ let parsedConfig;
3468
3665
  try {
3469
3666
  const dc = UpdateConfigV2Schema.parse(raw);
3667
+ parsedConfig = dc.tunnelConfig;
3668
+ remoteManagementWebSocketPrinter.printUpdateConfigRequested(dc.tunnelConfig);
3470
3669
  const result = await this.tunnelHandler.handleUpdateConfigV2(dc.tunnelConfig, true);
3670
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(dc.tunnelConfig, result);
3471
3671
  return this.wrapResponse(result, req);
3472
3672
  } catch (e) {
3473
3673
  if (e instanceof import_zod2.default.ZodError) {
3474
3674
  printer_default.warn("Validation failed for update-config-v2 request");
3475
3675
  return NewErrorResponseObject({ code: ErrorCode.InvalidBodyFormatError, message: "Validation failed" });
3476
3676
  }
3677
+ if (parsedConfig) {
3678
+ remoteManagementWebSocketPrinter.handleUpdateConfigResult(parsedConfig, newErrorResponse({ code: ErrorCode.InternalServerError, message: errorMessage(e) }));
3679
+ }
3477
3680
  printer_default.warn(`Error in handleUpdateConfigV2Req error: ${String(e)}`);
3478
3681
  return NewErrorResponseObject({ code: ErrorCode.InternalServerError, message: String(e) });
3479
3682
  }
@@ -3908,10 +4111,20 @@ function parseExtendedOptions(options, config, localServerTls) {
3908
4111
  case "fullrequesturl":
3909
4112
  config.originalRequestUrl = true;
3910
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;
3911
4121
  default: {
3912
4122
  if (value && (value.startsWith("localServerTls") || value.startsWith("localservertls"))) {
3913
4123
  const parts = value.split(/:(.+)/);
3914
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`);
3915
4128
  } else {
3916
4129
  printer_default.warn(`Unknown extended option "${value}"`);
3917
4130
  logger.warn(`Warning: Unknown extended option "${value}"`);
@@ -4013,11 +4226,12 @@ function isValidIpV6Cidr(input) {
4013
4226
  }
4014
4227
  return false;
4015
4228
  }
4016
- var import_net;
4229
+ var import_pinggy4, import_net;
4017
4230
  var init_extendedOptions = __esm({
4018
4231
  "src/cli/extendedOptions.ts"() {
4019
4232
  "use strict";
4020
4233
  init_cjs_shims();
4234
+ import_pinggy4 = require("@pinggy/pinggy");
4021
4235
  import_net = require("net");
4022
4236
  init_logger();
4023
4237
  init_printer();
@@ -4054,7 +4268,7 @@ function parseUserAndDomain(str) {
4054
4268
  const [user, domain] = str.split("@", 2);
4055
4269
  if (isValidServerAddress(domain)) {
4056
4270
  let processKeyword2 = function(keyword) {
4057
- 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)) {
4058
4272
  type = keyword;
4059
4273
  } else if (keyword === "force") {
4060
4274
  forceFlag = true;
@@ -4142,7 +4356,7 @@ function parseUsers(positionalArgs, explicitToken) {
4142
4356
  }
4143
4357
  function parseType(finalConfig, values, inferredType) {
4144
4358
  const t = inferredType || values.type;
4145
- 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) {
4146
4360
  return t;
4147
4361
  }
4148
4362
  }
@@ -4240,7 +4454,7 @@ function parseAdditionalForwarding(forwarding) {
4240
4454
  }
4241
4455
  const firstPart = parsed[0];
4242
4456
  const [hostPart] = firstPart.split("@");
4243
- let protocol = import_pinggy4.TunnelType.Http;
4457
+ let protocol = import_pinggy5.TunnelType.Http;
4244
4458
  let remoteDomainRaw;
4245
4459
  let remotePort = 0;
4246
4460
  if (hostPart.includes("//")) {
@@ -4273,7 +4487,7 @@ function parseAdditionalForwarding(forwarding) {
4273
4487
  if (!isValidServerAddress(remoteDomainRaw)) {
4274
4488
  return new Error("invalid remote domain");
4275
4489
  }
4276
- protocol = import_pinggy4.TunnelType.Http;
4490
+ protocol = import_pinggy5.TunnelType.Http;
4277
4491
  remotePort = 0;
4278
4492
  }
4279
4493
  const localDomain = removeIPv6Brackets(parsed[2] || "localhost");
@@ -4303,7 +4517,7 @@ function parseReverseTunnelAddr(finalConfig, values, primaryType) {
4303
4517
  if (parsed instanceof Error) return parsed;
4304
4518
  forwardingData.push({
4305
4519
  address: `${parsed.localDomain}:${parsed.localPort}`,
4306
- type: primaryType || import_pinggy4.TunnelType.Http
4520
+ type: primaryType || import_pinggy5.TunnelType.Http
4307
4521
  });
4308
4522
  } else if (slicedForwarding.length === 4) {
4309
4523
  const parsed = parseAdditionalForwarding(forwarding);
@@ -4369,7 +4583,7 @@ function normalizeForwardingToArray(finalConfig, type) {
4369
4583
  if (typeof fwd !== "string") {
4370
4584
  return;
4371
4585
  }
4372
- 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() }];
4373
4587
  }
4374
4588
  function parseArgs2(finalConfig, remainingPositionals) {
4375
4589
  let localserverTls = "";
@@ -4496,7 +4710,7 @@ function buildFinalConfig(values, positionals, baseConfig) {
4496
4710
  storeJson(finalConfig, saveconf);
4497
4711
  return finalConfig;
4498
4712
  }
4499
- 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;
4500
4714
  var init_buildConfig = __esm({
4501
4715
  "src/cli/buildConfig.ts"() {
4502
4716
  "use strict";
@@ -4505,21 +4719,21 @@ var init_buildConfig = __esm({
4505
4719
  init_extendedOptions();
4506
4720
  init_logger();
4507
4721
  init_util();
4508
- import_pinggy4 = require("@pinggy/pinggy");
4722
+ import_pinggy5 = require("@pinggy/pinggy");
4509
4723
  import_fs6 = __toESM(require("fs"), 1);
4510
4724
  import_path6 = __toESM(require("path"), 1);
4511
4725
  import_net2 = require("net");
4512
4726
  domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
4513
4727
  KEYWORDS = /* @__PURE__ */ new Set([
4514
- import_pinggy4.TunnelType.Http,
4515
- import_pinggy4.TunnelType.Tcp,
4516
- import_pinggy4.TunnelType.Tls,
4517
- import_pinggy4.TunnelType.Udp,
4518
- 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,
4519
4733
  "force",
4520
4734
  "qr"
4521
4735
  ]);
4522
- 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];
4523
4737
  }
4524
4738
  });
4525
4739
 
@@ -5103,7 +5317,7 @@ var init_webDebuggerConnection = __esm({
5103
5317
  });
5104
5318
 
5105
5319
  // src/tui/ink/asciArt.ts
5106
- var asciiArtPinggyLogo;
5320
+ var asciiArtPinggyLogo, asciiArtPinggyLogoCompact, asciiArtPinggyLogoMedium;
5107
5321
  var init_asciArt = __esm({
5108
5322
  "src/tui/ink/asciArt.ts"() {
5109
5323
  "use strict";
@@ -5115,6 +5329,13 @@ var init_asciArt = __esm({
5115
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
5116
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
5117
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");
5118
5339
  }
5119
5340
  });
5120
5341
 
@@ -5127,6 +5348,54 @@ function colorizeGradient(text) {
5127
5348
  return `{${color}-fg}${line}{/${color}-fg}`;
5128
5349
  }).join("\n");
5129
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
+ }
5130
5399
  function createWarningUI(screen) {
5131
5400
  return import_blessed.default.box({
5132
5401
  parent: screen,
@@ -5153,21 +5422,26 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
5153
5422
  height: "100%",
5154
5423
  padding: 1
5155
5424
  });
5425
+ const { content: logoContent, boxHeight: logoHeight } = selectLogo(
5426
+ screen.width,
5427
+ screen.height
5428
+ );
5156
5429
  const logoBox = import_blessed.default.box({
5157
5430
  parent: mainContainer,
5158
5431
  top: 0,
5159
5432
  left: 0,
5160
5433
  width: "100%",
5161
- height: 7,
5162
- content: colorizeGradient(asciiArtPinggyLogo),
5434
+ height: logoHeight,
5435
+ content: logoContent,
5163
5436
  tags: true
5164
5437
  });
5438
+ const contentTop = logoHeight + 1;
5165
5439
  const contentBox = import_blessed.default.box({
5166
5440
  parent: mainContainer,
5167
- top: 8,
5441
+ top: contentTop,
5168
5442
  left: 0,
5169
5443
  width: "100%-2",
5170
- height: "100%-10",
5444
+ height: `100%-${contentTop + 2}`,
5171
5445
  padding: 0,
5172
5446
  border: {
5173
5447
  type: "line"
@@ -5180,20 +5454,24 @@ function createFullUI(screen, urls, greet, tunnelConfig) {
5180
5454
  });
5181
5455
  let greetHeight = 0;
5182
5456
  if (greet) {
5183
- 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({
5184
5462
  parent: contentBox,
5185
5463
  top: 0,
5186
5464
  left: "center",
5187
- width: "60%",
5188
- height: 4,
5189
- content: `{bold}${greet}{/bold}`,
5465
+ width: greetWidth,
5466
+ height: greetBoxHeight,
5467
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
5190
5468
  tags: true,
5191
5469
  align: "center",
5192
5470
  style: {
5193
5471
  fg: "green"
5194
5472
  }
5195
5473
  });
5196
- greetHeight = 4;
5474
+ greetHeight = greetBoxHeight;
5197
5475
  }
5198
5476
  const upperSectionTop = greetHeight > 0 ? greetHeight : 0;
5199
5477
  const upperSection = import_blessed.default.box({
@@ -5287,21 +5565,22 @@ function createSimpleUI(screen, urls, greet) {
5287
5565
  });
5288
5566
  let currentTop = 0;
5289
5567
  if (greet) {
5568
+ const greetWidth = Math.max(20, Math.floor((screen.width - 2) * 0.9));
5569
+ const greetLines = wrapText(greet, greetWidth);
5290
5570
  import_blessed.default.box({
5291
5571
  parent: mainContainer,
5292
5572
  top: currentTop,
5293
5573
  left: "center",
5294
- width: "90%",
5295
- height: "shrink",
5296
- content: `{bold}${greet}{/bold}`,
5574
+ width: greetWidth,
5575
+ height: greetLines.length,
5576
+ content: greetLines.map((line) => `{bold}${line}{/bold}`).join("\n"),
5297
5577
  tags: true,
5298
5578
  align: "center",
5299
5579
  style: {
5300
5580
  fg: "green"
5301
5581
  }
5302
5582
  });
5303
- const lines = Math.ceil(greet.length / (screen.width * 0.9));
5304
- currentTop += Math.max(lines, 1) + 1;
5583
+ currentTop += greetLines.length + 1;
5305
5584
  }
5306
5585
  const urlsBox = import_blessed.default.box({
5307
5586
  parent: mainContainer,
@@ -5340,7 +5619,7 @@ function createSimpleUI(screen, urls, greet) {
5340
5619
  footerBox
5341
5620
  };
5342
5621
  }
5343
- 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;
5344
5623
  var init_UIComponents = __esm({
5345
5624
  "src/tui/blessed/components/UIComponents.ts"() {
5346
5625
  "use strict";
@@ -5349,6 +5628,10 @@ var init_UIComponents = __esm({
5349
5628
  init_asciArt();
5350
5629
  MIN_WIDTH_WARNING = 60;
5351
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;
5352
5635
  }
5353
5636
  });
5354
5637
 
@@ -7243,6 +7526,8 @@ async function handleDaemonStop() {
7243
7526
  const result = await stopDaemon();
7244
7527
  if (result.ok) {
7245
7528
  printer_default.success("Daemon stopped.");
7529
+ } else if (result.reason === DaemonHost.APP) {
7530
+ printer_default.print(import_picocolors6.default.yellow("The Pinggy daemon is running inside the Pinggy desktop app. Please quit the Pinggy app to stop it."));
7246
7531
  } else {
7247
7532
  printer_default.error(result.error);
7248
7533
  }
@@ -7263,6 +7548,7 @@ function handleDaemonStatus() {
7263
7548
  printer_default.print(` Port: ${info.port}`);
7264
7549
  printer_default.print(` Started: ${info.startedAt}`);
7265
7550
  printer_default.print(` Uptime: ${uptimeStr}`);
7551
+ printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
7266
7552
  }
7267
7553
  var import_picocolors6;
7268
7554
  var init_daemonCommandsHandler = __esm({
@@ -7273,6 +7559,7 @@ var init_daemonCommandsHandler = __esm({
7273
7559
  import_picocolors6 = __toESM(require("picocolors"), 1);
7274
7560
  init_configStore();
7275
7561
  init_daemonManager();
7562
+ init_ipcRoutes();
7276
7563
  init_serviceInstaller();
7277
7564
  init_helpMessages();
7278
7565
  init_util();
@@ -8007,6 +8294,7 @@ var init_subcommands = __esm({
8007
8294
  // src/main.ts
8008
8295
  var main_exports = {};
8009
8296
  __export(main_exports, {
8297
+ DaemonHost: () => DaemonHost,
8010
8298
  RemoteManagementUnauthorizedError: () => RemoteManagementUnauthorizedError,
8011
8299
  TunnelManager: () => TunnelManager,
8012
8300
  TunnelOperations: () => TunnelOperations,
@@ -8069,6 +8357,7 @@ var init_main = __esm({
8069
8357
  init_buildAndStartTunnel();
8070
8358
  init_subcommands();
8071
8359
  init_daemonChild();
8360
+ init_ipcRoutes();
8072
8361
  init_daemonManager();
8073
8362
  currentFile = (0, import_url2.fileURLToPath)(importMetaUrl);
8074
8363
  entryFile = null;
@@ -8308,7 +8597,7 @@ var init_ipcServer = __esm({
8308
8597
  },
8309
8598
  [Route.RestartTunnel]: async (req) => {
8310
8599
  if (!req.tunnelid) throw new Error("Missing 'tunnelid' field");
8311
- return await this.ops.handleRestart(req.tunnelid);
8600
+ return await this.ops.handleRestart(req.tunnelid, req.noWait);
8312
8601
  },
8313
8602
  [Route.UpdateConfig]: async (req) => {
8314
8603
  if (!req.config) throw new Error("Missing 'config' field");
@@ -8375,6 +8664,10 @@ var init_ipcServer = __esm({
8375
8664
  },
8376
8665
  [Route.Shutdown]: () => {
8377
8666
  logger.info("Daemon shutdown requested via IPC");
8667
+ if (getDaemonHost() === DaemonHost.APP) {
8668
+ logger.info("Shutdown refused: daemon is hosted in-process by the Pinggy app");
8669
+ return { status: ShutdownStatus.RefusedInApp, errors: [] };
8670
+ }
8378
8671
  const errors = [];
8379
8672
  const step = (label, fn) => {
8380
8673
  try {
@@ -8389,7 +8682,7 @@ var init_ipcServer = __esm({
8389
8682
  step("clearDaemonState", clearDaemonState);
8390
8683
  step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels());
8391
8684
  setTimeout(() => process.exit(0), 200);
8392
- return { status: "shutting_down", errors };
8685
+ return { status: ShutdownStatus.ShuttingDown, errors };
8393
8686
  }
8394
8687
  };
8395
8688
  }
@@ -8847,6 +9140,8 @@ var init_sessionTracker = __esm({
8847
9140
  // src/daemon/lifecycle/daemonChild.ts
8848
9141
  var daemonChild_exports = {};
8849
9142
  __export(daemonChild_exports, {
9143
+ DaemonHost: () => DaemonHost,
9144
+ getDaemonHost: () => getDaemonHost,
8850
9145
  removeDaemonInfo: () => removeDaemonInfo,
8851
9146
  runDaemonChild: () => runDaemonChild,
8852
9147
  setDaemonSessionTracker: () => setDaemonSessionTracker,
@@ -8854,6 +9149,9 @@ __export(daemonChild_exports, {
8854
9149
  trackTunnelStart: () => trackTunnelStart,
8855
9150
  trackTunnelStop: () => trackTunnelStop
8856
9151
  });
9152
+ function getDaemonHost() {
9153
+ return daemonHost;
9154
+ }
8857
9155
  function setDaemonSessionTracker(st) {
8858
9156
  sessionTrackerRef = st;
8859
9157
  }
@@ -8982,6 +9280,7 @@ async function restoreCrashedTunnels(manager) {
8982
9280
  async function runDaemonChild(opts = {}) {
8983
9281
  const installSignalHandlers = opts.installSignalHandlers ?? true;
8984
9282
  const exitOnFailure = opts.exitOnFailure ?? true;
9283
+ daemonHost = opts.host ?? DaemonHost.CLI;
8985
9284
  ensurePinggyConfigDir();
8986
9285
  const persistedLevel = readDaemonConfig()?.logLevel;
8987
9286
  const envLevel = process.env.PINGGY_LOG_LEVEL;
@@ -9057,7 +9356,8 @@ async function runDaemonChild(opts = {}) {
9057
9356
  const info = {
9058
9357
  pid: process.pid,
9059
9358
  port,
9060
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
9359
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9360
+ host: daemonHost
9061
9361
  };
9062
9362
  writeDaemonInfo(info);
9063
9363
  logger.info("Daemon info written", info);
@@ -9090,7 +9390,7 @@ async function runDaemonChild(opts = {}) {
9090
9390
  throw err;
9091
9391
  }
9092
9392
  }
9093
- var import_node_fs7, daemonState, sessionTrackerRef;
9393
+ var import_node_fs7, daemonState, daemonHost, sessionTrackerRef;
9094
9394
  var init_daemonChild = __esm({
9095
9395
  "src/daemon/lifecycle/daemonChild.ts"() {
9096
9396
  "use strict";
@@ -9108,6 +9408,7 @@ var init_daemonChild = __esm({
9108
9408
  init_util();
9109
9409
  init_ipcRoutes();
9110
9410
  daemonState = { tunnels: [], lastUpdated: "" };
9411
+ daemonHost = DaemonHost.CLI;
9111
9412
  sessionTrackerRef = null;
9112
9413
  }
9113
9414
  });
@@ -9194,13 +9495,15 @@ async function ensureDaemonRunning() {
9194
9495
  const { runDaemonChild: runDaemonChild2 } = await Promise.resolve().then(() => (init_daemonChild(), daemonChild_exports));
9195
9496
  const handle = await runDaemonChild2({
9196
9497
  installSignalHandlers: false,
9197
- exitOnFailure: false
9498
+ exitOnFailure: false,
9499
+ host: DaemonHost.APP
9198
9500
  });
9199
9501
  inProcessHandle = handle;
9200
9502
  return getDaemonInfo() ?? {
9201
9503
  pid: handle.pid,
9202
9504
  port: handle.port,
9203
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
9505
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9506
+ host: DaemonHost.APP
9204
9507
  };
9205
9508
  }
9206
9509
  return startDaemon();
@@ -9262,6 +9565,13 @@ async function stopDaemon() {
9262
9565
  const client = new IPCClient2(info.port);
9263
9566
  const result = await client.shutdown();
9264
9567
  logger.debug("Sent shutdown command to daemon", { result });
9568
+ if (result?.status === ShutdownStatus.RefusedInApp) {
9569
+ return {
9570
+ ok: false,
9571
+ reason: DaemonHost.APP,
9572
+ error: "The daemon is running inside the Pinggy app."
9573
+ };
9574
+ }
9265
9575
  if (Array.isArray(result?.errors)) daemonErrors = result.errors;
9266
9576
  } catch (e) {
9267
9577
  return { ok: false, error: `Failed to reach daemon: ${errorMessage(e)}` };
@@ -9293,6 +9603,7 @@ var init_daemonManager = __esm({
9293
9603
  import_node_fs8 = __toESM(require("fs"), 1);
9294
9604
  import_node_child_process2 = require("child_process");
9295
9605
  init_configDir();
9606
+ init_ipcRoutes();
9296
9607
  init_logger();
9297
9608
  init_types();
9298
9609
  init_util();
@@ -9583,6 +9894,13 @@ var init_daemonHealth = __esm({
9583
9894
  bindPid(pid) {
9584
9895
  this.originalPid = pid;
9585
9896
  }
9897
+ /**
9898
+ * Forget a previous loss verdict. Called when the client re-binds to a
9899
+ * live daemon via ensureDaemon():
9900
+ */
9901
+ reset() {
9902
+ this.lost = false;
9903
+ }
9586
9904
  isLost() {
9587
9905
  return this.lost;
9588
9906
  }
@@ -9709,6 +10027,7 @@ var init_tunnelClient = __esm({
9709
10027
  const info = await ensureDaemonRunning();
9710
10028
  this.ipc = new IPCClient(info.port, this.origin);
9711
10029
  this.health.bindPid(info.pid);
10030
+ this.health.reset();
9712
10031
  }
9713
10032
  static async forRemoteManagement() {
9714
10033
  const client = new _TunnelClient({ origin: "remote" });
@@ -9773,15 +10092,30 @@ var init_tunnelClient = __esm({
9773
10092
  this.assertClient();
9774
10093
  return this.ipc.getTunnel(tunnelId);
9775
10094
  }
9776
- async handleRestart(tunnelId) {
10095
+ async handleRestart(tunnelId, noWait) {
9777
10096
  this.assertClient();
9778
- return this.ipc.restartTunnel(tunnelId);
10097
+ return this.ipc.restartTunnel(tunnelId, noWait);
9779
10098
  }
9780
10099
  async shutdown() {
9781
10100
  this.assertClient();
9782
10101
  await this.ipc.shutdown();
9783
10102
  this.close();
9784
10103
  }
10104
+ /**
10105
+ * Tear down the daemon hosted in THIS process, if any. Stops all tunnels
10106
+ * and removes daemon.json + daemon-state.json so the next CLI run doesn't
10107
+ * "crash-recover" tunnels that died with the host application.
10108
+ */
10109
+ shutdownInProcessDaemon() {
10110
+ const handle = getInProcessDaemonHandle();
10111
+ if (!handle) return false;
10112
+ try {
10113
+ this.close();
10114
+ } catch {
10115
+ }
10116
+ handle.shutdown();
10117
+ return true;
10118
+ }
9785
10119
  async getLogLevel() {
9786
10120
  this.assertClient();
9787
10121
  const res = await this.ipc.getLogLevel();
@@ -9914,11 +10248,13 @@ __export(index_exports, {
9914
10248
  findConfig: () => findConfig,
9915
10249
  findConfigByName: () => findConfigByName,
9916
10250
  getAutoStartConfigs: () => getAutoStartConfigs,
10251
+ getDaemonInfo: () => getDaemonInfo,
9917
10252
  getDaemonInfoPath: () => getDaemonInfoPath,
9918
10253
  getPinggyConfigDir: () => getPinggyConfigDir,
9919
10254
  getRemoteManagementState: () => getRemoteManagementState,
9920
10255
  getTunnelConfigDir: () => getTunnelConfigDir,
9921
10256
  initiateRemoteManagement: () => initiateRemoteManagement,
10257
+ isDaemonRunning: () => isDaemonRunning,
9922
10258
  listSavedConfigs: () => listSavedConfigs,
9923
10259
  loadConfigByName: () => loadConfigByName,
9924
10260
  sanitizeName: () => sanitizeName,
@@ -10019,6 +10355,7 @@ init_printer();
10019
10355
  init_TunnelManager();
10020
10356
  init_handler();
10021
10357
  init_tunnelClient();
10358
+ init_daemonManager();
10022
10359
  init_logger();
10023
10360
  init_remoteManagement();
10024
10361
  init_types();
@@ -10061,11 +10398,13 @@ verifyAndLoad().catch((err) => {
10061
10398
  findConfig,
10062
10399
  findConfigByName,
10063
10400
  getAutoStartConfigs,
10401
+ getDaemonInfo,
10064
10402
  getDaemonInfoPath,
10065
10403
  getPinggyConfigDir,
10066
10404
  getRemoteManagementState,
10067
10405
  getTunnelConfigDir,
10068
10406
  initiateRemoteManagement,
10407
+ isDaemonRunning,
10069
10408
  listSavedConfigs,
10070
10409
  loadConfigByName,
10071
10410
  sanitizeName,