@whatalo/cli-kit 1.0.2 → 1.1.1

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
@@ -674,7 +674,9 @@ var import_promises4 = require("fs/promises");
674
674
  var import_node_path4 = __toESM(require("path"), 1);
675
675
  var import_node_os2 = __toESM(require("os"), 1);
676
676
  var import_node_https = __toESM(require("https"), 1);
677
- var TUNNEL_START_TIMEOUT_MS = 15e3;
677
+ var TUNNEL_START_TIMEOUT_MS = 4e4;
678
+ var MAX_TUNNEL_RETRIES = 5;
679
+ var TUNNEL_RETRY_DELAY_MS = 1e3;
678
680
  var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
679
681
  function getBinDir() {
680
682
  return import_node_path4.default.join(import_node_os2.default.homedir(), ".whatalo", "bin");
@@ -785,9 +787,7 @@ async function downloadCloudflared(targetPath) {
785
787
  info(`cloudflared saved to ${targetPath}`);
786
788
  return targetPath;
787
789
  }
788
- async function createTunnel(options) {
789
- const { localPort, protocol = "http" } = options;
790
- const binaryPath = await ensureCloudflared();
790
+ function spawnTunnelAttempt(binaryPath, localPort, protocol) {
791
791
  return new Promise((resolve, reject) => {
792
792
  const child = (0, import_node_child_process.spawn)(
793
793
  binaryPath,
@@ -825,16 +825,7 @@ async function createTunnel(options) {
825
825
  if (match && !urlFound) {
826
826
  urlFound = true;
827
827
  clearTimeout(timeout);
828
- const tunnelUrl = match[0];
829
- const kill = () => new Promise((res) => {
830
- if (child.exitCode !== null) {
831
- res();
832
- return;
833
- }
834
- child.once("exit", () => res());
835
- child.kill("SIGTERM");
836
- });
837
- resolve({ url: tunnelUrl, process: child, kill });
828
+ resolve([match[0], child]);
838
829
  }
839
830
  }
840
831
  });
@@ -861,6 +852,77 @@ async function createTunnel(options) {
861
852
  });
862
853
  });
863
854
  }
855
+ function attachHealthMonitor(child, isStopped, reconnect, onReconnect) {
856
+ child.on("close", (code2, signal) => {
857
+ if (isStopped()) return;
858
+ warn(
859
+ `Tunnel process exited unexpectedly (code=${code2 ?? "null"}, signal=${signal ?? "none"}). Reconnecting...`
860
+ );
861
+ reconnect().then(([newUrl, newChild]) => {
862
+ info(`Tunnel reconnected (webhooks): ${newUrl}`);
863
+ attachHealthMonitor(newChild, isStopped, reconnect, onReconnect);
864
+ if (onReconnect) {
865
+ void Promise.resolve(onReconnect(newUrl));
866
+ }
867
+ }).catch((err) => {
868
+ const message = err instanceof Error ? err.message : "unknown error";
869
+ warn(`Tunnel reconnect failed: ${message}`);
870
+ });
871
+ });
872
+ }
873
+ async function createTunnel(options) {
874
+ const {
875
+ localPort,
876
+ protocol = "http",
877
+ maxRetries = MAX_TUNNEL_RETRIES,
878
+ onReconnect
879
+ } = options;
880
+ const binaryPath = await ensureCloudflared();
881
+ const spawnNew = () => spawnTunnelAttempt(binaryPath, localPort, protocol);
882
+ let lastError;
883
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
884
+ try {
885
+ if (attempt > 1) {
886
+ info(`Tunnel attempt ${attempt}/${maxRetries}...`);
887
+ }
888
+ const [tunnelUrl, child] = await spawnNew();
889
+ let stopped = false;
890
+ let currentChild = child;
891
+ attachHealthMonitor(
892
+ child,
893
+ () => stopped,
894
+ async () => {
895
+ const result = await spawnNew();
896
+ currentChild = result[1];
897
+ return result;
898
+ },
899
+ onReconnect
900
+ );
901
+ const kill = () => new Promise((res) => {
902
+ stopped = true;
903
+ if (currentChild.exitCode !== null) {
904
+ res();
905
+ return;
906
+ }
907
+ currentChild.once("exit", () => res());
908
+ currentChild.kill("SIGTERM");
909
+ });
910
+ return { url: tunnelUrl, process: child, kill };
911
+ } catch (err) {
912
+ lastError = err instanceof Error ? err : new Error(String(err));
913
+ if (attempt < maxRetries) {
914
+ warn(
915
+ `Tunnel startup failed (attempt ${attempt}/${maxRetries}): ${lastError.message}`
916
+ );
917
+ await new Promise((r) => setTimeout(r, TUNNEL_RETRY_DELAY_MS));
918
+ }
919
+ }
920
+ }
921
+ throw new Error(
922
+ `Tunnel failed after ${maxRetries} attempts. Check your network or use \`--tunnel-url\` to provide a URL manually.
923
+ Last error: ${lastError?.message ?? "unknown"}`
924
+ );
925
+ }
864
926
 
