@juspay/shooter 1.10.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/.claude/hooks/notifier.cjs +24 -242
  2. package/bin/lib/tunnel-discovery.cjs +519 -0
  3. package/bin/shooter.cjs +204 -49
  4. package/build/client/_app/immutable/chunks/{CmtInjm0.js → DTGtOxE1.js} +1 -1
  5. package/build/client/_app/immutable/chunks/DTGtOxE1.js.br +0 -0
  6. package/build/client/_app/immutable/chunks/DTGtOxE1.js.gz +0 -0
  7. package/build/client/_app/immutable/chunks/{DfKeHoAm.js → DfsJh23H.js} +1 -1
  8. package/build/client/_app/immutable/chunks/DfsJh23H.js.br +0 -0
  9. package/build/client/_app/immutable/chunks/DfsJh23H.js.gz +0 -0
  10. package/build/client/_app/immutable/chunks/DlSs5Yra.js +3 -0
  11. package/build/client/_app/immutable/chunks/DlSs5Yra.js.br +0 -0
  12. package/build/client/_app/immutable/chunks/DlSs5Yra.js.gz +0 -0
  13. package/build/client/_app/immutable/entry/{app.Dp9YhfEg.js → app.B-sEFuLK.js} +2 -2
  14. package/build/client/_app/immutable/entry/app.B-sEFuLK.js.br +0 -0
  15. package/build/client/_app/immutable/entry/app.B-sEFuLK.js.gz +0 -0
  16. package/build/client/_app/immutable/entry/start.A2buqyYO.js +1 -0
  17. package/build/client/_app/immutable/entry/start.A2buqyYO.js.br +2 -0
  18. package/build/client/_app/immutable/entry/start.A2buqyYO.js.gz +0 -0
  19. package/build/client/_app/immutable/nodes/{0.B-M6sgow.js → 0.-0SstbRm.js} +1 -1
  20. package/build/client/_app/immutable/nodes/0.-0SstbRm.js.br +0 -0
  21. package/build/client/_app/immutable/nodes/0.-0SstbRm.js.gz +0 -0
  22. package/build/client/_app/immutable/nodes/{1.C8aY7Yn3.js → 1.BVLzPogE.js} +1 -1
  23. package/build/client/_app/immutable/nodes/1.BVLzPogE.js.br +0 -0
  24. package/build/client/_app/immutable/nodes/1.BVLzPogE.js.gz +0 -0
  25. package/build/client/_app/immutable/nodes/{2.YJZruh1H.js → 2.CiUyTQg5.js} +1 -1
  26. package/build/client/_app/immutable/nodes/2.CiUyTQg5.js.br +0 -0
  27. package/build/client/_app/immutable/nodes/2.CiUyTQg5.js.gz +0 -0
  28. package/build/client/_app/immutable/nodes/{3.VV-tRemY.js → 3.C9vlOBU0.js} +1 -1
  29. package/build/client/_app/immutable/nodes/3.C9vlOBU0.js.br +0 -0
  30. package/build/client/_app/immutable/nodes/3.C9vlOBU0.js.gz +0 -0
  31. package/build/client/_app/immutable/nodes/{6.CDJA8Na9.js → 6.BSsUBbIT.js} +1 -1
  32. package/build/client/_app/immutable/nodes/6.BSsUBbIT.js.br +0 -0
  33. package/build/client/_app/immutable/nodes/6.BSsUBbIT.js.gz +0 -0
  34. package/build/client/_app/immutable/nodes/{7.BX9znBYU.js → 7.BIQq9Yuz.js} +1 -1
  35. package/build/client/_app/immutable/nodes/7.BIQq9Yuz.js.br +0 -0
  36. package/build/client/_app/immutable/nodes/7.BIQq9Yuz.js.gz +0 -0
  37. package/build/client/_app/immutable/nodes/{8.CmdrNdfj.js → 8.BU_sJ5_M.js} +1 -1
  38. package/build/client/_app/immutable/nodes/8.BU_sJ5_M.js.br +0 -0
  39. package/build/client/_app/immutable/nodes/8.BU_sJ5_M.js.gz +0 -0
  40. package/build/client/_app/immutable/nodes/{9.BSleOtKF.js → 9.C1vJI771.js} +1 -1
  41. package/build/client/_app/immutable/nodes/9.C1vJI771.js.br +0 -0
  42. package/build/client/_app/immutable/nodes/9.C1vJI771.js.gz +0 -0
  43. package/build/client/_app/version.json +1 -1
  44. package/build/client/_app/version.json.br +0 -0
  45. package/build/client/_app/version.json.gz +0 -0
  46. package/build/server/chunks/{0-vSdphvn2.js → 0-DgzcVTc0.js} +3 -3
  47. package/build/server/chunks/{0-vSdphvn2.js.map → 0-DgzcVTc0.js.map} +1 -1
  48. package/build/server/chunks/{1-B5tn1ob0.js → 1-iMvE8O_M.js} +3 -3
  49. package/build/server/chunks/{1-B5tn1ob0.js.map → 1-iMvE8O_M.js.map} +1 -1
  50. package/build/server/chunks/{2-CUbzGnZ8.js → 2-BJrmwHii.js} +3 -3
  51. package/build/server/chunks/{2-CUbzGnZ8.js.map → 2-BJrmwHii.js.map} +1 -1
  52. package/build/server/chunks/{3-BR90tKg7.js → 3-Ds3b4DfT.js} +3 -3
  53. package/build/server/chunks/{3-BR90tKg7.js.map → 3-Ds3b4DfT.js.map} +1 -1
  54. package/build/server/chunks/{4-CbejE1SC.js → 4-BtYdKCVW.js} +2 -2
  55. package/build/server/chunks/{4-CbejE1SC.js.map → 4-BtYdKCVW.js.map} +1 -1
  56. package/build/server/chunks/{5-BwqumuR1.js → 5-CvJK3PiH.js} +2 -2
  57. package/build/server/chunks/{5-BwqumuR1.js.map → 5-CvJK3PiH.js.map} +1 -1
  58. package/build/server/chunks/{6-cW7umWCt.js → 6-DEbZkQEO.js} +3 -3
  59. package/build/server/chunks/{6-cW7umWCt.js.map → 6-DEbZkQEO.js.map} +1 -1
  60. package/build/server/chunks/{7-D3cb9T2g.js → 7-BrQeR-CO.js} +3 -3
  61. package/build/server/chunks/{7-D3cb9T2g.js.map → 7-BrQeR-CO.js.map} +1 -1
  62. package/build/server/chunks/{8-BEgZo4wA.js → 8-e5TDwEpx.js} +3 -3
  63. package/build/server/chunks/{8-BEgZo4wA.js.map → 8-e5TDwEpx.js.map} +1 -1
  64. package/build/server/chunks/{9-Dy8aTTtf.js → 9-1iqRqatJ.js} +3 -3
  65. package/build/server/chunks/{9-Dy8aTTtf.js.map → 9-1iqRqatJ.js.map} +1 -1
  66. package/build/server/chunks/{Button-Dpueno77.js → Button-B5dU-ntz.js} +2 -2
  67. package/build/server/chunks/{Button-Dpueno77.js.map → Button-B5dU-ntz.js.map} +1 -1
  68. package/build/server/chunks/{Icon-aOLx5ELI.js → Icon-C7Ml3GX6.js} +3 -3
  69. package/build/server/chunks/{Icon-aOLx5ELI.js.map → Icon-C7Ml3GX6.js.map} +1 -1
  70. package/build/server/chunks/{Input-Cq7ZdLxS.js → Input-CPGO0sbS.js} +2 -2
  71. package/build/server/chunks/{Input-Cq7ZdLxS.js.map → Input-CPGO0sbS.js.map} +1 -1
  72. package/build/server/chunks/{Pill-Bn597jm0.js → Pill-CcrtCejm.js} +3 -3
  73. package/build/server/chunks/{Pill-Bn597jm0.js.map → Pill-CcrtCejm.js.map} +1 -1
  74. package/build/server/chunks/{Shimmer-5o7MVwXF.js → Shimmer-C5jkvGr1.js} +2 -2
  75. package/build/server/chunks/{Shimmer-5o7MVwXF.js.map → Shimmer-C5jkvGr1.js.map} +1 -1
  76. package/build/server/chunks/{_error.svelte-C85mEsv9.js → _error.svelte-CSIxs-ab.js} +8 -8
  77. package/build/server/chunks/{_error.svelte-C85mEsv9.js.map → _error.svelte-CSIxs-ab.js.map} +1 -1
  78. package/build/server/chunks/{_layout.svelte-rpYLLajc.js → _layout.svelte-noB4j-v2.js} +10 -10
  79. package/build/server/chunks/{_layout.svelte-rpYLLajc.js.map → _layout.svelte-noB4j-v2.js.map} +1 -1
  80. package/build/server/chunks/{_page.svelte-BfCXobKv.js → _page.svelte-B6qyh-K-.js} +11 -11
  81. package/build/server/chunks/{_page.svelte-BfCXobKv.js.map → _page.svelte-B6qyh-K-.js.map} +1 -1
  82. package/build/server/chunks/{_page.svelte-CA46TyHk.js → _page.svelte-BUkm2304.js} +5 -5
  83. package/build/server/chunks/{_page.svelte-CA46TyHk.js.map → _page.svelte-BUkm2304.js.map} +1 -1
  84. package/build/server/chunks/{_page.svelte-B6O0uTrK.js → _page.svelte-BV0XyYJZ.js} +4 -4
  85. package/build/server/chunks/{_page.svelte-B6O0uTrK.js.map → _page.svelte-BV0XyYJZ.js.map} +1 -1
  86. package/build/server/chunks/{_page.svelte-CCpVmMpU.js → _page.svelte-BfB8maoc.js} +9 -9
  87. package/build/server/chunks/{_page.svelte-CCpVmMpU.js.map → _page.svelte-BfB8maoc.js.map} +1 -1
  88. package/build/server/chunks/{_page.svelte-DjADcbfZ.js → _page.svelte-C60lAagP.js} +8 -8
  89. package/build/server/chunks/{_page.svelte-DjADcbfZ.js.map → _page.svelte-C60lAagP.js.map} +1 -1
  90. package/build/server/chunks/{_page.svelte-DUj1mSq0.js → _page.svelte-Dmg-RFCg.js} +7 -7
  91. package/build/server/chunks/{_page.svelte-DUj1mSq0.js.map → _page.svelte-Dmg-RFCg.js.map} +1 -1
  92. package/build/server/chunks/{_page.svelte-fkR4xqGu.js → _page.svelte-DnTpPnPR.js} +5 -5
  93. package/build/server/chunks/{_page.svelte-fkR4xqGu.js.map → _page.svelte-DnTpPnPR.js.map} +1 -1
  94. package/build/server/chunks/{_page.svelte-D0FBtMtH.js → _page.svelte-DuzZr5dA.js} +11 -11
  95. package/build/server/chunks/{_page.svelte-D0FBtMtH.js.map → _page.svelte-DuzZr5dA.js.map} +1 -1
  96. package/build/server/chunks/{_server.ts-HGYjOWF2.js → _server.ts-C-W5J15L.js} +2 -2
  97. package/build/server/chunks/{_server.ts-HGYjOWF2.js.map → _server.ts-C-W5J15L.js.map} +1 -1
  98. package/build/server/chunks/_server.ts-CvJKTS3Z.js +35 -0
  99. package/build/server/chunks/_server.ts-CvJKTS3Z.js.map +1 -0
  100. package/build/server/chunks/{_server.ts-BBLuxvp6.js → _server.ts-tChyh9FX.js} +43 -8
  101. package/build/server/chunks/_server.ts-tChyh9FX.js.map +1 -0
  102. package/build/server/chunks/{cache-5_eamjtv.js → cache-Me3zUAaD.js} +2 -2
  103. package/build/server/chunks/{cache-5_eamjtv.js.map → cache-Me3zUAaD.js.map} +1 -1
  104. package/build/server/chunks/{client-w1WsLDGu.js → client-CfNnl32g.js} +4 -4
  105. package/build/server/chunks/{client-w1WsLDGu.js.map → client-CfNnl32g.js.map} +1 -1
  106. package/build/server/chunks/client2-DDP30_vY.js +7 -0
  107. package/build/server/chunks/{client2-B5wCRDQi.js.map → client2-DDP30_vY.js.map} +1 -1
  108. package/build/server/chunks/{index-DMikC9Qy.js → index-CJrGuxuM.js} +2 -2
  109. package/build/server/chunks/{index-DMikC9Qy.js.map → index-CJrGuxuM.js.map} +1 -1
  110. package/build/server/chunks/{index-server-DHNcb_Bd.js → index-server--49oHtA0.js} +2 -2
  111. package/build/server/chunks/{index-server-DHNcb_Bd.js.map → index-server--49oHtA0.js.map} +1 -1
  112. package/build/server/chunks/{index2-BTTf6mSG.js → index2-MY7PXeAc.js} +2 -2
  113. package/build/server/chunks/{index2-BTTf6mSG.js.map → index2-MY7PXeAc.js.map} +1 -1
  114. package/build/server/chunks/pending-requests-C9p57WoU.js +174 -0
  115. package/build/server/chunks/pending-requests-C9p57WoU.js.map +1 -0
  116. package/build/server/chunks/{root-DhBbA8QD.js → root-xvQIR1Bt.js} +2 -2
  117. package/build/server/chunks/{root-DhBbA8QD.js.map → root-xvQIR1Bt.js.map} +1 -1
  118. package/build/server/chunks/{state.svelte-M8y8rROy.js → state.svelte-RCtlkrNH.js} +3 -3
  119. package/build/server/chunks/{state.svelte-M8y8rROy.js.map → state.svelte-RCtlkrNH.js.map} +1 -1
  120. package/build/server/chunks/{stores-CwkRmCHA.js → stores-C-LqoonT.js} +4 -4
  121. package/build/server/chunks/{stores-CwkRmCHA.js.map → stores-C-LqoonT.js.map} +1 -1
  122. package/build/server/index.js +4 -4
  123. package/build/server/index.js.map +1 -1
  124. package/build/server/manifest.js +20 -13
  125. package/build/server/manifest.js.map +1 -1
  126. package/package.json +2 -2
  127. package/src/lib/modules/server/apn/pending-requests.ts +156 -33
  128. package/src/lib/types/decision.ts +114 -0
  129. package/src/lib/types/index.ts +1 -0
  130. package/src/routes/api/decide/[requestId]/+server.ts +46 -0
  131. package/src/routes/api/response/+server.ts +61 -11
  132. package/build/client/_app/immutable/chunks/BFXEYMV8.js +0 -3
  133. package/build/client/_app/immutable/chunks/BFXEYMV8.js.br +0 -0
  134. package/build/client/_app/immutable/chunks/BFXEYMV8.js.gz +0 -0
  135. package/build/client/_app/immutable/chunks/CmtInjm0.js.br +0 -0
  136. package/build/client/_app/immutable/chunks/CmtInjm0.js.gz +0 -0
  137. package/build/client/_app/immutable/chunks/DfKeHoAm.js.br +0 -0
  138. package/build/client/_app/immutable/chunks/DfKeHoAm.js.gz +0 -0
  139. package/build/client/_app/immutable/entry/app.Dp9YhfEg.js.br +0 -0
  140. package/build/client/_app/immutable/entry/app.Dp9YhfEg.js.gz +0 -0
  141. package/build/client/_app/immutable/entry/start.Bc5yZsyK.js +0 -1
  142. package/build/client/_app/immutable/entry/start.Bc5yZsyK.js.br +0 -2
  143. package/build/client/_app/immutable/entry/start.Bc5yZsyK.js.gz +0 -0
  144. package/build/client/_app/immutable/nodes/0.B-M6sgow.js.br +0 -0
  145. package/build/client/_app/immutable/nodes/0.B-M6sgow.js.gz +0 -0
  146. package/build/client/_app/immutable/nodes/1.C8aY7Yn3.js.br +0 -0
  147. package/build/client/_app/immutable/nodes/1.C8aY7Yn3.js.gz +0 -0
  148. package/build/client/_app/immutable/nodes/2.YJZruh1H.js.br +0 -0
  149. package/build/client/_app/immutable/nodes/2.YJZruh1H.js.gz +0 -0
  150. package/build/client/_app/immutable/nodes/3.VV-tRemY.js.br +0 -0
  151. package/build/client/_app/immutable/nodes/3.VV-tRemY.js.gz +0 -0
  152. package/build/client/_app/immutable/nodes/6.CDJA8Na9.js.br +0 -0
  153. package/build/client/_app/immutable/nodes/6.CDJA8Na9.js.gz +0 -0
  154. package/build/client/_app/immutable/nodes/7.BX9znBYU.js.br +0 -0
  155. package/build/client/_app/immutable/nodes/7.BX9znBYU.js.gz +0 -0
  156. package/build/client/_app/immutable/nodes/8.CmdrNdfj.js.br +0 -0
  157. package/build/client/_app/immutable/nodes/8.CmdrNdfj.js.gz +0 -0
  158. package/build/client/_app/immutable/nodes/9.BSleOtKF.js.br +0 -0
  159. package/build/client/_app/immutable/nodes/9.BSleOtKF.js.gz +0 -0
  160. package/build/server/chunks/_server.ts-BBLuxvp6.js.map +0 -1
  161. package/build/server/chunks/client2-B5wCRDQi.js +0 -7
  162. package/build/server/chunks/pending-requests-B-JNGxpk.js +0 -96
  163. package/build/server/chunks/pending-requests-B-JNGxpk.js.map +0 -1
