harnery 0.6.0 → 0.7.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.
Files changed (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -12,30 +12,33 @@ import { existsSync, openSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
13
 
14
14
  import {
15
- cfdLogFile,
16
15
  clearState,
17
16
  DEFAULT_INSTANCE,
18
17
  ensureCloudflared,
19
18
  gateLogFile,
20
19
  isProcessAlive,
21
20
  listStates,
21
+ providerLogFile,
22
22
  readConfig,
23
23
  readState,
24
+ type TailscaleMode,
25
+ type TunnelProvider,
24
26
  type TunnelState,
25
27
  writeConfig,
26
28
  writeState,
27
29
  } from "../lib/tunnel/state.ts";
28
30
 
29
31
  /**
30
- * `tunnel`: IP-gated Cloudflare quick tunnel in front of a local upstream.
32
+ * `tunnel`: provider-backed tunnel in front of a local upstream.
31
33
  *
32
- * Two-process design: a Bun reverse-proxy worker (lib/tunnel/gate.ts) checks
33
- * the Cloudflare-set `CF-Connecting-IP` header against an allowlist and
34
- * rewrites Host for the upstream; cloudflared --url then exposes the gate
35
- * as a random `<words>.trycloudflare.com` hostname.
34
+ * A Bun reverse-proxy worker (lib/tunnel/gate.ts) rewrites Host for the
35
+ * upstream. Cloudflare quick tunnels add an IP allowlist at the gate via the
36
+ * Cloudflare-set `CF-Connecting-IP` header; Tailscale Serve/Funnel exposes the
37
+ * same gate through tailscaled, with Tailscale owning the access boundary.
36
38
  *
37
39
  * State + config persisted under `.cache/tunnel/`. cloudflared auto-installs
38
- * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`).
40
+ * to ~/.local/bin/ on first run (Linux only; macOS users `brew install`);
41
+ * Tailscale requires an installed/authenticated `tailscale` CLI.
39
42
  */
40
43
 
41
44
  const DEFAULT_TARGET = "127.0.0.1:8001";
@@ -45,9 +48,13 @@ const MAX_GATE_PORT = DEFAULT_GATE_PORT + 99; // auto-allocation scan ceiling
45
48
 
46
49
  interface UpOpts {
47
50
  name?: string;
51
+ provider?: string;
48
52
  target?: string;
49
53
  vhost?: string;
50
54
  gatePort?: string;
55
+ visibility?: string;
56
+ path?: string;
57
+ httpsPort?: string;
51
58
  }
52
59
 
53
60
  interface DownOpts {
@@ -64,6 +71,7 @@ interface LogsOpts {
64
71
  follow?: boolean;
65
72
  gate?: boolean;
66
73
  cloudflared?: boolean;
74
+ provider?: boolean;
67
75
  }
68
76
 
69
77
  /**
@@ -83,6 +91,55 @@ function resolveName(raw: string | undefined): string {
83
91
  return name;
84
92
  }
85
93
 
94
+ function resolveProvider(raw: string | undefined): TunnelProvider {
95
+ const provider = (raw ?? "cloudflare").trim().toLowerCase();
96
+ if (provider === "cloudflare" || provider === "cf") return "cloudflare";
97
+ if (provider === "tailscale" || provider === "ts") return "tailscale";
98
+ emit.error({
99
+ code: "tunnel_bad_provider",
100
+ message: `Invalid provider "${raw}". Use cloudflare or tailscale.`,
101
+ });
102
+ process.exit(1);
103
+ }
104
+
105
+ function resolveTailscaleMode(raw: string | undefined): TailscaleMode {
106
+ const visibility = (raw ?? "tailnet").trim().toLowerCase();
107
+ if (visibility === "tailnet" || visibility === "serve") return "serve";
108
+ if (visibility === "public" || visibility === "internet" || visibility === "funnel") {
109
+ return "funnel";
110
+ }
111
+ emit.error({
112
+ code: "tunnel_bad_visibility",
113
+ message: `Invalid Tailscale visibility "${raw}". Use tailnet or public.`,
114
+ });
115
+ process.exit(1);
116
+ }
117
+
118
+ function resolveTailscalePath(raw: string | undefined, name: string): string {
119
+ const fallback = name === DEFAULT_INSTANCE ? "/" : `/${name}`;
120
+ const path = (raw ?? fallback).trim();
121
+ if (!path.startsWith("/")) {
122
+ emit.error({
123
+ code: "tunnel_bad_path",
124
+ message: `Tailscale path "${path}" must start with /.`,
125
+ });
126
+ process.exit(1);
127
+ }
128
+ return path === "" ? "/" : path;
129
+ }
130
+
131
+ function resolveHttpsPort(raw: string | undefined): number {
132
+ const port = raw === undefined ? 443 : Number(raw);
133
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
134
+ emit.error({
135
+ code: "tunnel_bad_https_port",
136
+ message: `Invalid HTTPS port "${raw}". Use a number from 1 to 65535.`,
137
+ });
138
+ process.exit(1);
139
+ }
140
+ return port;
141
+ }
142
+
86
143
  function gateScriptPath(): string {
87
144
  return resolve(import.meta.dirname, "..", "lib", "tunnel", "gate.ts");
88
145
  }
@@ -98,6 +155,10 @@ function bunAvailable(): boolean {
98
155
  return spawnSync("bun", ["--version"], { stdio: "ignore" }).status === 0;
99
156
  }
100
157
 
158
+ function tailscaleAvailable(): boolean {
159
+ return spawnSync("tailscale", ["version"], { stdio: "ignore" }).status === 0;
160
+ }
161
+
101
162
  function sleep(ms: number): Promise<void> {
102
163
  return new Promise((r) => setTimeout(r, ms));
103
164
  }
@@ -226,6 +287,95 @@ async function waitForReady(
226
287
  return { url, registered: false };
227
288
  }
228
289
 
290
+ function tailscaleDnsName(): string {
291
+ const r = spawnSync("tailscale", ["status", "--json"], { encoding: "utf-8" });
292
+ if (r.status !== 0) {
293
+ emit.error({
294
+ code: "tunnel_tailscale_status_failed",
295
+ message:
296
+ "Failed to read `tailscale status --json`. Install Tailscale and connect this machine to a tailnet first.",
297
+ });
298
+ process.exit(1);
299
+ }
300
+ try {
301
+ const parsed = JSON.parse(r.stdout) as { Self?: { DNSName?: string } };
302
+ const dns = parsed.Self?.DNSName?.replace(/\.$/, "");
303
+ if (dns) return dns;
304
+ } catch {
305
+ /* handled below */
306
+ }
307
+ emit.error({
308
+ code: "tunnel_tailscale_no_dns",
309
+ message:
310
+ "Tailscale status did not report a MagicDNS name. Enable MagicDNS for the tailnet, then retry.",
311
+ });
312
+ process.exit(1);
313
+ }
314
+
315
+ function tailscaleUrl(path: string, httpsPort: number): string {
316
+ const base = `https://${tailscaleDnsName()}${httpsPort === 443 ? "" : `:${httpsPort}`}`;
317
+ return path === "/" ? `${base}/` : `${base}${path}`;
318
+ }
319
+
320
+ function tailscaleCommand(mode: TailscaleMode): "serve" | "funnel" {
321
+ return mode === "funnel" ? "funnel" : "serve";
322
+ }
323
+
324
+ function runTailscaleShare(
325
+ mode: TailscaleMode,
326
+ targetUrl: string,
327
+ path: string,
328
+ httpsPort: number,
329
+ logPath: string,
330
+ ): void {
331
+ const fd = openSync(logPath, "a");
332
+ const args = [
333
+ tailscaleCommand(mode),
334
+ "--bg",
335
+ "--yes",
336
+ `--https=${httpsPort}`,
337
+ `--set-path=${path}`,
338
+ targetUrl,
339
+ ];
340
+ const r = spawnSync("tailscale", args, {
341
+ stdio: ["ignore", fd, fd],
342
+ });
343
+ if (r.status !== 0) {
344
+ emit.error({
345
+ code: "tunnel_tailscale_failed",
346
+ message: `tailscale ${tailscaleCommand(mode)} failed. Check log: ${logPath}`,
347
+ });
348
+ process.exit(r.status ?? 1);
349
+ }
350
+ }
351
+
352
+ function stopTailscaleShare(state: TunnelState): boolean {
353
+ if (state.provider !== "tailscale" || !state.tailscale_mode) return true;
354
+ const cmd = tailscaleCommand(state.tailscale_mode);
355
+ const logPath = cachePath("tunnel", providerLogFile(state.name, "tailscale"));
356
+ const fd = openSync(logPath, "a");
357
+ const args = [
358
+ cmd,
359
+ `--https=${state.tailscale_https_port ?? 443}`,
360
+ `--set-path=${state.tailscale_path ?? "/"}`,
361
+ "off",
362
+ ];
363
+ const r = spawnSync("tailscale", args, { stdio: ["ignore", fd, fd] });
364
+ if (r.status !== 0) {
365
+ // The gate is torn down and its port is freed for reuse regardless, so a
366
+ // surviving serve/funnel mapping could later re-expose whatever next binds
367
+ // that port. Surface it loudly so the operator can clear it by hand.
368
+ emit.text(
369
+ `\n ⚠ Failed to remove the Tailscale ${cmd} mapping (path ${state.tailscale_path ?? "/"}).\n` +
370
+ ` It may still be exposing this machine. Clear it with:\n` +
371
+ ` tailscale ${cmd} --https=${state.tailscale_https_port ?? 443} --set-path=${state.tailscale_path ?? "/"} off\n` +
372
+ ` Log: ${logPath}\n\n`,
373
+ );
374
+ return false;
375
+ }
376
+ return true;
377
+ }
378
+
229
379
  /** Resolve the context-supplied default vhost (literal or lazy resolver). */
230
380
  function contextVhost(): string | null {
231
381
  const v = context?.tunnelDefaultVhost;
@@ -233,6 +383,16 @@ function contextVhost(): string | null {
233
383
  return resolved ?? null;
234
384
  }
235
385
 
386
+ function providerIsAlive(state: TunnelState): boolean {
387
+ if (state.provider === "tailscale") return true;
388
+ const pid = state.cloudflared_pid ?? state.provider_pid;
389
+ return typeof pid === "number" && isProcessAlive(pid);
390
+ }
391
+
392
+ function tunnelIsAlive(state: TunnelState): boolean {
393
+ return isProcessAlive(state.gate_pid) && providerIsAlive(state);
394
+ }
395
+
236
396
  async function up(opts: UpOpts): Promise<void> {
237
397
  if (!bunAvailable()) {
238
398
  emit.error({
@@ -244,18 +404,33 @@ async function up(opts: UpOpts): Promise<void> {
244
404
  process.exit(1);
245
405
  }
246
406
  const name = resolveName(opts.name);
407
+ const provider = resolveProvider(opts.provider);
247
408
  const target = opts.target ?? DEFAULT_TARGET;
248
409
  // Precedence: explicit --vhost > the consumer's configured default (via
249
410
  // context.tunnelDefaultVhost) > "localhost".
250
411
  const vhost = opts.vhost ?? contextVhost() ?? DEFAULT_VHOST;
412
+ const tailscaleMode =
413
+ provider === "tailscale" ? resolveTailscaleMode(opts.visibility) : undefined;
414
+ const tailscalePath =
415
+ provider === "tailscale" ? resolveTailscalePath(opts.path, name) : undefined;
416
+ const tailscaleHttpsPort =
417
+ provider === "tailscale" ? resolveHttpsPort(opts.httpsPort) : undefined;
251
418
 
252
419
  const existing = readState(name);
253
- if (existing && isProcessAlive(existing.gate_pid) && isProcessAlive(existing.cloudflared_pid)) {
420
+ if (existing && tunnelIsAlive(existing)) {
421
+ if (existing.provider !== provider) {
422
+ emit.error({
423
+ code: "tunnel_instance_in_use",
424
+ message: `Tunnel [${name}] is already up with provider ${existing.provider}. Stop it before starting ${provider}.`,
425
+ });
426
+ process.exit(1);
427
+ }
254
428
  emit.text(`Already up [${name}]: ${existing.url}\n`);
429
+ emit.text(` Provider: ${existing.provider}\n`);
255
430
  emit.text(` Forwarding: ${existing.target} (Host: ${existing.vhost})\n`);
256
431
  return;
257
432
  }
258
- if (existing) clearState(name);
433
+ if (existing) downOne(name, new Set());
259
434
 
260
435
  // Allocate the gate port (after clearing dead state so its old port frees up
261
436
  // for reuse). Explicit --gate-port is validated; otherwise auto-scan.
@@ -266,7 +441,7 @@ async function up(opts: UpOpts): Promise<void> {
266
441
  if (sweepStrays(gatePort, new Set())) await sleep(500);
267
442
 
268
443
  const cfg = readConfig();
269
- if (cfg.allowed_ips.length === 0) {
444
+ if (provider === "cloudflare" && cfg.allowed_ips.length === 0) {
270
445
  emit.error({
271
446
  code: "tunnel_allowlist_empty",
272
447
  message: "Allowlist is empty; refusing to start. Add an IP first: harn tunnel allow add <ip>",
@@ -274,12 +449,19 @@ async function up(opts: UpOpts): Promise<void> {
274
449
  process.exit(1);
275
450
  }
276
451
 
277
- const cloudflaredBin = ensureCloudflared();
452
+ const cloudflaredBin = provider === "cloudflare" ? ensureCloudflared() : null;
453
+ if (provider === "tailscale" && !tailscaleAvailable()) {
454
+ emit.error({
455
+ code: "tunnel_tailscale_missing",
456
+ message: "tailscale CLI not found or unavailable. Install Tailscale and sign in, then retry.",
457
+ });
458
+ process.exit(1);
459
+ }
278
460
 
279
461
  const gateLogPath = cachePath("tunnel", gateLogFile(name));
280
- const cfdLogPath = cachePath("tunnel", cfdLogFile(name));
462
+ const providerLogPath = cachePath("tunnel", providerLogFile(name, provider));
281
463
  writeFileSync(gateLogPath, "");
282
- writeFileSync(cfdLogPath, "");
464
+ writeFileSync(providerLogPath, "");
283
465
 
284
466
  const gateFd = openSync(gateLogPath, "a");
285
467
  // `--name`/`--port` on argv mirror the env vars; they're what makes the gate
@@ -293,6 +475,8 @@ async function up(opts: UpOpts): Promise<void> {
293
475
  env: {
294
476
  ...process.env,
295
477
  HARNERY_TUNNEL_ALLOW: cfg.allowed_ips.join(","),
478
+ HARNERY_TUNNEL_ACCESS:
479
+ provider === "cloudflare" ? "cloudflare-allowlist" : "trusted-local-proxy",
296
480
  HARNERY_TUNNEL_TARGET: target,
297
481
  HARNERY_TUNNEL_VHOST: vhost,
298
482
  HARNERY_TUNNEL_PORT: String(gatePort),
@@ -311,60 +495,98 @@ async function up(opts: UpOpts): Promise<void> {
311
495
  process.exit(1);
312
496
  }
313
497
 
314
- const cfdFd = openSync(cfdLogPath, "a");
315
- // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
316
- // constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
317
- // is restricted): the URL prints but the edge never registers. HTTP/2 is
318
- // marginally higher-latency but registers reliably, which is what a dev
319
- // tunnel needs.
320
- const cfdProc = spawn(
321
- cloudflaredBin,
322
- ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
323
- {
324
- detached: true,
325
- stdio: ["ignore", cfdFd, cfdFd],
326
- },
327
- );
328
- cfdProc.unref();
329
-
330
- const { url, registered } = await waitForReady(cfdLogPath, 30_000);
331
- if (!url) {
332
- try {
333
- process.kill(gateProc.pid!);
334
- } catch {
335
- /* already dead */
336
- }
337
- try {
338
- process.kill(cfdProc.pid!);
339
- } catch {
340
- /* already dead */
498
+ let url: string;
499
+ let registered = true;
500
+ let cloudflaredPid: number | undefined;
501
+
502
+ if (provider === "cloudflare") {
503
+ const cfdFd = openSync(providerLogPath, "a");
504
+ // Force HTTP/2 transport. The default QUIC transport wedges at precheck on
505
+ // constrained hosts (e.g. WSL, where UDP receive buffers can't grow and ICMP
506
+ // is restricted): the URL prints but the edge never registers. HTTP/2 is
507
+ // marginally higher-latency but registers reliably, which is what a dev
508
+ // tunnel needs.
509
+ const cfdProc = spawn(
510
+ cloudflaredBin!,
511
+ ["tunnel", "--protocol", "http2", "--url", `http://localhost:${gatePort}`],
512
+ {
513
+ detached: true,
514
+ stdio: ["ignore", cfdFd, cfdFd],
515
+ },
516
+ );
517
+ cfdProc.unref();
518
+ cloudflaredPid = cfdProc.pid!;
519
+
520
+ const ready = await waitForReady(providerLogPath, 30_000);
521
+ registered = ready.registered;
522
+ if (!ready.url) {
523
+ try {
524
+ process.kill(gateProc.pid!);
525
+ } catch {
526
+ /* already dead */
527
+ }
528
+ try {
529
+ process.kill(cloudflaredPid);
530
+ } catch {
531
+ /* already dead */
532
+ }
533
+ emit.error({
534
+ code: "tunnel_url_timeout",
535
+ message: `Failed to obtain tunnel URL within 30s. Check log: ${providerLogPath}`,
536
+ });
537
+ process.exit(1);
341
538
  }
342
- emit.error({
343
- code: "tunnel_url_timeout",
344
- message: `Failed to obtain tunnel URL within 30s. Check log: ${cfdLogPath}`,
345
- });
346
- process.exit(1);
539
+ url = ready.url;
540
+ } else {
541
+ // Resolve the public URL (MagicDNS name) BEFORE starting the share.
542
+ // tailscaleUrl -> tailscaleDnsName() hard-exits when MagicDNS is
543
+ // unavailable; doing it first means we fail cleanly rather than leaving a
544
+ // live serve/funnel exposure with no state file — which `down`/`status`/
545
+ // `heal` would then be unable to see or clean up.
546
+ url = tailscaleUrl(tailscalePath!, tailscaleHttpsPort!);
547
+ const gateTarget = `http://127.0.0.1:${gatePort}`;
548
+ runTailscaleShare(
549
+ tailscaleMode!,
550
+ gateTarget,
551
+ tailscalePath!,
552
+ tailscaleHttpsPort!,
553
+ providerLogPath,
554
+ );
347
555
  }
348
556
 
349
557
  const state: TunnelState = {
350
558
  name,
559
+ provider,
351
560
  url,
352
561
  gate_pid: gateProc.pid!,
353
- cloudflared_pid: cfdProc.pid!,
562
+ cloudflared_pid: cloudflaredPid,
354
563
  started_at: new Date().toISOString(),
355
564
  target,
356
565
  vhost,
357
566
  gate_port: gatePort,
567
+ tailscale_mode: tailscaleMode,
568
+ tailscale_path: tailscalePath,
569
+ tailscale_https_port: tailscaleHttpsPort,
358
570
  };
359
571
  writeState(state);
360
572
 
361
573
  const stopHint =
362
574
  name === DEFAULT_INSTANCE ? "harn tunnel down" : `harn tunnel down --name ${name}`;
363
575
  emit.text(`\n Instance: ${name}\n`);
576
+ emit.text(` Provider: ${provider}${tailscaleMode ? ` (${tailscaleMode})` : ""}\n`);
364
577
  emit.text(` URL: ${url}\n\n`);
365
578
  emit.text(` Forwarding: ${target} (Host: ${vhost})\n`);
366
579
  emit.text(` Gate port: ${gatePort}\n`);
367
- emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
580
+ if (provider === "cloudflare") {
581
+ emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n\n`);
582
+ } else {
583
+ emit.text(` Tailscale path: ${tailscalePath}\n`);
584
+ emit.text(
585
+ tailscaleMode === "funnel"
586
+ ? " Visibility: public internet via Tailscale Funnel\n\n"
587
+ : " Visibility: tailnet only via Tailscale Serve\n\n",
588
+ );
589
+ }
368
590
  if (!registered) {
369
591
  emit.text(
370
592
  ` ⚠ 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`,
@@ -379,7 +601,9 @@ function downOne(name: string, killed: Set<number>): number {
379
601
  const before = killed.size;
380
602
  const state = readState(name);
381
603
  if (state) {
382
- for (const pid of [state.gate_pid, state.cloudflared_pid]) {
604
+ if (state.provider === "tailscale") stopTailscaleShare(state);
605
+ for (const pid of [state.gate_pid, state.cloudflared_pid, state.provider_pid]) {
606
+ if (typeof pid !== "number") continue;
383
607
  if (isProcessAlive(pid)) {
384
608
  try {
385
609
  process.kill(pid);
@@ -435,7 +659,7 @@ function down(opts: DownOpts): void {
435
659
  }
436
660
 
437
661
  function instanceState(state: TunnelState): "up" | "stale" {
438
- return isProcessAlive(state.gate_pid) && isProcessAlive(state.cloudflared_pid) ? "up" : "stale";
662
+ return tunnelIsAlive(state) ? "up" : "stale";
439
663
  }
440
664
 
441
665
  function fmtUptime(startedAt: string): string {
@@ -449,15 +673,25 @@ function fmtUptime(startedAt: string): string {
449
673
  /** Detailed single-instance block (the pre-multi-instance format). */
450
674
  function statusDetail(state: TunnelState): void {
451
675
  const gateAlive = isProcessAlive(state.gate_pid);
452
- const cfdAlive = isProcessAlive(state.cloudflared_pid);
676
+ const providerAlive = providerIsAlive(state);
677
+ const providerPid = state.cloudflared_pid ?? state.provider_pid;
453
678
  const cfg = readConfig();
454
- emit.text(`${gateAlive && cfdAlive ? "up" : "stale"} [${state.name}]\n`);
679
+ emit.text(`${gateAlive && providerAlive ? "up" : "stale"} [${state.name}]\n`);
680
+ emit.text(
681
+ ` Provider: ${state.provider}${state.tailscale_mode ? ` (${state.tailscale_mode})` : ""}\n`,
682
+ );
455
683
  emit.text(` URL: ${state.url}\n`);
456
684
  emit.text(` Forwarding: ${state.target} (Host: ${state.vhost})\n`);
457
685
  emit.text(` Gate port: ${state.gate_port}\n`);
458
- emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
686
+ if (state.provider === "cloudflare") {
687
+ emit.text(` Allowed IPs: ${cfg.allowed_ips.join(", ")}\n`);
688
+ } else {
689
+ emit.text(` TS path: ${state.tailscale_path ?? "/"}\n`);
690
+ }
459
691
  emit.text(` Gate PID: ${state.gate_pid}${gateAlive ? "" : " (DEAD)"}\n`);
460
- emit.text(` CFD PID: ${state.cloudflared_pid}${cfdAlive ? "" : " (DEAD)"}\n`);
692
+ if (state.provider === "cloudflare") {
693
+ emit.text(` CFD PID: ${providerPid ?? "?"}${providerAlive ? "" : " (DEAD)"}\n`);
694
+ }
461
695
  emit.text(` Uptime: ${fmtUptime(state.started_at)}\n`);
462
696
  }
463
697
 
@@ -487,6 +721,7 @@ function status(opts: StatusOpts): void {
487
721
 
488
722
  const rows = states.map((s) => ({
489
723
  name: s.name,
724
+ provider: s.provider,
490
725
  state: instanceState(s),
491
726
  url: s.url,
492
727
  fwd: `${s.target} (${s.vhost})`,
@@ -495,6 +730,7 @@ function status(opts: StatusOpts): void {
495
730
  }));
496
731
  const w = {
497
732
  name: Math.max(4, ...rows.map((r) => r.name.length)),
733
+ provider: Math.max(8, ...rows.map((r) => r.provider.length)),
498
734
  state: 5,
499
735
  url: Math.max(3, ...rows.map((r) => r.url.length)),
500
736
  fwd: Math.max(10, ...rows.map((r) => r.fwd.length)),
@@ -502,18 +738,21 @@ function status(opts: StatusOpts): void {
502
738
  };
503
739
  const pad = (s: string, n: number) => s.padEnd(n);
504
740
  emit.text(
505
- `${pad("NAME", w.name)} ${pad("STATE", w.state)} ${pad("URL", w.url)} ${pad("FORWARDING", w.fwd)} ${pad("PORT", w.port)} UPTIME\n`,
741
+ `${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`,
506
742
  );
507
743
  for (const r of rows) {
508
744
  emit.text(
509
- `${pad(r.name, w.name)} ${pad(r.state, w.state)} ${pad(r.url, w.url)} ${pad(r.fwd, w.fwd)} ${pad(r.port, w.port)} ${r.up}\n`,
745
+ `${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`,
510
746
  );
511
747
  }
512
748
  }
513
749
 
514
750
  function logs(opts: LogsOpts): void {
515
751
  const name = resolveName(opts.name);
516
- const which = opts.cloudflared ? cfdLogFile(name) : gateLogFile(name);
752
+ const state = readState(name);
753
+ const provider = state?.provider ?? "cloudflare";
754
+ const which =
755
+ opts.cloudflared || opts.provider ? providerLogFile(name, provider) : gateLogFile(name);
517
756
  const path = cachePath("tunnel", which);
518
757
  if (!existsSync(path)) {
519
758
  emit.error({ code: "tunnel_no_log", message: `No log file at ${path}` });
@@ -542,10 +781,10 @@ function allowAdd(ip: string): void {
542
781
  cfg.allowed_ips.push(ip);
543
782
  writeConfig(cfg);
544
783
  emit.text(`Added ${ip}.\n`);
545
- const up = listStates();
784
+ const up = listStates().filter((s) => s.provider === "cloudflare");
546
785
  if (up.length > 0) {
547
786
  emit.text(
548
- `Allowlist is shared across all tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`,
787
+ `Cloudflare allowlist is shared across Cloudflare tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`,
549
788
  );
550
789
  }
551
790
  }
@@ -560,10 +799,10 @@ function allowRm(ip: string): void {
560
799
  cfg.allowed_ips.splice(idx, 1);
561
800
  writeConfig(cfg);
562
801
  emit.text(`Removed ${ip}.\n`);
563
- const up = listStates();
802
+ const up = listStates().filter((s) => s.provider === "cloudflare");
564
803
  if (up.length > 0) {
565
804
  emit.text(
566
- `Allowlist is shared across all tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`,
805
+ `Cloudflare allowlist is shared across Cloudflare tunnels; restart each to apply (${up.map((s) => s.name).join(", ")}).\n`,
567
806
  );
568
807
  }
569
808
  }
@@ -581,14 +820,15 @@ export function registerTunnelCommand(
581
820
  const cmd = program
582
821
  .command("tunnel")
583
822
  .description(
584
- "IP-gated Cloudflare quick tunnel(s) in front of a local upstream (default upstream: 127.0.0.1:8001). " +
823
+ "Provider-backed tunnel(s) in front of a local upstream (default upstream: 127.0.0.1:8001). " +
585
824
  "Run several at once with --name <instance>.",
586
825
  );
587
826
 
588
827
  cmd
589
828
  .command("up")
590
- .description("Start a gate + cloudflared tunnel (one per --name instance)")
829
+ .description("Start a gate + provider tunnel (one per --name instance)")
591
830
  .option("--name <name>", "instance name; run multiple tunnels side by side", DEFAULT_INSTANCE)
831
+ .option("--provider <provider>", "provider: cloudflare (default) or tailscale", "cloudflare")
592
832
  .option("--target <addr>", "upstream to forward to", DEFAULT_TARGET)
593
833
  .option(
594
834
  "--vhost <host>",
@@ -599,6 +839,16 @@ export function registerTunnelCommand(
599
839
  "--gate-port <port>",
600
840
  "local port the gate binds to (default: auto-allocate the first free port from 9001)",
601
841
  )
842
+ .option(
843
+ "--visibility <visibility>",
844
+ "Tailscale only: tailnet (Serve, default) or public (Funnel)",
845
+ "tailnet",
846
+ )
847
+ .option(
848
+ "--path <path>",
849
+ "Tailscale only: URL path mount (default: / for default, /<name> for named instances)",
850
+ )
851
+ .option("--https-port <port>", "Tailscale only: HTTPS listen port (default: 443)", "443")
602
852
  .action(up);
603
853
 
604
854
  cmd
@@ -616,16 +866,17 @@ export function registerTunnelCommand(
616
866
 
617
867
  cmd
618
868
  .command("logs")
619
- .description("Tail the gate log (default) or cloudflared log for an instance")
869
+ .description("Tail the gate log (default) or provider log for an instance")
620
870
  .option("--name <name>", "instance whose log to tail", DEFAULT_INSTANCE)
621
871
  .option("-f, --follow", "follow the log")
622
872
  .option("--gate", "tail the gate log (default)")
623
- .option("--cloudflared", "tail the cloudflared log instead")
873
+ .option("--provider", "tail the provider log instead")
874
+ .option("--cloudflared", "tail the Cloudflare provider log instead (legacy alias)")
624
875
  .action(logs);
625
876
 
626
877
  const allow = cmd
627
878
  .command("allow")
628
- .description("Manage the CF-Connecting-IP allowlist")
879
+ .description("Manage the Cloudflare CF-Connecting-IP allowlist")
629
880
  .action(allowList);
630
881
  allow.command("add <ip>").description("Add an IP to the allowlist").action(allowAdd);
631
882
  allow.command("rm <ip>").description("Remove an IP from the allowlist").action(allowRm);
@@ -16,7 +16,7 @@ import type { EmitContext } from "../commander.ts";
16
16
  * harnery repo at the *matching version tag* into a cache dir and install the
17
17
  * web app's deps. Subsequent runs reuse the cache.
18
18
  *
19
- * The only thing `web/` pulls from `src/` is `harnery/lib/scratch` (a single,
19
+ * The only thing `web/` pulls from `src/` is `harnery/core/scratch` (a single,
20
20
  * dependency-free module), so the install is `web/` deps only: no root install,
21
21
  * no Playwright browser download.
22
22
  */