865
927
  // src/version/check.ts
866
928
  var import_promises5 = require("fs/promises");
package/dist/index.d.cts CHANGED
@@ -2,7 +2,7 @@ export { clearSession, getSession, getSessionDir, isSessionValid, pollForToken,
2
2
  export { D as DeviceCodeResponse, b as POLL_STATUS, P as PollResult, a as PollStatus, T as TokenResponse, W as WhataloSession } from './types-DunvRQ0f.cjs';
3
3
  export { Spinner, TaskStatus, WhataloAuthError, WhataloConfigError, WhataloNetworkError, WhataloValidationError, banner, code, createSpinner, error, failMissingNonTTYFlags, handleCliError, info, link, renderInfoPanel, renderTable, renderTasks, success, table, warn, withErrorHandler } from './output/index.cjs';
4
4
  export { HttpClientOptions, WhataloApiClient } from './http/index.cjs';
5
- export { C as CONFIG_FILE_NAME, E as EnvEntry, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from './env-file-KvUHlLtI.cjs';
5
+ export { C as CONFIG_FILE_NAME, E as EnvEntry, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from './env-file-Qh_6c5K2.cjs';
6
6
  export { TunnelDownloadProgress, TunnelOptions, TunnelProcess, createTunnel, ensureCloudflared } from './tunnel/index.cjs';
7
7
  export { VersionCheckResult, checkSdkCompatibility, getUpgradeCommand, isNewerVersion, scheduleVersionCheck } from './version/index.cjs';
8
8
  import 'node:child_process';
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { clearSession, getSession, getSessionDir, isSessionValid, pollForToken,
2
2
  export { D as DeviceCodeResponse, b as POLL_STATUS, P as PollResult, a as PollStatus, T as TokenResponse, W as WhataloSession } from './types-DunvRQ0f.js';
3
3
  export { Spinner, TaskStatus, WhataloAuthError, WhataloConfigError, WhataloNetworkError, WhataloValidationError, banner, code, createSpinner, error, failMissingNonTTYFlags, handleCliError, info, link, renderInfoPanel, renderTable, renderTasks, success, table, warn, withErrorHandler } from './output/index.js';
4
4
  export { HttpClientOptions, WhataloApiClient } from './http/index.js';
5
- export { C as CONFIG_FILE_NAME, E as EnvEntry, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from './env-file-KvUHlLtI.js';
5
+ export { C as CONFIG_FILE_NAME, E as EnvEntry, W as WhataloAppConfig, p as parseEnvFile, r as readConfig, u as updateEnvFile, w as writeConfig } from './env-file-Qh_6c5K2.js';
6
6
  export { TunnelDownloadProgress, TunnelOptions, TunnelProcess, createTunnel, ensureCloudflared } from './tunnel/index.js';
7
7
  export { VersionCheckResult, checkSdkCompatibility, getUpgradeCommand, isNewerVersion, scheduleVersionCheck } from './version/index.js';
8
8
  import 'node:child_process';
package/dist/index.mjs CHANGED
@@ -598,7 +598,9 @@ import { unlink, rename } from "fs/promises";
598
598
  import path3 from "path";
599
599
  import os2 from "os";
600
600
  import https from "https";
601
- var TUNNEL_START_TIMEOUT_MS = 15e3;
601
+ var TUNNEL_START_TIMEOUT_MS = 4e4;
602
+ var MAX_TUNNEL_RETRIES = 5;
603
+ var TUNNEL_RETRY_DELAY_MS = 1e3;
602
604
  var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
603
605
  function getBinDir() {
604
606
  return path3.join(os2.homedir(), ".whatalo", "bin");
@@ -709,9 +711,7 @@ async function downloadCloudflared(targetPath) {
709
711
  info(`cloudflared saved to ${targetPath}`);
710
712
  return targetPath;
711
713
  }
712
- async function createTunnel(options) {
713
- const { localPort, protocol = "http" } = options;
714
- const binaryPath = await ensureCloudflared();
714
+ function spawnTunnelAttempt(binaryPath, localPort, protocol) {
715
715
  return new Promise((resolve, reject) => {
716
716
  const child = spawn(
717
717
  binaryPath,
@@ -749,16 +749,7 @@ async function createTunnel(options) {
749
749
  if (match && !urlFound) {
750
750
  urlFound = true;
751
751
  clearTimeout(timeout);
752
- const tunnelUrl = match[0];
753
- const kill = () => new Promise((res) => {
754
- if (child.exitCode !== null) {
755
- res();
756
- return;
757
- }
758
- child.once("exit", () => res());
759
- child.kill("SIGTERM");
760
- });
761
- resolve({ url: tunnelUrl, process: child, kill });
752
+ resolve([match[0], child]);
762
753
  }
763
754
  }
764
755
  });
@@ -785,6 +776,77 @@ async function createTunnel(options) {
785
776
  });
786
777
  });
787
778
  }
779
+ function attachHealthMonitor(child, isStopped, reconnect, onReconnect) {
780
+ child.on("close", (code2, signal) => {
781
+ if (isStopped()) return;
782
+ warn(
783
+ `Tunnel process exited unexpectedly (code=${code2 ?? "null"}, signal=${signal ?? "none"}). Reconnecting...`
784
+ );
785
+ reconnect().then(([newUrl, newChild]) => {
786
+ info(`Tunnel reconnected (webhooks): ${newUrl}`);
787
+ attachHealthMonitor(newChild, isStopped, reconnect, onReconnect);
788
+ if (onReconnect) {
789
+ void Promise.resolve(onReconnect(newUrl));
790
+ }
791
+ }).catch((err) => {
792
+ const message = err instanceof Error ? err.message : "unknown error";
793
+ warn(`Tunnel reconnect failed: ${message}`);
794
+ });
795
+ });
796
+ }
797
+ async function createTunnel(options) {
798
+ const {
799
+ localPort,
800
+ protocol = "http",
801
+ maxRetries = MAX_TUNNEL_RETRIES,
802
+ onReconnect
803
+ } = options;
804
+ const binaryPath = await ensureCloudflared();
805
+ const spawnNew = () => spawnTunnelAttempt(binaryPath, localPort, protocol);
806
+ let lastError;
807
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
808
+ try {
809
+ if (attempt > 1) {
810
+ info(`Tunnel attempt ${attempt}/${maxRetries}...`);
811
+ }
812
+ const [tunnelUrl, child] = await spawnNew();
813
+ let stopped = false;
814
+ let currentChild = child;
815
+ attachHealthMonitor(
816
+ child,
817
+ () => stopped,
818
+ async () => {
819
+ const result = await spawnNew();
820
+ currentChild = result[1];
821
+ return result;
822
+ },
823
+ onReconnect
824
+ );
825
+ const kill = () => new Promise((res) => {
826
+ stopped = true;
827
+ if (currentChild.exitCode !== null) {
828
+ res();
829
+ return;
830
+ }
831
+ currentChild.once("exit", () => res());
832
+ currentChild.kill("SIGTERM");
833
+ });
834
+ return { url: tunnelUrl, process: child, kill };
835
+ } catch (err) {
836
+ lastError = err instanceof Error ? err : new Error(String(err));
837
+ if (attempt < maxRetries) {
838
+ warn(
839
+ `Tunnel startup failed (attempt ${attempt}/${maxRetries}): ${lastError.message}`
840
+ );
841
+ await new Promise((r) => setTimeout(r, TUNNEL_RETRY_DELAY_MS));
842
+ }
843
+ }
844
+ }
845
+ throw new Error(
846
+ `Tunnel failed after ${maxRetries} attempts. Check your network or use \`--tunnel-url\` to provide a URL manually.
847
+ Last error: ${lastError?.message ?? "unknown"}`
848
+ );
849
+ }
788
850
 
789
851
  // src/version/check.ts
790
852
  import { readFile as readFile3, writeFile as writeFile3, mkdir } from "fs/promises";
@@ -45,6 +45,9 @@ var import_node_https = __toESM(require("https"), 1);
45
45
 
46
46
  // src/output/format.ts
47
47
  var import_chalk = __toESM(require("chalk"), 1);
48
+ function warn(message) {
49
+ console.log(` ${import_chalk.default.yellow("\u26A0")} ${message}`);
50
+ }
48
51
  function info(message) {
49
52
  console.log(` ${import_chalk.default.blue("\u2139")} ${message}`);
50
53
  }
@@ -58,7 +61,9 @@ var STATUS_ICONS = {
58
61
  };
59
62
 
60
63
  // src/tunnel/cloudflared.ts
61
- var TUNNEL_START_TIMEOUT_MS = 15e3;
64
+ var TUNNEL_START_TIMEOUT_MS = 4e4;
65
+ var MAX_TUNNEL_RETRIES = 5;
66
+ var TUNNEL_RETRY_DELAY_MS = 1e3;
62
67
  var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
63
68
  function getBinDir() {
64
69
  return import_node_path.default.join(import_node_os.default.homedir(), ".whatalo", "bin");
@@ -169,9 +174,7 @@ async function downloadCloudflared(targetPath) {
169
174
  info(`cloudflared saved to ${targetPath}`);
170
175
  return targetPath;
171
176
  }
172
- async function createTunnel(options) {
173
- const { localPort, protocol = "http" } = options;
174
- const binaryPath = await ensureCloudflared();
177
+ function spawnTunnelAttempt(binaryPath, localPort, protocol) {
175
178
  return new Promise((resolve, reject) => {
176
179
  const child = (0, import_node_child_process.spawn)(
177
180
  binaryPath,
@@ -209,16 +212,7 @@ async function createTunnel(options) {
209
212
  if (match && !urlFound) {
210
213
  urlFound = true;
211
214
  clearTimeout(timeout);
212
- const tunnelUrl = match[0];
213
- const kill = () => new Promise((res) => {
214
- if (child.exitCode !== null) {
215
- res();
216
- return;
217
- }
218
- child.once("exit", () => res());
219
- child.kill("SIGTERM");
220
- });
221
- resolve({ url: tunnelUrl, process: child, kill });
215
+ resolve([match[0], child]);
222
216
  }
223
217
  }
224
218
  });
@@ -245,6 +239,77 @@ async function createTunnel(options) {
245
239
  });
246
240
  });
247
241
  }
242
+ function attachHealthMonitor(child, isStopped, reconnect, onReconnect) {
243
+ child.on("close", (code, signal) => {
244
+ if (isStopped()) return;
245
+ warn(
246
+ `Tunnel process exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "none"}). Reconnecting...`
247
+ );
248
+ reconnect().then(([newUrl, newChild]) => {
249
+ info(`Tunnel reconnected (webhooks): ${newUrl}`);
250
+ attachHealthMonitor(newChild, isStopped, reconnect, onReconnect);
251
+ if (onReconnect) {
252
+ void Promise.resolve(onReconnect(newUrl));
253
+ }
254
+ }).catch((err) => {
255
+ const message = err instanceof Error ? err.message : "unknown error";
256
+ warn(`Tunnel reconnect failed: ${message}`);
257
+ });
258
+ });
259
+ }
260
+ async function createTunnel(options) {
261
+ const {
262
+ localPort,
263
+ protocol = "http",
264
+ maxRetries = MAX_TUNNEL_RETRIES,
265
+ onReconnect
266
+ } = options;
267
+ const binaryPath = await ensureCloudflared();
268
+ const spawnNew = () => spawnTunnelAttempt(binaryPath, localPort, protocol);
269
+ let lastError;
270
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
271
+ try {
272
+ if (attempt > 1) {
273
+ info(`Tunnel attempt ${attempt}/${maxRetries}...`);
274
+ }
275
+ const [tunnelUrl, child] = await spawnNew();
276
+ let stopped = false;
277
+ let currentChild = child;
278
+ attachHealthMonitor(
279
+ child,
280
+ () => stopped,
281
+ async () => {
282
+ const result = await spawnNew();
283
+ currentChild = result[1];
284
+ return result;
285
+ },
286
+ onReconnect
287
+ );
288
+ const kill = () => new Promise((res) => {
289
+ stopped = true;
290
+ if (currentChild.exitCode !== null) {
291
+ res();
292
+ return;
293
+ }
294
+ currentChild.once("exit", () => res());
295
+ currentChild.kill("SIGTERM");
296
+ });
297
+ return { url: tunnelUrl, process: child, kill };
298
+ } catch (err) {
299
+ lastError = err instanceof Error ? err : new Error(String(err));
300
+ if (attempt < maxRetries) {
301
+ warn(
302
+ `Tunnel startup failed (attempt ${attempt}/${maxRetries}): ${lastError.message}`
303
+ );
304
+ await new Promise((r) => setTimeout(r, TUNNEL_RETRY_DELAY_MS));
305
+ }
306
+ }
307
+ }
308
+ throw new Error(
309
+ `Tunnel failed after ${maxRetries} attempts. Check your network or use \`--tunnel-url\` to provide a URL manually.
310
+ Last error: ${lastError?.message ?? "unknown"}`
311
+ );
312
+ }
248
313
  // Annotate the CommonJS export names for ESM import in node:
249
314
  0 && (module.exports = {
250
315
  createTunnel,
@@ -6,6 +6,17 @@ interface TunnelOptions {
6
6
  localPort: number;
7
7
  /** Protocol to use for the tunnel connection (default: "http") */
8
8
  protocol?: "http" | "https";
9
+ /**
10
+ * Maximum number of times to retry tunnel creation on failure.
11
+ * Defaults to MAX_TUNNEL_RETRIES (5).
12
+ */
13
+ maxRetries?: number;
14
+ /**
15
+ * Called whenever the tunnel reconnects after an unexpected process exit.
16
+ * Receives the new public URL so the caller can update the dev session.
17
+ * Optional — callers that do not need URL update notifications can omit this.
18
+ */
19
+ onReconnect?: (newUrl: string) => void | Promise<void>;
9
20
  }
10
21
  /**
11
22
  * Represents a running tunnel process.
@@ -37,7 +48,7 @@ interface TunnelDownloadProgress {
37
48
  * Binary resolution order:
38
49
  * 1. System PATH (user's global install, resolved via `which`/`where`)
39
50
  * 2. ~/.whatalo/bin/cloudflared (previously downloaded by the CLI)
40
- * 3. Download from GitHub releases ~/.whatalo/bin/cloudflared
51
+ * 3. Download from GitHub releases -> ~/.whatalo/bin/cloudflared
41
52
  *
42
53
  * Note: process-spawning helpers use spawnSync / spawn (not exec/execSync)
43
54
  * to avoid shell interpolation entirely — all arguments are passed as an
@@ -56,14 +67,24 @@ interface TunnelDownloadProgress {
56
67
  */
57
68
  declare function ensureCloudflared(): Promise<string>;
58
69
  /**
59
- * Spawns a cloudflared quick tunnel targeting the given local port.
70
+ * Creates a cloudflared quick tunnel targeting the given local port.
60
71
  *
61
- * Streams stderr line-by-line to detect the assigned public URL.
62
- * Resolves once the URL is found or rejects after TUNNEL_START_TIMEOUT_MS.
72
+ * Enhancements over a bare spawn:
73
+ *
74
+ * - 40-second startup timeout (previously 15 s) to handle slow initialization
75
+ * - Up to MAX_TUNNEL_RETRIES (5) automatic retry attempts with a 1-second
76
+ * delay between each before giving up
77
+ * - Health monitoring via attachHealthMonitor: detects unexpected process exit
78
+ * and auto-reconnects; calls options.onReconnect with the new URL so the
79
+ * caller can patch server-side state (e.g., dev session tunnel URL)
80
+ *
81
+ * The tunnel is primarily used for webhook and API callbacks. Plugin iframes
82
+ * are served directly from the developer's local server via the CDN dev bundle,
83
+ * so tunnel availability affects event delivery, not page rendering.
63
84
  *
64
85
  * @param options - localPort is required; protocol defaults to "http"
65
- * @returns A TunnelProcess containing the public URL and a kill() helper
66
- * @throws If the process exits before printing a URL, or if startup times out
86
+ * @returns A TunnelProcess with the current public URL and a kill() helper
87
+ * @throws After exhausting all retries with a descriptive aggregated error
67
88
  */
68
89
  declare function createTunnel(options: TunnelOptions): Promise<TunnelProcess>;
69
90
 
@@ -6,6 +6,17 @@ interface TunnelOptions {
6
6
  localPort: number;
7
7
  /** Protocol to use for the tunnel connection (default: "http") */
8
8
  protocol?: "http" | "https";
9
+ /**
10
+ * Maximum number of times to retry tunnel creation on failure.
11
+ * Defaults to MAX_TUNNEL_RETRIES (5).
12
+ */
13
+ maxRetries?: number;
14
+ /**
15
+ * Called whenever the tunnel reconnects after an unexpected process exit.
16
+ * Receives the new public URL so the caller can update the dev session.
17
+ * Optional — callers that do not need URL update notifications can omit this.
18
+ */
19
+ onReconnect?: (newUrl: string) => void | Promise<void>;
9
20
  }
10
21
  /**
11
22
  * Represents a running tunnel process.
@@ -37,7 +48,7 @@ interface TunnelDownloadProgress {
37
48
  * Binary resolution order:
38
49
  * 1. System PATH (user's global install, resolved via `which`/`where`)
39
50
  * 2. ~/.whatalo/bin/cloudflared (previously downloaded by the CLI)
40
- * 3. Download from GitHub releases ~/.whatalo/bin/cloudflared
51
+ * 3. Download from GitHub releases -> ~/.whatalo/bin/cloudflared
41
52
  *
42
53
  * Note: process-spawning helpers use spawnSync / spawn (not exec/execSync)
43
54
  * to avoid shell interpolation entirely — all arguments are passed as an
@@ -56,14 +67,24 @@ interface TunnelDownloadProgress {
56
67
  */
57
68
  declare function ensureCloudflared(): Promise<string>;
58
69
  /**
59
- * Spawns a cloudflared quick tunnel targeting the given local port.
70
+ * Creates a cloudflared quick tunnel targeting the given local port.
60
71
  *
61
- * Streams stderr line-by-line to detect the assigned public URL.
62
- * Resolves once the URL is found or rejects after TUNNEL_START_TIMEOUT_MS.
72
+ * Enhancements over a bare spawn:
73
+ *
74
+ * - 40-second startup timeout (previously 15 s) to handle slow initialization
75
+ * - Up to MAX_TUNNEL_RETRIES (5) automatic retry attempts with a 1-second
76
+ * delay between each before giving up
77
+ * - Health monitoring via attachHealthMonitor: detects unexpected process exit
78
+ * and auto-reconnects; calls options.onReconnect with the new URL so the
79
+ * caller can patch server-side state (e.g., dev session tunnel URL)
80
+ *
81
+ * The tunnel is primarily used for webhook and API callbacks. Plugin iframes
82
+ * are served directly from the developer's local server via the CDN dev bundle,
83
+ * so tunnel availability affects event delivery, not page rendering.
63
84
  *
64
85
  * @param options - localPort is required; protocol defaults to "http"
65
- * @returns A TunnelProcess containing the public URL and a kill() helper
66
- * @throws If the process exits before printing a URL, or if startup times out
86
+ * @returns A TunnelProcess with the current public URL and a kill() helper
87
+ * @throws After exhausting all retries with a descriptive aggregated error
67
88
  */
68
89
  declare function createTunnel(options: TunnelOptions): Promise<TunnelProcess>;
69
90
 
@@ -8,6 +8,9 @@ import https from "https";
8
8
 
9
9
  // src/output/format.ts
10
10
  import chalk from "chalk";
11
+ function warn(message) {
12
+ console.log(` ${chalk.yellow("\u26A0")} ${message}`);
13
+ }
11
14
  function info(message) {
12
15
  console.log(` ${chalk.blue("\u2139")} ${message}`);
13
16
  }
@@ -21,7 +24,9 @@ var STATUS_ICONS = {
21
24
  };
22
25
 
23
26
  // src/tunnel/cloudflared.ts
24
- var TUNNEL_START_TIMEOUT_MS = 15e3;
27
+ var TUNNEL_START_TIMEOUT_MS = 4e4;
28
+ var MAX_TUNNEL_RETRIES = 5;
29
+ var TUNNEL_RETRY_DELAY_MS = 1e3;
25
30
  var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
26
31
  function getBinDir() {
27
32
  return path.join(os.homedir(), ".whatalo", "bin");
@@ -132,9 +137,7 @@ async function downloadCloudflared(targetPath) {
132
137
  info(`cloudflared saved to ${targetPath}`);
133
138
  return targetPath;
134
139
  }
135
- async function createTunnel(options) {
136
- const { localPort, protocol = "http" } = options;
137
- const binaryPath = await ensureCloudflared();
140
+ function spawnTunnelAttempt(binaryPath, localPort, protocol) {
138
141
  return new Promise((resolve, reject) => {
139
142
  const child = spawn(
140
143
  binaryPath,
@@ -172,16 +175,7 @@ async function createTunnel(options) {
172
175
  if (match && !urlFound) {
173
176
  urlFound = true;
174
177
  clearTimeout(timeout);
175
- const tunnelUrl = match[0];
176
- const kill = () => new Promise((res) => {
177
- if (child.exitCode !== null) {
178
- res();
179
- return;
180
- }
181
- child.once("exit", () => res());
182
- child.kill("SIGTERM");
183
- });
184
- resolve({ url: tunnelUrl, process: child, kill });
178
+ resolve([match[0], child]);
185
179
  }
186
180
  }
187
181
  });
@@ -208,6 +202,77 @@ async function createTunnel(options) {
208
202
  });
209
203
  });
210
204
  }
205
+ function attachHealthMonitor(child, isStopped, reconnect, onReconnect) {
206
+ child.on("close", (code, signal) => {
207
+ if (isStopped()) return;
208
+ warn(
209
+ `Tunnel process exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "none"}). Reconnecting...`
210
+ );
211
+ reconnect().then(([newUrl, newChild]) => {
212
+ info(`Tunnel reconnected (webhooks): ${newUrl}`);
213
+ attachHealthMonitor(newChild, isStopped, reconnect, onReconnect);
214
+ if (onReconnect) {
215
+ void Promise.resolve(onReconnect(newUrl));
216
+ }
217
+ }).catch((err) => {
218
+ const message = err instanceof Error ? err.message : "unknown error";
219
+ warn(`Tunnel reconnect failed: ${message}`);
220
+ });
221
+ });
222
+ }
223
+ async function createTunnel(options) {
224
+ const {
225
+ localPort,
226
+ protocol = "http",
227
+ maxRetries = MAX_TUNNEL_RETRIES,
228
+ onReconnect
229
+ } = options;
230
+ const binaryPath = await ensureCloudflared();
231
+ const spawnNew = () => spawnTunnelAttempt(binaryPath, localPort, protocol);
232
+ let lastError;
233
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
234
+ try {
235
+ if (attempt > 1) {
236
+ info(`Tunnel attempt ${attempt}/${maxRetries}...`);
237
+ }
238
+ const [tunnelUrl, child] = await spawnNew();
239
+ let stopped = false;
240
+ let currentChild = child;
241
+ attachHealthMonitor(
242
+ child,
243
+ () => stopped,
244
+ async () => {
245
+ const result = await spawnNew();
246
+ currentChild = result[1];
247
+ return result;
248
+ },
249
+ onReconnect
250
+ );
251
+ const kill = () => new Promise((res) => {
252
+ stopped = true;
253
+ if (currentChild.exitCode !== null) {
254
+ res();
255
+ return;
256
+ }
257
+ currentChild.once("exit", () => res());
258
+ currentChild.kill("SIGTERM");
259
+ });
260
+ return { url: tunnelUrl, process: child, kill };
261
+ } catch (err) {
262
+ lastError = err instanceof Error ? err : new Error(String(err));
263
+ if (attempt < maxRetries) {
264
+ warn(
265
+ `Tunnel startup failed (attempt ${attempt}/${maxRetries}): ${lastError.message}`
266
+ );
267
+ await new Promise((r) => setTimeout(r, TUNNEL_RETRY_DELAY_MS));
268
+ }
269
+ }
270
+ }
271
+ throw new Error(
272
+ `Tunnel failed after ${maxRetries} attempts. Check your network or use \`--tunnel-url\` to provide a URL manually.
273
+ Last error: ${lastError?.message ?? "unknown"}`
274
+ );
275
+ }
211
276
  export {
212
277
  createTunnel,
213
278
  ensureCloudflared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@whatalo/cli-kit",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Shared CLI utilities for Whatalo plugin development tools",
@@ -64,6 +64,11 @@
64
64
  "types": "./dist/version/index.d.ts",
65
65
  "import": "./dist/version/index.mjs",
66
66
  "require": "./dist/version/index.cjs"
67
+ },
68
+ "./bundle": {
69
+ "types": "./dist/bundle/index.d.ts",
70
+ "import": "./dist/bundle/index.mjs",
71
+ "require": "./dist/bundle/index.cjs"
67
72
  }
68
73
  },
69
74
  "files": [