harnery 0.41.0 → 0.43.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 (269) hide show
  1. package/dist/commander.d.ts +16 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commander.js +21 -0
  4. package/dist/commands/agents.d.ts.map +1 -1
  5. package/dist/commands/agents.js +63 -5
  6. package/dist/commands/artifacts.d.ts.map +1 -1
  7. package/dist/commands/artifacts.js +113 -12
  8. package/dist/commands/browse.d.ts +164 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +263 -51
  11. package/dist/commands/claude-desktop.d.ts.map +1 -1
  12. package/dist/commands/claude-desktop.js +52 -0
  13. package/dist/commands/disk.d.ts +6 -0
  14. package/dist/commands/disk.d.ts.map +1 -0
  15. package/dist/commands/disk.js +80 -0
  16. package/dist/commands/doctor.d.ts +8 -0
  17. package/dist/commands/doctor.d.ts.map +1 -1
  18. package/dist/commands/doctor.js +48 -1
  19. package/dist/commands/files.d.ts +16 -0
  20. package/dist/commands/files.d.ts.map +1 -1
  21. package/dist/commands/files.js +54 -2
  22. package/dist/commands/init.d.ts +2 -2
  23. package/dist/commands/init.d.ts.map +1 -1
  24. package/dist/commands/init.js +43 -9
  25. package/dist/commands/rm.d.ts +4 -0
  26. package/dist/commands/rm.d.ts.map +1 -0
  27. package/dist/commands/rm.js +87 -0
  28. package/dist/commands/servers.d.ts +7 -0
  29. package/dist/commands/servers.d.ts.map +1 -0
  30. package/dist/commands/servers.js +394 -0
  31. package/dist/commands/tunnel.d.ts +65 -0
  32. package/dist/commands/tunnel.d.ts.map +1 -1
  33. package/dist/commands/tunnel.js +372 -103
  34. package/dist/commands/web-fetch.d.ts +3 -2
  35. package/dist/commands/web-fetch.d.ts.map +1 -1
  36. package/dist/commands/web-fetch.js +25 -19
  37. package/dist/commands/web.d.ts +7 -1
  38. package/dist/commands/web.d.ts.map +1 -1
  39. package/dist/commands/web.js +9 -3
  40. package/dist/core/adapters/profiles.d.ts +9 -0
  41. package/dist/core/adapters/profiles.d.ts.map +1 -1
  42. package/dist/core/adapters/profiles.js +22 -1
  43. package/dist/core/adapters/types.d.ts +9 -0
  44. package/dist/core/adapters/types.d.ts.map +1 -1
  45. package/dist/core/agents/cli.js +13 -6
  46. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  47. package/dist/core/agents/render/prompt-context.js +11 -5
  48. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  49. package/dist/core/agents/render/session-context.js +59 -13
  50. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  51. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  52. package/dist/core/agents/state/live-coordination-view.js +60 -0
  53. package/dist/core/agents/state/names.d.ts +43 -3
  54. package/dist/core/agents/state/names.d.ts.map +1 -1
  55. package/dist/core/agents/state/names.js +87 -3
  56. package/dist/core/artifacts/delivery-card.js +9 -2
  57. package/dist/core/artifacts/index.d.ts +140 -4
  58. package/dist/core/artifacts/index.d.ts.map +1 -1
  59. package/dist/core/artifacts/index.js +522 -49
  60. package/dist/core/config.d.ts +68 -11
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +128 -3
  63. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  64. package/dist/core/diagnostics/bundle.js +2 -2
  65. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  66. package/dist/core/events/v3/producers/recorder.js +16 -0
  67. package/dist/core/events/v3/projection.js +9 -1
  68. package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
  69. package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
  70. package/dist/core/hooks/adapter/events.d.ts +17 -0
  71. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  72. package/dist/core/hooks/adapter/events.js +5 -0
  73. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  74. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  75. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  76. package/dist/core/hooks/adapter/wiring.js +62 -4
  77. package/dist/core/hooks/cli.js +131 -23
  78. package/dist/core/hooks/fork-lineage.d.ts +40 -0
  79. package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
  80. package/dist/core/hooks/fork-lineage.js +62 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts +16 -0
  82. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  83. package/dist/core/hooks/resolve/transcript.js +84 -0
  84. package/dist/core/hooks/run-markers.d.ts +37 -0
  85. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  86. package/dist/core/hooks/run-markers.js +109 -0
  87. package/dist/core/hooks/shell-rm.d.ts +2 -0
  88. package/dist/core/hooks/shell-rm.d.ts.map +1 -0
  89. package/dist/core/hooks/shell-rm.js +105 -0
  90. package/dist/core/hooks/shell-tokens.d.ts +12 -0
  91. package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
  92. package/dist/core/hooks/shell-tokens.js +97 -0
  93. package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
  94. package/dist/core/hooks/shell-waiter.js +3 -93
  95. package/dist/core/semantic/contract.d.ts +5 -5
  96. package/dist/core/servers/index.d.ts +211 -0
  97. package/dist/core/servers/index.d.ts.map +1 -0
  98. package/dist/core/servers/index.js +560 -0
  99. package/dist/core/servers/net.d.ts +58 -0
  100. package/dist/core/servers/net.d.ts.map +1 -0
  101. package/dist/core/servers/net.js +270 -0
  102. package/dist/core/servers/tunnels.d.ts +14 -0
  103. package/dist/core/servers/tunnels.d.ts.map +1 -0
  104. package/dist/core/servers/tunnels.js +33 -0
  105. package/dist/core/storage/builtins.js +1 -0
  106. package/dist/core/storage/process-log.d.ts +12 -0
  107. package/dist/core/storage/process-log.d.ts.map +1 -1
  108. package/dist/core/storage/process-log.js +10 -2
  109. package/dist/core/workflow/engine.d.ts.map +1 -1
  110. package/dist/core/workflow/engine.js +30 -1
  111. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  112. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  113. package/dist/core/workflow/sandbox-projection.js +23 -0
  114. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  115. package/dist/core/workflow/spawn-claude.js +6 -1
  116. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  117. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  118. package/dist/core/workflow/spawn-codex.js +20 -1
  119. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  120. package/dist/core/workflow/spawn-cursor.js +6 -1
  121. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  122. package/dist/core/workflow/spawn-opencode.js +4 -1
  123. package/dist/core/workflow/types.d.ts +47 -2
  124. package/dist/core/workflow/types.d.ts.map +1 -1
  125. package/dist/core/workflow/worker-access.d.ts +92 -0
  126. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  127. package/dist/core/workflow/worker-access.js +450 -0
  128. package/dist/lib/browser/batch-steps.d.ts +18 -0
  129. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  130. package/dist/lib/browser/batch-steps.js +82 -0
  131. package/dist/lib/browser/collect/collector.d.ts +99 -0
  132. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  133. package/dist/lib/browser/collect/collector.js +202 -0
  134. package/dist/lib/browser/collect/fields.d.ts +31 -0
  135. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  136. package/dist/lib/browser/collect/fields.js +75 -0
  137. package/dist/lib/browser/collect/index.d.ts +6 -0
  138. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  139. package/dist/lib/browser/collect/index.js +5 -0
  140. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  141. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  142. package/dist/lib/browser/collect/page-driver.js +306 -0
  143. package/dist/lib/browser/collect/presets.d.ts +52 -0
  144. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  145. package/dist/lib/browser/collect/presets.js +176 -0
  146. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  147. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  148. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  149. package/dist/lib/browser/index.d.ts +1 -1
  150. package/dist/lib/browser/index.d.ts.map +1 -1
  151. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  152. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  153. package/dist/lib/browser/page-review-judge.js +13 -0
  154. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  155. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  156. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  157. package/dist/lib/browser/qa-run.js +1 -0
  158. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  159. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  160. package/dist/lib/browser/windows-chrome.js +179 -0
  161. package/dist/lib/claude-desktop-share.d.ts +136 -0
  162. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  163. package/dist/lib/claude-desktop-share.js +394 -0
  164. package/dist/lib/disk-usage.d.ts +50 -0
  165. package/dist/lib/disk-usage.d.ts.map +1 -0
  166. package/dist/lib/disk-usage.js +303 -0
  167. package/dist/lib/docs-lint.d.ts +6 -0
  168. package/dist/lib/docs-lint.d.ts.map +1 -1
  169. package/dist/lib/docs-lint.js +8 -3
  170. package/dist/lib/guarded-remove.d.ts +34 -0
  171. package/dist/lib/guarded-remove.d.ts.map +1 -0
  172. package/dist/lib/guarded-remove.js +310 -0
  173. package/dist/lib/instructions/templates.js +8 -8
  174. package/dist/lib/tunnel/allowlist.d.ts +44 -0
  175. package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
  176. package/dist/lib/tunnel/allowlist.js +204 -0
  177. package/dist/lib/tunnel/current-address.d.ts +41 -0
  178. package/dist/lib/tunnel/current-address.d.ts.map +1 -0
  179. package/dist/lib/tunnel/current-address.js +123 -0
  180. package/dist/lib/tunnel/error-page.d.ts +1 -1
  181. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  182. package/dist/lib/tunnel/error-page.js +19 -8
  183. package/dist/lib/tunnel/gate.js +33 -7
  184. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  185. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  186. package/dist/lib/tunnel/path-scope.js +62 -0
  187. package/dist/lib/tunnel/processes.d.ts +39 -0
  188. package/dist/lib/tunnel/processes.d.ts.map +1 -0
  189. package/dist/lib/tunnel/processes.js +121 -0
  190. package/dist/lib/tunnel/state.d.ts +33 -3
  191. package/dist/lib/tunnel/state.d.ts.map +1 -1
  192. package/dist/lib/tunnel/state.js +115 -30
  193. package/package.json +7 -1
  194. package/schemas/config.schema.json +91 -0
  195. package/src/commander.ts +51 -0
  196. package/src/commands/agents.ts +111 -11
  197. package/src/commands/artifacts.ts +171 -19
  198. package/src/commands/browse.ts +400 -57
  199. package/src/commands/claude-desktop.ts +61 -0
  200. package/src/commands/disk.ts +114 -0
  201. package/src/commands/doctor.ts +52 -1
  202. package/src/commands/files.ts +93 -2
  203. package/src/commands/init.ts +55 -5
  204. package/src/commands/rm.ts +115 -0
  205. package/src/commands/servers.ts +506 -0
  206. package/src/commands/tunnel.ts +494 -116
  207. package/src/commands/web-fetch.ts +30 -19
  208. package/src/commands/web.ts +9 -3
  209. package/src/core/adapters/profiles.ts +22 -1
  210. package/src/core/adapters/types.ts +10 -0
  211. package/src/core/agents/cli.ts +14 -6
  212. package/src/core/agents/render/prompt-context.ts +25 -8
  213. package/src/core/agents/render/session-context.ts +84 -16
  214. package/src/core/agents/state/live-coordination-view.ts +73 -0
  215. package/src/core/agents/state/names.ts +117 -6
  216. package/src/core/artifacts/delivery-card.ts +10 -2
  217. package/src/core/artifacts/index.ts +697 -44
  218. package/src/core/config.ts +183 -4
  219. package/src/core/diagnostics/bundle.ts +2 -1
  220. package/src/core/events/v3/producers/recorder.ts +16 -0
  221. package/src/core/events/v3/projection.ts +11 -1
  222. package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
  223. package/src/core/hooks/adapter/events.ts +19 -0
  224. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  225. package/src/core/hooks/adapter/wiring.ts +116 -8
  226. package/src/core/hooks/cli.ts +152 -22
  227. package/src/core/hooks/fork-lineage.ts +82 -0
  228. package/src/core/hooks/resolve/transcript.ts +88 -0
  229. package/src/core/hooks/run-markers.ts +132 -0
  230. package/src/core/hooks/shell-rm.ts +103 -0
  231. package/src/core/hooks/shell-tokens.ts +96 -0
  232. package/src/core/hooks/shell-waiter.ts +4 -91
  233. package/src/core/servers/index.ts +712 -0
  234. package/src/core/servers/net.ts +283 -0
  235. package/src/core/servers/tunnels.ts +39 -0
  236. package/src/core/storage/builtins.ts +1 -0
  237. package/src/core/storage/process-log.ts +17 -2
  238. package/src/core/workflow/engine.ts +38 -1
  239. package/src/core/workflow/sandbox-projection.ts +38 -2
  240. package/src/core/workflow/spawn-claude.ts +5 -1
  241. package/src/core/workflow/spawn-codex.ts +18 -1
  242. package/src/core/workflow/spawn-cursor.ts +5 -1
  243. package/src/core/workflow/spawn-opencode.ts +4 -1
  244. package/src/core/workflow/types.ts +62 -2
  245. package/src/core/workflow/worker-access.ts +525 -0
  246. package/src/lib/browser/batch-steps.ts +79 -0
  247. package/src/lib/browser/collect/collector.ts +306 -0
  248. package/src/lib/browser/collect/fields.ts +87 -0
  249. package/src/lib/browser/collect/index.ts +47 -0
  250. package/src/lib/browser/collect/page-driver.ts +327 -0
  251. package/src/lib/browser/collect/presets.ts +237 -0
  252. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  253. package/src/lib/browser/index.ts +1 -0
  254. package/src/lib/browser/page-review-judge.ts +16 -0
  255. package/src/lib/browser/qa-run-contracts.ts +11 -0
  256. package/src/lib/browser/qa-run.ts +1 -0
  257. package/src/lib/browser/windows-chrome.ts +227 -0
  258. package/src/lib/claude-desktop-share.ts +511 -0
  259. package/src/lib/disk-usage.ts +347 -0
  260. package/src/lib/docs-lint.ts +8 -3
  261. package/src/lib/guarded-remove.ts +376 -0
  262. package/src/lib/instructions/templates.ts +8 -8
  263. package/src/lib/tunnel/allowlist.ts +208 -0
  264. package/src/lib/tunnel/current-address.ts +158 -0
  265. package/src/lib/tunnel/error-page.ts +25 -9
  266. package/src/lib/tunnel/gate.ts +38 -9
  267. package/src/lib/tunnel/path-scope.ts +73 -0
  268. package/src/lib/tunnel/processes.ts +149 -0
  269. package/src/lib/tunnel/state.ts +143 -30