@@ -0,0 +1,519 @@
1
+ // Named-tunnel auto-discovery and self-heal for cloudflared.
2
+ //
3
+ // On macOS, scans ~/.cloudflared/*.yml for tunnel configs that proxy a
4
+ // shooter-matching localhost port, then correlates them with LaunchAgents
5
+ // in ~/Library/LaunchAgents/*.plist that run `cloudflared tunnel ... run`
6
+ // against the same config. If a discovered LaunchAgent points to a stale
7
+ // cloudflared binary (e.g. an Intel Homebrew path after migrating to
8
+ // Apple Silicon), the plist is rewritten in-place against `which
9
+ // cloudflared` and reloaded via `launchctl bootout` + `bootstrap`.
10
+ //
11
+ // The Linux variant is intentionally limited to discovery (no auto-heal
12
+ // of systemd units yet); shooter on Linux primarily uses the quick-tunnel
13
+ // flow today.
14
+
15
+ 'use strict';
16
+
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+ const { execFileSync } = require('child_process');
21
+
22
+ const CLOUDFLARED_DIR = path.join(os.homedir(), '.cloudflared');
23
+ const LAUNCH_AGENTS_DIR = path.join(os.homedir(), 'Library', 'LaunchAgents');
24
+ const SYSTEMD_USER_DIR = path.join(os.homedir(), '.config', 'systemd', 'user');
25
+
26
+ // ── YAML parsing (intentionally minimal) ────────────────────────────
27
+ //
28
+ // Cloudflared tunnel configs are flat enough that we don't need a full
29
+ // YAML library. We extract only:
30
+ // - tunnel: <uuid>
31
+ // - credentials-file: <path>
32
+ // - ingress: [{ hostname, service, path? }, ...]
33
+ function parseCloudflaredConfig(yamlText) {
34
+ const lines = yamlText.split(/\r?\n/);
35
+ const out = { tunnel: null, credentialsFile: null, ingress: [] };
36
+ let inIngress = false;
37
+ let current = null;
38
+
39
+ for (const raw of lines) {
40
+ const line = raw.replace(/\s+#.*$/, '').trimEnd();
41
+ if (!line.trim() || line.trim().startsWith('#')) continue;
42
+
43
+ if (!inIngress) {
44
+ const tunnel = line.match(/^tunnel:\s*(\S+)/);
45
+ if (tunnel) {
46
+ out.tunnel = tunnel[1].replace(/^["']|["']$/g, '');
47
+ continue;
48
+ }
49
+ const creds = line.match(/^credentials-file:\s*(\S+)/);
50
+ if (creds) {
51
+ out.credentialsFile = creds[1].replace(/^["']|["']$/g, '');
52
+ continue;
53
+ }
54
+ if (/^ingress:\s*$/.test(line)) {
55
+ inIngress = true;
56
+ continue;
57
+ }
58
+ continue;
59
+ }
60
+
61
+ // We're inside ingress:
62
+ if (/^[A-Za-z]/.test(line)) {
63
+ // A new top-level key terminates the ingress block.
64
+ if (current) out.ingress.push(current);
65
+ current = null;
66
+ inIngress = false;
67
+ continue;
68
+ }
69
+ const itemStart = line.match(/^\s*-\s*(\S+):\s*(.*)$/);
70
+ if (itemStart) {
71
+ if (current) out.ingress.push(current);
72
+ current = {};
73
+ current[itemStart[1]] = itemStart[2].replace(/^["']|["']$/g, '');
74
+ continue;
75
+ }
76
+ const cont = line.match(/^\s+(\S+):\s*(.*)$/);
77
+ if (cont && current) {
78
+ current[cont[1]] = cont[2].replace(/^["']|["']$/g, '');
79
+ }
80
+ }
81
+ if (current) out.ingress.push(current);
82
+ return out;
83
+ }
84
+
85
+ // ── plist parsing (intentionally minimal) ───────────────────────────
86
+ //
87
+ // Apple's plist format supports XML and binary; LaunchAgents written by
88
+ // hand or by `cloudflared service install` are XML. We extract:
89
+ // - Label
90
+ // - ProgramArguments[] (array of string children of the array)
91
+ //
92
+ // Entities (&amp;, &lt;, &gt;, &quot;, &apos;) are decoded so the
93
+ // captured values round-trip with rewritePlistBinaryPath (which encodes
94
+ // the same five) and compare correctly against on-disk paths used by
95
+ // e.g. `programArguments.includes(cfg.path)`.
96
+ function xmlUnescapeText(s) {
97
+ return s
98
+ .replace(/&lt;/g, '<')
99
+ .replace(/&gt;/g, '>')
100
+ .replace(/&quot;/g, '"')
101
+ .replace(/&apos;/g, "'")
102
+ .replace(/&amp;/g, '&'); // last so we don't double-decode
103
+ }
104
+
105
+ function parseLaunchAgentPlist(xml) {
106
+ const out = { label: null, programArguments: [] };
107
+
108
+ const labelMatch = xml.match(/<key>\s*Label\s*<\/key>\s*<string>([^<]*)<\/string>/);
109
+ if (labelMatch) out.label = xmlUnescapeText(labelMatch[1]);
110
+
111
+ const argsBlock = xml.match(/<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/);
112
+ if (argsBlock) {
113
+ const strs = argsBlock[1].matchAll(/<string>([^<]*)<\/string>/g);
114
+ for (const m of strs) out.programArguments.push(xmlUnescapeText(m[1]));
115
+ }
116
+ return out;
117
+ }
118
+
119
+ // ── filesystem scans ────────────────────────────────────────────────
120
+
121
+ function listCloudflaredConfigs() {
122
+ try {
123
+ return fs
124
+ .readdirSync(CLOUDFLARED_DIR)
125
+ .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
126
+ .map((f) => path.join(CLOUDFLARED_DIR, f));
127
+ } catch {
128
+ return [];
129
+ }
130
+ }
131
+
132
+ function listLaunchAgents() {
133
+ try {
134
+ return fs
135
+ .readdirSync(LAUNCH_AGENTS_DIR)
136
+ .filter((f) => f.endsWith('.plist'))
137
+ .map((f) => path.join(LAUNCH_AGENTS_DIR, f));
138
+ } catch {
139
+ return [];
140
+ }
141
+ }
142
+
143
+ function listSystemdUserUnits() {
144
+ try {
145
+ return fs
146
+ .readdirSync(SYSTEMD_USER_DIR)
147
+ .filter((f) => f.endsWith('.service'))
148
+ .map((f) => path.join(SYSTEMD_USER_DIR, f));
149
+ } catch {
150
+ return [];
151
+ }
152
+ }
153
+
154
+ // ── matching ────────────────────────────────────────────────────────
155
+
156
+ function ingressMatchesPort(ingress, port) {
157
+ if (!ingress) return false;
158
+ const wanted = parseInt(port, 10);
159
+ if (isNaN(wanted)) return false;
160
+ for (const item of ingress) {
161
+ const svc = item.service || '';
162
+ // Only http[s] ingress with localhost / 127.0.0.1 host; compare the
163
+ // port numerically to avoid substring false-positives (e.g. port 80
164
+ // matching localhost:8080).
165
+ const m = svc.match(/^https?:\/\/(localhost|127\.0\.0\.1):(\d+)(?:\/|\?|#|$)/);
166
+ if (m && parseInt(m[2], 10) === wanted) return true;
167
+ }
168
+ return false;
169
+ }
170
+
171
+ // When `port` is supplied, return the hostname of the ingress entry whose
172
+ // service actually targets that port — important when one tunnel routes
173
+ // several hostnames to several local ports. Falls back to the first
174
+ // hostname only if no entry matches (preserves the old behavior for
175
+ // callers that don't pass a port).
176
+ function hostnameFromIngress(ingress, port) {
177
+ if (!ingress) return null;
178
+ if (port != null) {
179
+ for (const item of ingress) {
180
+ if (item.hostname && ingressMatchesPort([item], port)) return item.hostname;
181
+ }
182
+ }
183
+ for (const item of ingress) {
184
+ if (item.hostname) return item.hostname;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // ── launchctl state ─────────────────────────────────────────────────
190
+
191
+ // Launchd labels are reverse-DNS strings; restrict to a conservative
192
+ // charset so a hostile plist Label can't influence argv parsing even
193
+ // though we already avoid the shell.
194
+ const LAUNCHD_LABEL_RE = /^[A-Za-z0-9._-]+$/;
195
+
196
+ function getLaunchctlState(label) {
197
+ if (!label || !LAUNCHD_LABEL_RE.test(label)) {
198
+ return { loaded: false, state: null, pid: null, lastExitCode: null };
199
+ }
200
+ try {
201
+ const uid = process.getuid
202
+ ? process.getuid()
203
+ : execFileSync('id', ['-u'], {
204
+ encoding: 'utf8',
205
+ stdio: ['ignore', 'pipe', 'ignore'],
206
+ }).trim();
207
+ const out = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], {
208
+ encoding: 'utf8',
209
+ stdio: ['ignore', 'pipe', 'ignore'],
210
+ });
211
+ const stateMatch = out.match(/^\s*state\s*=\s*(\S+)/m);
212
+ const pidMatch = out.match(/^\s*pid\s*=\s*(\d+)/m);
213
+ const exitMatch = out.match(/^\s*last exit code\s*=\s*(.+)$/m);
214
+ return {
215
+ loaded: true,
216
+ state: stateMatch ? stateMatch[1] : null,
217
+ pid: pidMatch ? parseInt(pidMatch[1], 10) : null,
218
+ lastExitCode: exitMatch ? exitMatch[1] : null,
219
+ };
220
+ } catch {
221
+ return { loaded: false, state: null, pid: null, lastExitCode: null };
222
+ }
223
+ }
224
+
225
+ // ── binary path validation ──────────────────────────────────────────
226
+
227
+ function isExecutable(p) {
228
+ try {
229
+ // Resolve through symlinks; if the chain dangles, fs.statSync throws.
230
+ fs.statSync(p);
231
+ fs.accessSync(p, fs.constants.X_OK);
232
+ return true;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ function resolveCloudflaredBinary() {
239
+ // Prefer `which`, falling back to common Homebrew prefixes. We never
240
+ // return a path that doesn't actually exist.
241
+ try {
242
+ const p = execFileSync('which', ['cloudflared'], {
243
+ encoding: 'utf8',
244
+ stdio: ['ignore', 'pipe', 'ignore'],
245
+ }).trim();
246
+ if (p && isExecutable(p)) return p;
247
+ } catch {}
248
+ for (const candidate of [
249
+ '/opt/homebrew/bin/cloudflared',
250
+ '/usr/local/bin/cloudflared',
251
+ '/usr/bin/cloudflared',
252
+ path.join(os.homedir(), '.local', 'bin', 'cloudflared'),
253
+ ]) {
254
+ if (isExecutable(candidate)) return candidate;
255
+ }
256
+ return null;
257
+ }
258
+
259
+ // ── discovery (public) ──────────────────────────────────────────────
260
+ //
261
+ // Returns an array of NamedTunnel records:
262
+ // {
263
+ // kind: 'launchd' | 'systemd',
264
+ // label,
265
+ // unitPath, // .plist on macOS, .service on Linux
266
+ // configPath,
267
+ // hostname, port,
268
+ // binaryPath, // what the unit *currently* says
269
+ // binaryPathHealthy, // does that file exist + execute?
270
+ // tunnelUuid, credentialsFile,
271
+ // launch: { loaded, state, pid, lastExitCode } | null,
272
+ // }
273
+ function discoverNamedTunnels(port) {
274
+ const platform = os.platform();
275
+ const configs = listCloudflaredConfigs()
276
+ .map((p) => {
277
+ try {
278
+ return { path: p, data: parseCloudflaredConfig(fs.readFileSync(p, 'utf8')) };
279
+ } catch {
280
+ return null;
281
+ }
282
+ })
283
+ .filter((c) => c && ingressMatchesPort(c.data.ingress, port));
284
+
285
+ if (configs.length === 0) return [];
286
+
287
+ if (platform === 'darwin') {
288
+ const agents = listLaunchAgents()
289
+ .map((p) => {
290
+ try {
291
+ return { path: p, data: parseLaunchAgentPlist(fs.readFileSync(p, 'utf8')) };
292
+ } catch {
293
+ return null;
294
+ }
295
+ })
296
+ .filter(
297
+ (a) =>
298
+ a && a.data.programArguments.length > 0 && /cloudflared$/.test(a.data.programArguments[0])
299
+ );
300
+
301
+ const tunnels = [];
302
+ for (const cfg of configs) {
303
+ const agent = agents.find((a) => a.data.programArguments.includes(cfg.path));
304
+ if (!agent) continue;
305
+ const binaryPath = agent.data.programArguments[0];
306
+ const launch = agent.data.label ? getLaunchctlState(agent.data.label) : null;
307
+ tunnels.push({
308
+ kind: 'launchd',
309
+ label: agent.data.label,
310
+ unitPath: agent.path,
311
+ configPath: cfg.path,
312
+ hostname: hostnameFromIngress(cfg.data.ingress, port),
313
+ port,
314
+ binaryPath,
315
+ binaryPathHealthy: isExecutable(binaryPath),
316
+ tunnelUuid: cfg.data.tunnel,
317
+ credentialsFile: cfg.data.credentialsFile,
318
+ launch,
319
+ });
320
+ }
321
+ return tunnels;
322
+ }
323
+
324
+ if (platform === 'linux') {
325
+ const units = listSystemdUserUnits()
326
+ .map((p) => {
327
+ try {
328
+ return { path: p, contents: fs.readFileSync(p, 'utf8') };
329
+ } catch {
330
+ return null;
331
+ }
332
+ })
333
+ .filter((u) => u && /cloudflared\b.*\btunnel\b/.test(u.contents));
334
+
335
+ const tunnels = [];
336
+ for (const cfg of configs) {
337
+ const unit = units.find((u) => u.contents.includes(cfg.path));
338
+ if (!unit) continue;
339
+ const execMatch = unit.contents.match(/ExecStart\s*=\s*(\S+)/);
340
+ const rawBinary = execMatch ? execMatch[1] : 'cloudflared';
341
+ // systemd unit files commonly use a bare command name
342
+ // (`ExecStart=cloudflared …`) and resolve it via $PATH at exec
343
+ // time. `isExecutable` does a literal stat, so a bare name would
344
+ // always report unhealthy. Resolve to a real path first.
345
+ const binaryPath = rawBinary.includes('/')
346
+ ? rawBinary
347
+ : resolveCloudflaredBinary() || rawBinary;
348
+ tunnels.push({
349
+ kind: 'systemd',
350
+ label: path.basename(unit.path, '.service'),
351
+ unitPath: unit.path,
352
+ configPath: cfg.path,
353
+ hostname: hostnameFromIngress(cfg.data.ingress, port),
354
+ port,
355
+ binaryPath,
356
+ binaryPathHealthy: isExecutable(binaryPath),
357
+ tunnelUuid: cfg.data.tunnel,
358
+ credentialsFile: cfg.data.credentialsFile,
359
+ launch: null, // systemctl status integration is deferred
360
+ });
361
+ }
362
+ return tunnels;
363
+ }
364
+
365
+ return [];
366
+ }
367
+
368
+ // ── self-heal ───────────────────────────────────────────────────────
369
+ //
370
+ // Rewrites the plist's first <string> under ProgramArguments (the
371
+ // program path) to `newPath`. We write a `.bak` copy first; the
372
+ // rewrite uses a string slice rather than an XML library to preserve
373
+ // the file's exact formatting / quirks. `newPath` is XML-escaped so a
374
+ // filesystem path containing `&`, `<`, or `>` can't corrupt the plist.
375
+ function xmlEscapeText(s) {
376
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
377
+ }
378
+
379
+ function rewritePlistBinaryPath(plistPath, oldPath, newPath) {
380
+ const xml = fs.readFileSync(plistPath, 'utf8');
381
+ // Locate the first <string>...</string> inside ProgramArguments.
382
+ const argsStart = xml.indexOf('<key>ProgramArguments</key>');
383
+ if (argsStart < 0) {
384
+ throw new Error(`No ProgramArguments key in ${plistPath}`);
385
+ }
386
+ const arrayOpen = xml.indexOf('<array>', argsStart);
387
+ if (arrayOpen < 0) throw new Error(`No <array> after ProgramArguments`);
388
+ const firstStringOpen = xml.indexOf('<string>', arrayOpen);
389
+ const firstStringClose = xml.indexOf('</string>', firstStringOpen);
390
+ if (firstStringOpen < 0 || firstStringClose < 0) {
391
+ throw new Error(`Malformed ProgramArguments array`);
392
+ }
393
+ const existingRaw = xml.slice(firstStringOpen + '<string>'.length, firstStringClose);
394
+ // Compare in the decoded domain so paths containing XML special chars
395
+ // round-trip with parseLaunchAgentPlist (which decodes the same entities).
396
+ const existing = xmlUnescapeText(existingRaw);
397
+ if (existing !== oldPath) {
398
+ throw new Error(`ProgramArguments[0] is ${existing}, expected ${oldPath} — refusing to heal`);
399
+ }
400
+ const next =
401
+ xml.slice(0, firstStringOpen + '<string>'.length) +
402
+ xmlEscapeText(newPath) +
403
+ xml.slice(firstStringClose);
404
+
405
+ fs.writeFileSync(plistPath + '.bak', xml);
406
+ fs.writeFileSync(plistPath, next);
407
+ }
408
+
409
+ function reloadLaunchAgent(plistPath, label) {
410
+ const uid = process.getuid
411
+ ? process.getuid()
412
+ : execFileSync('id', ['-u'], {
413
+ encoding: 'utf8',
414
+ stdio: ['ignore', 'pipe', 'ignore'],
415
+ }).trim();
416
+ // bootout may fail if not loaded; ignore.
417
+ try {
418
+ execFileSync('launchctl', ['bootout', `gui/${uid}/${label}`], {
419
+ stdio: 'ignore',
420
+ });
421
+ } catch {}
422
+ execFileSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], {
423
+ stdio: 'ignore',
424
+ });
425
+ }
426
+
427
+ function kickstartLaunchAgent(label) {
428
+ const uid = process.getuid
429
+ ? process.getuid()
430
+ : execFileSync('id', ['-u'], {
431
+ encoding: 'utf8',
432
+ stdio: ['ignore', 'pipe', 'ignore'],
433
+ }).trim();
434
+ try {
435
+ execFileSync('launchctl', ['kickstart', '-k', `gui/${uid}/${label}`], {
436
+ stdio: 'ignore',
437
+ });
438
+ } catch {}
439
+ }
440
+
441
+ // Heal-and-ensure-running. Returns one of:
442
+ // { action: 'healed', from, to }
443
+ // { action: 'reloaded' } - was loaded, kickstarted
444
+ // { action: 'started' } - was not loaded, bootstrapped
445
+ // { action: 'ok' } - already running, nothing done
446
+ // { action: 'failed', reason }
447
+ function healAndEnsureRunning(tunnel) {
448
+ if (tunnel.kind !== 'launchd') {
449
+ return { action: 'ok' }; // systemd auto-heal is out of scope
450
+ }
451
+ try {
452
+ if (!tunnel.binaryPathHealthy) {
453
+ const realPath = resolveCloudflaredBinary();
454
+ if (!realPath) {
455
+ return {
456
+ action: 'failed',
457
+ reason: 'cloudflared binary not found on PATH',
458
+ };
459
+ }
460
+ rewritePlistBinaryPath(tunnel.unitPath, tunnel.binaryPath, realPath);
461
+ reloadLaunchAgent(tunnel.unitPath, tunnel.label);
462
+ return { action: 'healed', from: tunnel.binaryPath, to: realPath };
463
+ }
464
+
465
+ if (!tunnel.launch || !tunnel.launch.loaded) {
466
+ reloadLaunchAgent(tunnel.unitPath, tunnel.label);
467
+ return { action: 'started' };
468
+ }
469
+ if (tunnel.launch.state !== 'running') {
470
+ kickstartLaunchAgent(tunnel.label);
471
+ return { action: 'reloaded' };
472
+ }
473
+ return { action: 'ok' };
474
+ } catch (err) {
475
+ return { action: 'failed', reason: err.message };
476
+ }
477
+ }
478
+
479
+ // ── reachability ────────────────────────────────────────────────────
480
+ //
481
+ // Curl the public URL with a short timeout. We use curl directly for
482
+ // the same reason apn.ts does: native http modules occasionally fail
483
+ // at TLS on this user's Node.
484
+ function probeReachability(url, timeoutMs = 5000) {
485
+ try {
486
+ const out = execFileSync(
487
+ 'curl',
488
+ [
489
+ '-s',
490
+ '-o',
491
+ '/dev/null',
492
+ '-w',
493
+ '%{http_code}',
494
+ '--max-time',
495
+ String(Math.ceil(timeoutMs / 1000)),
496
+ url,
497
+ ],
498
+ { encoding: 'utf8', timeout: timeoutMs + 2000 }
499
+ ).trim();
500
+ const code = parseInt(out, 10);
501
+ return { ok: code >= 200 && code < 500, status: isNaN(code) ? null : code };
502
+ } catch {
503
+ return { ok: false, status: null };
504
+ }
505
+ }
506
+
507
+ module.exports = {
508
+ discoverNamedTunnels,
509
+ healAndEnsureRunning,
510
+ probeReachability,
511
+ resolveCloudflaredBinary,
512
+ xmlEscapeText,
513
+ // exported for tests
514
+ parseCloudflaredConfig,
515
+ parseLaunchAgentPlist,
516
+ rewritePlistBinaryPath,
517
+ ingressMatchesPort,
518
+ hostnameFromIngress,
519
+ };