@@ -1,8 +1,14 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
+ import { resolveCoordRoot } from "../core/agents/coord-client.js";
4
5
  import { resolveBinName } from "../core/config.js";
5
- import { processLogDestination, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
6
+ import { tryRegisterServer, tunnelServerId, tunnelServerInput, unregisterServer, } from "../core/servers/index.js";
7
+ import { processLogDestination, runProcessLogWorker, runRotatingProcessSync, spawnRotatingProcess, } from "../core/storage/process-log.js";
8
+ import { normalizeAllowEntry } from "../lib/tunnel/allowlist.js";
9
+ import { detectPublicAddresses, planCurrentAddresses, } from "../lib/tunnel/current-address.js";
10
+ import { ALLOW_PATHS_ENV, normalizeAllowPaths } from "../lib/tunnel/path-scope.js";
11
+ import { connectProbe, findPidsByCommandLine, listeningPorts, portListening, } from "../lib/tunnel/processes.js";
6
12
  import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcessAlive, isTunnelStateLive, listStates, providerLogFile, readConfig, readState, writeConfig, writeState, } from "../lib/tunnel/state.js";
7
13
  /**
8
14
  * `tunnel`: provider-backed tunnel in front of a local upstream.
@@ -11,10 +17,14 @@ import { clearState, DEFAULT_INSTANCE, ensureCloudflared, gateLogFile, isProcess
11
17
  * upstream. Cloudflare quick tunnels add an IP allowlist at the gate via the
12
18
  * Cloudflare-set `CF-Connecting-IP` header; Tailscale Serve/Funnel exposes the
13
19
  * same gate through tailscaled, with Tailscale owning the access boundary.
20
+ * Every provider also passes through a path scope: `up` requires at least one
21
+ * `--allow-path <prefix>`, and the gate refuses any request outside it.
14
22
  *
15
- * State + config persisted under `.cache/tunnel/`. cloudflared auto-installs
16
- * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`);
17
- * Tailscale requires an installed/authenticated `tailscale` CLI.
23
+ * State + config persisted under `.cache/tunnel/` (or `$HARNERY_TUNNEL_DIR`).
24
+ * cloudflared comes from `$HARNERY_CLOUDFLARED` / the config's `cloudflared_path`
25
+ * when set, else PATH, else auto-installs to ~/.local/bin/ on Linux (macOS and
26
+ * Windows hosts install it themselves); Tailscale requires an installed and
27
+ * authenticated `tailscale` CLI.
18
28
  */
19
29
  const DEFAULT_TARGET = "127.0.0.1:8001";
20
30
  const DEFAULT_VHOST = "localhost";
@@ -98,38 +108,126 @@ function resolveHttpsPort(raw) {
98
108
  }
99
109
  return port;
100
110
  }
111
+ /**
112
+ * The path scope for `up`. At least one prefix is required: a tunnel forwards
113
+ * to a whole local app, and publishing all of it has to be a stated choice
114
+ * (`--allow-path /`), never the result of leaving a flag off.
115
+ */
116
+ function resolveAllowPaths(raw) {
117
+ let paths;
118
+ try {
119
+ paths = normalizeAllowPaths(raw ?? []);
120
+ }
121
+ catch (error) {
122
+ emit.error({
123
+ code: "tunnel_path_scope_invalid",
124
+ message: error instanceof Error ? error.message : String(error),
125
+ });
126
+ process.exit(1);
127
+ }
128
+ if (paths.length === 0) {
129
+ emit.error({
130
+ code: "tunnel_path_scope_empty",
131
+ message: "No path scope; refusing to start. Name what the tunnel shares with --allow-path <prefix> " +
132
+ "(repeatable), or pass --allow-path / to publish the whole upstream.",
133
+ });
134
+ process.exit(1);
135
+ }
136
+ return paths;
137
+ }
138
+ function samePaths(a, b) {
139
+ return a.length === b.length && [...a].sort().join("\n") === [...b].sort().join("\n");
140
+ }
141
+ function formatPaths(paths) {
142
+ return paths.length === 0 ? "(none: every path refused)" : paths.join(", ");
143
+ }
144
+ function collectPath(value, previous) {
145
+ return [...previous, value];
146
+ }
101
147
  function gateScriptPath() {
102
148
  return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
103
149
  }
150
+ /** The gate worker's `--name`/`--port` marker, also what the stray sweep matches. */
151
+ const GATE_SUBCOMMAND = ["tunnel", "gate"];
104
152
  /**
105
- * `harn tunnel` is the one command that hard-requires Bun: the gate worker is a
106
- * `Bun.serve` process (HTTP + WebSocket reverse proxy), spawned as `bun run
107
- * gate.ts`. Everything else in harnery runs on Node, but this can't until the
108
- * gate is ported off `Bun.serve` (node:http + `ws`). Detect Bun up front so the
109
- * failure is a clear message rather than an opaque ENOENT from the gate spawn.
153
+ * How to start the gate worker, which is a `Bun.serve` process (HTTP and
154
+ * WebSocket reverse proxy):
155
+ * 1. From a source or package checkout, `gate.ts` sits beside this module and
156
+ * runs directly.
157
+ * 2. Inside a single-file bundle there is no such file. The CLI then
158
+ * re-executes itself with the hidden `tunnel gate` task, which loads the
159
+ * gate in-process. This uses the running Bun binary, so Bun need not be
160
+ * on PATH.
161
+ * Null means neither works (a Node host with no Bun, or an unknown entry).
110
162
  */
111
- function bunAvailable() {
112
- return spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0;
163
+ export function resolveGateLaunch(i) {
164
+ if (i.gateScriptExists && (i.underBun || i.bunOnPath)) {
165
+ return {
166
+ command: i.underBun ? i.execPath : "bun",
167
+ arguments: ["run", i.gateScript],
168
+ };
169
+ }
170
+ if (i.underBun && i.entryScript && i.entryScriptExists) {
171
+ return { command: i.execPath, arguments: [i.entryScript, ...GATE_SUBCOMMAND] };
172
+ }
173
+ return null;
113
174
  }
175
+ /**
176
+ * How to start the log wrapper that rotates each process's output. Beside a
177
+ * source or package checkout the default (this package's own wrapper file)
178
+ * works; inside a single-file bundle the CLI re-executes itself with the hidden
179
+ * `tunnel log-worker` task instead.
180
+ */
181
+ export function resolveWorkerLaunch(i) {
182
+ if (i.gateScriptExists)
183
+ return undefined;
184
+ if (i.underBun && i.entryScript && i.entryScriptExists) {
185
+ return { command: i.execPath, arguments: [i.entryScript, "tunnel", "log-worker"] };
186
+ }
187
+ return undefined;
188
+ }
189
+ function currentLaunchInputs() {
190
+ const gateScript = gateScriptPath();
191
+ const entryScript = process.argv[1] ? resolve(process.argv[1]) : undefined;
192
+ const underBun = typeof process.versions.bun === "string";
193
+ const gateScriptExists = existsSync(gateScript);
194
+ return {
195
+ gateScript,
196
+ gateScriptExists,
197
+ underBun,
198
+ execPath: process.execPath,
199
+ entryScript,
200
+ entryScriptExists: entryScript ? existsSync(entryScript) : false,
201
+ bunOnPath: !underBun && gateScriptExists && bunOnPath(),
202
+ };
203
+ }
204
+ function currentGateLaunch() {
205
+ return resolveGateLaunch(currentLaunchInputs());
206
+ }
207
+ function currentWorkerLaunch() {
208
+ return resolveWorkerLaunch(currentLaunchInputs());
209
+ }
210
+ function bunOnPath() {
211
+ return spawnSync("bun", ["--version"], { stdio: "ignore", windowsHide: true }).status === 0;
212
+ }
213
+ const requiresBunMessage = () => `${resolveBinName()} tunnel requires Bun: the gate worker is a Bun.serve process. ` +
214
+ "Install Bun (https://bun.sh) and re-run. (Every other command runs on Node.)";
114
215
  function tailscaleAvailable() {
115
- return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
216
+ return spawnSync("tailscale", ["version"], { stdio: "ignore", windowsHide: true }).status === 0;
116
217
  }
117
218
  function sleep(ms) {
118
219
  return new Promise((r) => setTimeout(r, ms));
119
220
  }
120
221
  /**
121
- * Kill every process whose command line matches `pattern` (via `pgrep -f`),
122
- * skipping our own PID and any already-killed. Returns the count killed. Used
123
- * as a fallback so orphaned gate/cloudflared processes get cleaned even when
124
- * the state file was lost (which otherwise left them squatting on the port).
222
+ * Kill every process whose command line matches `pattern` (an extended regular
223
+ * expression), skipping our own PID and any already-killed. Returns the count
224
+ * killed. Used as a fallback so orphaned gate/cloudflared processes get cleaned
225
+ * even when the state file was lost (which otherwise left them squatting on the
226
+ * port).
125
227
  */
126
228
  function killByPattern(pattern, alreadyKilled) {
127
- const r = spawnSync("pgrep", ["-f", pattern], { encoding: "utf-8" });
128
- if (r.status !== 0 || typeof r.stdout !== "string")
129
- return 0;
130
229
  let killed = 0;
131
- for (const line of r.stdout.split("\n")) {
132
- const pid = Number(line.trim());
230
+ for (const pid of findPidsByCommandLine(pattern)) {
133
231
  if (!pid || pid === process.pid || alreadyKilled.has(pid))
134
232
  continue;
135
233
  try {
@@ -143,58 +241,41 @@ function killByPattern(pattern, alreadyKilled) {
143
241
  }
144
242
  return killed;
145
243
  }
244
+ /**
245
+ * Command-line pattern for ONE instance's gate, identified by its port. Matches
246
+ * both launch forms: `bun run .../gate.ts --name N --port P` and a host CLI
247
+ * re-executing itself as `<cli> tunnel gate --name N --port P`. Port boundary is
248
+ * guarded with `( |$)` so port 9001 doesn't match 90011.
249
+ */
250
+ function gatePattern(gatePort) {
251
+ return `(gate\\.ts|tunnel gate) .*--port ${gatePort}( |$)`;
252
+ }
146
253
  /**
147
254
  * Sweep stray gate + cloudflared processes for ONE instance, identified by its
148
255
  * gate port. Both signatures are port-scoped so tearing down one tunnel never
149
256
  * touches another:
150
- * - gate: `gate.ts ... --port <port>` (the port is on the gate's argv)
257
+ * - gate: see gatePattern (the port is on the gate's argv)
151
258
  * - cloudflared: `--url http://localhost:<port>` (order-independent, so it
152
259
  * matches regardless of the `--protocol http2` flag we also pass).
153
- * Port boundary is guarded with `( |$)` so port 9001 doesn't match 90011.
154
260
  */
155
261
  function sweepStrays(gatePort, alreadyKilled) {
156
- return (killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, alreadyKilled) +
262
+ return (killByPattern(gatePattern(gatePort), alreadyKilled) +
157
263
  killByPattern(`--url http://localhost:${gatePort}( |$)`, alreadyKilled));
158
264
  }
159
- /**
160
- * Ports currently bound by a LISTEN socket (best-effort, platform-dependent).
161
- *
162
- * `ss` (iproute2) is Linux-only. Without the lsof fallback this returns an empty
163
- * set on macOS/BSD. That does not fail loudly. It reports every port as free, so
164
- * `allocateGatePort` can hand out an occupied port and the reload port-release
165
- * check becomes inert.
166
- */
167
- function listeningPorts() {
168
- const ports = new Set();
169
- const ss = spawnSync("ss", ["-tlnH"], { encoding: "utf-8" });
170
- if (ss.status === 0 && typeof ss.stdout === "string") {
171
- for (const m of ss.stdout.matchAll(/:(\d+)\s/g))
172
- ports.add(Number(m[1]));
173
- return ports;
174
- }
175
- // Rows look like: `bun 123 user 4u IPv4 0x… 0t0 TCP 127.0.0.1:58055 (LISTEN)`.
176
- // Status can be nonzero while stdout still holds usable rows (lsof reports a
177
- // failure when any single process is unreadable), so judge it on the output.
178
- const lsof = spawnSync("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN"], { encoding: "utf-8" });
179
- if (typeof lsof.stdout === "string") {
180
- for (const m of lsof.stdout.matchAll(/:(\d+) \(LISTEN\)$/gm))
181
- ports.add(Number(m[1]));
182
- }
183
- return ports;
184
- }
185
265
  /**
186
266
  * Pick a gate port for a new instance. An explicit `--gate-port` is honored
187
267
  * (and rejected if it's already taken); otherwise scan upward from 9001 for the
188
268
  * first port that's neither held by a live instance nor currently listening.
189
269
  */
190
- function allocateGatePort(preferred) {
270
+ async function allocateGatePort(preferred) {
191
271
  const used = new Set(listStates()
192
272
  .filter((s) => isProcessAlive(s.gate_pid))
193
273
  .map((s) => s.gate_port));
274
+ // No discovery tool (null) is not "every port free": probe candidates by connecting.
194
275
  const listening = listeningPorts();
195
- const taken = (p) => used.has(p) || listening.has(p);
276
+ const taken = async (p) => used.has(p) || (listening ? listening.has(p) : await connectProbe(p));
196
277
  if (preferred !== undefined) {
197
- if (taken(preferred)) {
278
+ if (await taken(preferred)) {
198
279
  emit.error({
199
280
  code: "tunnel_port_taken",
200
281
  message: `Gate port ${preferred} is already in use. Omit --gate-port to auto-allocate, or pick a free one.`,
@@ -204,7 +285,7 @@ function allocateGatePort(preferred) {
204
285
  return preferred;
205
286
  }
206
287
  for (let p = DEFAULT_GATE_PORT; p <= MAX_GATE_PORT; p++) {
207
- if (!taken(p))
288
+ if (!(await taken(p)))
208
289
  return p;
209
290
  }
210
291
  emit.error({
@@ -352,13 +433,14 @@ function tunnelIsAlive(state) {
352
433
  * is passed as an env var, which the gate snapshots at module load and never
353
434
  * re-reads — that snapshot is precisely why `reload` has to exist.
354
435
  */
355
- function spawnGate(o) {
436
+ function spawnGate(o, launch) {
356
437
  // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
357
- // process distinguishable per-instance in `pgrep -f` (see sweepStrays).
438
+ // process distinguishable per-instance in a command-line search (see sweepStrays).
358
439
  const gateProc = spawnRotatingProcess({
359
440
  path: o.gateLogPath,
360
- command: "bun",
361
- arguments: ["run", gateScriptPath(), "--name", o.name, "--port", String(o.gatePort)],
441
+ command: launch.command,
442
+ arguments: [...launch.arguments, "--name", o.name, "--port", String(o.gatePort)],
443
+ worker: currentWorkerLaunch(),
362
444
  env: {
363
445
  ...process.env,
364
446
  HARNERY_TUNNEL_ALLOW: o.allowedIps.join(","),
@@ -366,18 +448,16 @@ function spawnGate(o) {
366
448
  HARNERY_TUNNEL_TARGET: o.target,
367
449
  HARNERY_TUNNEL_VHOST: o.vhost,
368
450
  HARNERY_TUNNEL_PORT: String(o.gatePort),
451
+ [ALLOW_PATHS_ENV]: o.allowPaths.join(","),
369
452
  },
370
453
  });
371
454
  gateProc.unref();
372
455
  return gateProc;
373
456
  }
374
457
  async function up(opts) {
375
- if (!bunAvailable()) {
376
- emit.error({
377
- code: "tunnel_requires_bun",
378
- message: "harn tunnel requires Bun: the gate worker is a Bun.serve process. " +
379
- "Install Bun (https://bun.sh) and re-run. (Every other harn command runs on Node.)",
380
- });
458
+ const launch = currentGateLaunch();
459
+ if (!launch) {
460
+ emit.error({ code: "tunnel_requires_bun", message: requiresBunMessage() });
381
461
  process.exit(1);
382
462
  }
383
463
  const name = resolveName(opts.name);
@@ -389,6 +469,7 @@ async function up(opts) {
389
469
  const tailscaleMode = provider === "tailscale" ? resolveTailscaleMode(opts.visibility) : undefined;
390
470
  const tailscalePath = provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
391
471
  const tailscaleHttpsPort = provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
472
+ const allowPaths = resolveAllowPaths(opts.allowPath);
392
473
  const existing = readState(name);
393
474
  if (existing && tunnelIsAlive(existing)) {
394
475
  if (existing.provider !== provider) {
@@ -398,16 +479,25 @@ async function up(opts) {
398
479
  });
399
480
  process.exit(1);
400
481
  }
482
+ if (!samePaths(existing.allow_paths, allowPaths)) {
483
+ emit.error({
484
+ code: "tunnel_instance_in_use",
485
+ message: `Tunnel [${name}] is already up sharing ${formatPaths(existing.allow_paths)}. ` +
486
+ `Stop it before starting it with ${formatPaths(allowPaths)}.`,
487
+ });
488
+ process.exit(1);
489
+ }
401
490
  emit.text(`Already up [${name}]: ${existing.url}\n`);
402
491
  emit.text(` Provider: ${existing.provider}\n`);
403
492
  emit.text(` Forwarding: ${existing.target} (Host: ${existing.vhost})\n`);
493
+ emit.text(` Paths: ${formatPaths(existing.allow_paths)}\n`);
404
494
  return;
405
495
  }
406
496
  if (existing)
407
497
  downOne(name, new Set());
408
498
  // Allocate the gate port (after clearing dead state so its old port frees up
409
499
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
410
- const gatePort = allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
500
+ const gatePort = await allocateGatePort(opts.gatePort ? Number(opts.gatePort) : undefined);
411
501
  // Self-heal: clear any orphaned gate/cloudflared on THIS instance's port from
412
502
  // a prior crashed or state-cleared run so the gate port is free before we bind.
413
503
  if (sweepStrays(gatePort, new Set()))
@@ -416,11 +506,23 @@ async function up(opts) {
416
506
  if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
417
507
  emit.error({
418
508
  code: "tunnel_allowlist_empty",
419
- message: "Allowlist is empty; refusing to start. Add an IP first: harn tunnel allow add <ip>",
509
+ message: `Allowlist is empty; refusing to start. Add an IP first: ${resolveBinName()} tunnel allow add <ip> (or --current)`,
420
510
  });
421
511
  process.exit(1);
422
512
  }
423
- const cloudflaredBin = provider === "cloudflare" ? ensureCloudflared() : null;
513
+ let cloudflaredBin = null;
514
+ if (provider === "cloudflare") {
515
+ try {
516
+ cloudflaredBin = ensureCloudflared();
517
+ }
518
+ catch (error) {
519
+ emit.error({
520
+ code: "tunnel_cloudflared_missing",
521
+ message: error instanceof Error ? error.message : String(error),
522
+ });
523
+ process.exit(1);
524
+ }
525
+ }
424
526
  if (provider === "tailscale" && !tailscaleAvailable()) {
425
527
  emit.error({
426
528
  code: "tunnel_tailscale_missing",
@@ -438,10 +540,20 @@ async function up(opts) {
438
540
  vhost,
439
541
  provider,
440
542
  allowedIps: cfg.allowed_ips,
543
+ allowPaths,
441
544
  gateLogPath,
442
- });
545
+ }, launch);
443
546
  await sleep(800);
444
- if (!isProcessAlive(gateProc.pid)) {
547
+ // A host CLI that re-executes itself takes longer to load than a bare script,
548
+ // so wait for the gate to actually listen before the provider forwards to it.
549
+ if (!isProcessAlive(gateProc.pid) || !(await waitForPortBound(gatePort, 15_000))) {
550
+ try {
551
+ process.kill(gateProc.pid);
552
+ }
553
+ catch {
554
+ /* already dead */
555
+ }
556
+ sweepStrays(gatePort, new Set());
445
557
  emit.error({
446
558
  code: "tunnel_gate_failed",
447
559
  message: `Gate failed to start. Check log: ${gateLogPath}`,
@@ -461,6 +573,7 @@ async function up(opts) {
461
573
  path: providerLogPath,
462
574
  command: cloudflaredBin,
463
575
  arguments: ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
576
+ worker: currentWorkerLaunch(),
464
577
  });
465
578
  cfdProc.unref();
466
579
  cloudflaredPid = cfdProc.pid;
@@ -507,16 +620,20 @@ async function up(opts) {
507
620
  target,
508
621
  vhost,
509
622
  gate_port: gatePort,
623
+ allow_paths: allowPaths,
510
624
  tailscale_mode: tailscaleMode,
511
625
  tailscale_path: tailscalePath,
512
626
  tailscale_https_port: tailscaleHttpsPort,
513
627
  };
514
628
  writeState(state);
515
- const stopHint = name === DEFAULT_INSTANCE ? "harn tunnel down" : `harn tunnel down --name ${name}`;
629
+ recordTunnelServer(state);
630
+ const bin = resolveBinName();
631
+ const stopHint = name === DEFAULT_INSTANCE ? `${bin} tunnel down` : `${bin} tunnel down --name ${name}`;
516
632
  emit.text(`\n Instance: ${name}\n`);
517
633
  emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
518
634
  emit.text(` URL: ${url}\n\n`);
519
635
  emit.text(` Forwarding: ${target} (Host: ${vhost})\n`);
636
+ emit.text(` Paths: ${formatPaths(allowPaths)}\n`);
520
637
  emit.text(` Gate port: ${gatePort}\n`);
521
638
  if (provider === "cloudflare") {
522
639
  emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
@@ -528,13 +645,24 @@ async function up(opts) {
528
645
  : " Visibility: tailnet only via Tailscale Serve\n\n");
529
646
  }
530
647
  if (!registered) {
531
- emit.text(` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && harn tunnel up\n\n`);
648
+ emit.text(` ⚠ Edge connection didn't register within 30s (QUIC can wedge on a cold\n start). If the URL 404s or times out, bounce it: ${stopHint} && ${bin} tunnel up\n\n`);
532
649
  }
533
650
  emit.text(` Stop: ${stopHint}\n`);
534
- emit.text(" Status: harn tunnel status\n");
651
+ emit.text(` Status: ${bin} tunnel status\n`);
652
+ }
653
+ /** The server registry lists tunnels beside every other local server. */
654
+ function registryRoot() {
655
+ return resolveCoordRoot() ?? process.cwd();
656
+ }
657
+ function recordTunnelServer(state) {
658
+ const coordRoot = registryRoot();
659
+ tryRegisterServer(tunnelServerInput(state, coordRoot), { coordRoot });
535
660
  }
536
661
  /** Tear down a single instance by name. Returns the number of processes killed. */
537
- function downOne(name, killed) {
662
+ function portHeldByOtherLiveInstance(port, name) {
663
+ return listStates().some((s) => s.name !== name && s.gate_port === port && isProcessAlive(s.gate_pid));
664
+ }
665
+ export function downOne(name, killed) {
538
666
  const before = killed.size;
539
667
  const state = readState(name);
540
668
  if (state) {
@@ -555,9 +683,14 @@ function downOne(name, killed) {
555
683
  }
556
684
  }
557
685
  // Fallback: sweep orphans on this instance's gate port, even when state was
558
- // lost; they'd otherwise squat on the port and break the next `up`.
559
- sweepStrays(state?.gate_port ?? DEFAULT_GATE_PORT, killed);
686
+ // lost; they'd otherwise squat on the port and break the next `up`. Skip it
687
+ // when another live instance now owns that port: a stale record's port is
688
+ // reused after a reboot, and sweeping it would kill the live tunnel there.
689
+ const port = state?.gate_port ?? DEFAULT_GATE_PORT;
690
+ if (!portHeldByOtherLiveInstance(port, name))
691
+ sweepStrays(port, killed);
560
692
  clearState(name);
693
+ unregisterServer(tunnelServerId(name), { coordRoot: registryRoot() });
561
694
  return killed.size - before;
562
695
  }
563
696
  function down(opts) {
@@ -579,7 +712,7 @@ function down(opts) {
579
712
  if (name === DEFAULT_INSTANCE && !readState(DEFAULT_INSTANCE)) {
580
713
  const others = listStates();
581
714
  if (others.length > 0) {
582
- emit.text(`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`harn tunnel down --name <name>\` or \`harn tunnel down --all\`.\n`);
715
+ emit.text(`No default tunnel running. Other tunnels up: ${others.map((s) => s.name).join(", ")}.\nUse \`${resolveBinName()} tunnel down --name <name>\` or \`${resolveBinName()} tunnel down --all\`.\n`);
583
716
  return;
584
717
  }
585
718
  }
@@ -592,21 +725,21 @@ function down(opts) {
592
725
  async function waitForPortFree(port, timeoutMs) {
593
726
  const deadline = Date.now() + timeoutMs;
594
727
  while (Date.now() < deadline) {
595
- if (!listeningPorts().has(port))
728
+ if (!(await portListening(port)))
596
729
  return true;
597
730
  await sleep(100);
598
731
  }
599
- return !listeningPorts().has(port);
732
+ return !(await portListening(port));
600
733
  }
601
734
  /** Poll until `port` has a LISTEN socket (or the deadline passes). */
602
735
  async function waitForPortBound(port, timeoutMs) {
603
736
  const deadline = Date.now() + timeoutMs;
604
737
  while (Date.now() < deadline) {
605
- if (listeningPorts().has(port))
738
+ if (await portListening(port))
606
739
  return true;
607
740
  await sleep(100);
608
741
  }
609
- return listeningPorts().has(port);
742
+ return portListening(port);
610
743
  }
611
744
  /**
612
745
  * Restart ONE instance's gate in place, deliberately leaving the provider
@@ -630,8 +763,20 @@ export async function reloadOne(state) {
630
763
  `Reloading the gate can't bring it back — run \`${bin} tunnel up --name ${name}\`.`,
631
764
  };
632
765
  }
633
- if (!bunAvailable()) {
634
- return { ok: false, message: `[${name}] bun is not on PATH, so the gate can't be respawned.` };
766
+ const launch = currentGateLaunch();
767
+ if (!launch) {
768
+ return {
769
+ ok: false,
770
+ message: `[${name}] Bun is not available, so the gate can't be respawned.`,
771
+ };
772
+ }
773
+ if (state.allow_paths.length === 0) {
774
+ return {
775
+ ok: false,
776
+ message: `[${name}] has no recorded path scope, so a reloaded gate would refuse every request. ` +
777
+ `Restart it with \`${bin} tunnel down --name ${name}\` and ` +
778
+ `\`${bin} tunnel up --name ${name} --allow-path <prefix>\`.`,
779
+ };
635
780
  }
636
781
  // Kill ONLY the gate. sweepStrays() is deliberately avoided here: it also
637
782
  // matches `--url http://localhost:<port>`, which is the provider process we
@@ -646,7 +791,7 @@ export async function reloadOne(state) {
646
791
  /* race: already gone */
647
792
  }
648
793
  }
649
- killByPattern(`gate\\.ts.*--port ${gatePort}( |$)`, killed);
794
+ killByPattern(gatePattern(gatePort), killed);
650
795
  if (!(await waitForPortFree(gatePort, 5_000))) {
651
796
  return {
652
797
  ok: false,
@@ -662,13 +807,14 @@ export async function reloadOne(state) {
662
807
  vhost: state.vhost,
663
808
  provider: state.provider,
664
809
  allowedIps: cfg.allowed_ips,
810
+ allowPaths: state.allow_paths,
665
811
  gateLogPath: tunnelLogDestinations(name, state.provider).gate,
666
- });
812
+ }, launch);
667
813
  await sleep(800);
668
814
  const pid = gateProc.pid;
669
815
  if (typeof pid !== "number" ||
670
816
  !isProcessAlive(pid) ||
671
- !(await waitForPortBound(gatePort, 5_000))) {
817
+ !(await waitForPortBound(gatePort, 15_000))) {
672
818
  return {
673
819
  ok: false,
674
820
  message: `[${name}] the gate did not come back on port ${gatePort}. ` +
@@ -676,6 +822,7 @@ export async function reloadOne(state) {
676
822
  };
677
823
  }
678
824
  writeState({ ...state, gate_pid: pid });
825
+ recordTunnelServer({ ...state, gate_pid: pid });
679
826
  const allowNote = state.provider === "cloudflare" ? ` Allowlist: ${cfg.allowed_ips.length} IP(s).` : "";
680
827
  return { ok: true, message: `[${name}] gate reloaded, URL unchanged: ${state.url}.${allowNote}` };
681
828
  }
@@ -745,6 +892,7 @@ function statusDetail(state) {
745
892
  emit.text(` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`);
746
893
  emit.text(` URL: ${state.url}\n`);
747
894
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
895
+ emit.text(` Paths: ${formatPaths(state.allow_paths)}\n`);
748
896
  emit.text(` Gate port: ${state.gate_port}\n`);
749
897
  if (state.provider === "cloudflare") {
750
898
  emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
@@ -786,6 +934,7 @@ function status(opts) {
786
934
  state: instanceState(s),
787
935
  url: s.url,
788
936
  fwd: `${s.target} (${s.vhost})`,
937
+ paths: formatPaths(s.allow_paths),
789
938
  port: String(s.gate_port),
790
939
  up: fmtUptime(s.started_at),
791
940
  }));
@@ -795,12 +944,13 @@ function status(opts) {
795
944
  state: 5,
796
945
  url: Math.max(3, ...rows.map((r) => r.url.length)),
797
946
  fwd: Math.max(10, ...rows.map((r) => r.fwd.length)),
947
+ paths: Math.max(5, ...rows.map((r) => r.paths.length)),
798
948
  port: 4,
799
949
  };
800
950
  const pad = (s, n) => s.padEnd(n);
801
- emit.text(`${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`);
951
+ emit.text(`${pad("NAME", w.name)} ${pad("PROVIDER", w.provider)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PATHS", w.paths)} ${pad("PORT", w.port)} UPTIME\n`);
802
952
  for (const r of rows) {
803
- emit.text(`${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`);
953
+ emit.text(`${pad(r.name, w.name)} ${pad(r.provider, w.provider)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.paths, w.paths)} ${pad(r.port, w.port)} ${r.up}\n`);
804
954
  }
805
955
  }
806
956
  function logs(opts) {
@@ -824,9 +974,13 @@ function allowList() {
824
974
  emit.text("(empty)\n");
825
975
  return;
826
976
  }
827
- for (const ip of cfg.allowed_ips)
828
- emit.text(`${ip}\n`);
977
+ const automatic = cfg.auto_allowed ?? [];
978
+ for (const ip of cfg.allowed_ips) {
979
+ emit.text(`${ip}${sameEntryIn(automatic, ip) ? " (automatic)" : ""}\n`);
980
+ }
829
981
  }
982
+ const canonicalEntry = (entry) => normalizeAllowEntry(entry) ?? entry;
983
+ const sameEntryIn = (list, entry) => list.some((e) => canonicalEntry(e) === canonicalEntry(entry));
830
984
  /**
831
985
  * Trailer for `allow add`/`allow rm`. Each gate snapshots the allowlist at
832
986
  * spawn, so a config edit alone changes nothing until the gates restart.
@@ -842,29 +996,122 @@ function allowChangedHint() {
842
996
  `(${live.map((s) => s.name).join(", ")}):\n` +
843
997
  ` ${resolveBinName()} tunnel reload --all\n`);
844
998
  }
845
- function allowAdd(ip) {
999
+ async function allowAdd(entry, opts = {}) {
1000
+ if (opts.current) {
1001
+ if (entry) {
1002
+ emit.error({
1003
+ code: "tunnel_allow_current_with_entry",
1004
+ message: "Pass either an address or --current, not both.",
1005
+ });
1006
+ process.exit(1);
1007
+ }
1008
+ const result = await refreshCurrentAddress({ emit, reload: true });
1009
+ if (!result.ok)
1010
+ process.exit(1);
1011
+ return;
1012
+ }
1013
+ if (!entry) {
1014
+ emit.error({
1015
+ code: "tunnel_allow_entry_missing",
1016
+ message: `Name an address or range to allow, or use --current: ${resolveBinName()} tunnel allow add <ip|cidr>`,
1017
+ });
1018
+ process.exit(1);
1019
+ }
1020
+ const canonical = normalizeAllowEntry(entry);
1021
+ if (!canonical) {
1022
+ emit.error({
1023
+ code: "tunnel_allow_entry_invalid",
1024
+ message: `"${entry}" is not an IP address or CIDR range. ` +
1025
+ "Examples: 203.0.113.8, 203.0.113.0/24, 2601:db8:1:2::/64.",
1026
+ });
1027
+ process.exit(1);
1028
+ }
846
1029
  const cfg = readConfig();
847
- if (cfg.allowed_ips.includes(ip)) {
848
- emit.text(`${ip} already in allowlist.\n`);
1030
+ if (sameEntryIn(cfg.allowed_ips, canonical)) {
1031
+ // Adding by hand an entry the refresh owns adopts it: later refreshes keep it.
1032
+ if (cfg.auto_allowed && sameEntryIn(cfg.auto_allowed, canonical)) {
1033
+ cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonical);
1034
+ writeConfig(cfg);
1035
+ emit.text(`${canonical} is now a manual entry; automatic refreshes will keep it.\n`);
1036
+ return;
1037
+ }
1038
+ emit.text(`${canonical} already in allowlist.\n`);
849
1039
  return;
850
1040
  }
851
- cfg.allowed_ips.push(ip);
1041
+ cfg.allowed_ips.push(canonical);
852
1042
  writeConfig(cfg);
853
- emit.text(`Added ${ip}.\n`);
1043
+ emit.text(`Added ${canonical}.\n`);
854
1044
  allowChangedHint();
855
1045
  }
856
- function allowRm(ip) {
1046
+ function allowRm(entry) {
857
1047
  const cfg = readConfig();
858
- const idx = cfg.allowed_ips.indexOf(ip);
1048
+ const wanted = canonicalEntry(entry);
1049
+ const idx = cfg.allowed_ips.findIndex((e) => e === entry || canonicalEntry(e) === wanted);
859
1050
  if (idx === -1) {
860
- emit.text(`${ip} not in allowlist.\n`);
1051
+ emit.text(`${entry} not in allowlist.\n`);
861
1052
  return;
862
1053
  }
863
- cfg.allowed_ips.splice(idx, 1);
1054
+ const [removed] = cfg.allowed_ips.splice(idx, 1);
1055
+ if (cfg.auto_allowed) {
1056
+ cfg.auto_allowed = cfg.auto_allowed.filter((e) => canonicalEntry(e) !== canonicalEntry(removed));
1057
+ }
864
1058
  writeConfig(cfg);
865
- emit.text(`Removed ${ip}.\n`);
1059
+ emit.text(`Removed ${removed}.\n`);
866
1060
  allowChangedHint();
867
1061
  }
1062
+ /**
1063
+ * `tunnel allow add --current`: allow this machine's own public addresses, as
1064
+ * Cloudflare sees them, and drop the ones allowed by the previous refresh.
1065
+ * Exported so a host CLI can run it before `tunnel up` and from its own sync.
1066
+ */
1067
+ export async function refreshCurrentAddress(options) {
1068
+ if (options.emit)
1069
+ emit = options.emit;
1070
+ const detected = await (options.detect ?? detectPublicAddresses)(options.detectOptions);
1071
+ const cfg = readConfig();
1072
+ const plan = planCurrentAddresses(cfg, detected);
1073
+ const noAddress = !detected.v4 && !detected.v6;
1074
+ // Quiet runs (a host's sync) speak only when something changed or nothing
1075
+ // could be detected; "no IPv6 on this network" is not news every time.
1076
+ if (!options.quiet || noAddress || plan.changed) {
1077
+ for (const warning of plan.warnings)
1078
+ emit.text(` ⚠ ${warning}\n`);
1079
+ }
1080
+ const result = {
1081
+ ok: true,
1082
+ changed: plan.changed,
1083
+ added: plan.added,
1084
+ removed: plan.removed,
1085
+ warnings: plan.warnings,
1086
+ };
1087
+ if (noAddress)
1088
+ return result;
1089
+ writeConfig(plan.config);
1090
+ if (!options.quiet || plan.changed) {
1091
+ const found = [detected.v4, detected.v6].filter(Boolean).join(", ");
1092
+ emit.text(`This machine reaches Cloudflare as ${found}.\n`);
1093
+ emit.text(plan.changed
1094
+ ? `Allowlist updated${plan.added.length ? `: added ${plan.added.join(", ")}` : ""}${plan.removed.length ? `; removed ${plan.removed.join(", ")}` : ""}.\n`
1095
+ : "Allowlist already covers this machine.\n");
1096
+ }
1097
+ if (!plan.changed || !options.reload)
1098
+ return result;
1099
+ const live = listStates().filter((s) => s.provider === "cloudflare" && providerIsAlive(s));
1100
+ for (const s of live) {
1101
+ const r = await reloadOne(s);
1102
+ emit.text(`${r.ok ? "ok " : "FAIL"} ${r.message}\n`);
1103
+ if (!r.ok)
1104
+ result.ok = false;
1105
+ }
1106
+ return result;
1107
+ }
1108
+ /** Hidden task: run the gate worker in this process (the self-launch form of the gate). */
1109
+ async function runGateInProcess() {
1110
+ await import("../lib/tunnel/gate.js");
1111
+ // The gate's server keeps the process alive. Never resolve, so a host's
1112
+ // post-command exit or output finalization cannot end the worker.
1113
+ await new Promise(() => { });
1114
+ }
868
1115
  let emit;
869
1116
  let context;
870
1117
  export function registerTunnelCommand(program, emitParam, contextParam) {
@@ -886,6 +1133,8 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
886
1133
  .option("--visibility <visibility>", "Tailscale only: tailnet (Serve, default) or public (Funnel)", "tailnet")
887
1134
  .option("--path <path>", "Tailscale only: URL path mount (default: / for default, /<name> for named instances)")
888
1135
  .option("--https-port <port>", "Tailscale only: HTTPS listen port (default: 443)", "443")
1136
+ .option("--allow-path <prefix>", "URL path prefix the tunnel shares (repeatable, required). Every other path is refused; " +
1137
+ "pass / to publish the whole upstream.", collectPath, [])
889
1138
  .action(up);
890
1139
  cmd
891
1140
  .command("down")
@@ -914,9 +1163,29 @@ export function registerTunnelCommand(program, emitParam, contextParam) {
914
1163
  .action(logs);
915
1164
  const allow = cmd
916
1165
  .command("allow")
917
- .description("Manage the Cloudflare CF-Connecting-IP allowlist")
1166
+ .description("Manage the Cloudflare CF-Connecting-IP allowlist (addresses and CIDR ranges, IPv4 and IPv6)")
918
1167
  .action(allowList);
919
- allow.command("add <ip>").description("Add an IP to the allowlist").action(allowAdd);
920
- allow.command("rm <ip>").description("Remove an IP from the allowlist").action(allowRm);
921
- allow.command("list").description("List allowed IPs (default action)").action(allowList);
1168
+ allow
1169
+ .command("add [entry]")
1170
+ .description("Add an IP address or CIDR range (203.0.113.0/24, 2601:db8:1:2::/64), or --current for this machine")
1171
+ .option("--current", "allow this machine's public addresses as Cloudflare sees them (IPv4 /32 and IPv6 /64), " +
1172
+ "replace the ones the previous --current added, and reload running gates")
1173
+ .action(allowAdd);
1174
+ allow
1175
+ .command("rm <entry>")
1176
+ .description("Remove an address or range from the allowlist")
1177
+ .action(allowRm);
1178
+ allow.command("list").description("List allowed entries (default action)").action(allowList);
1179
+ cmd
1180
+ .command("log-worker <specification>", { hidden: true })
1181
+ .description("Internal: run the output-rotating wrapper for one tunnel process")
1182
+ .action(async (specification) => {
1183
+ await runProcessLogWorker(specification);
1184
+ });
1185
+ cmd
1186
+ .command("gate", { hidden: true })
1187
+ .description("Internal: run the gate worker in this process")
1188
+ .option("--name <name>", "instance name")
1189
+ .option("--port <port>", "port to listen on")
1190
+ .action(runGateInProcess);
922
1191
  }