harnery 0.5.0 → 0.7.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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +74 -2
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/fetch.js +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +6 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -17
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +15 -10
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +61 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +15 -10
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /package/dist/{lib → core}/scratch/index.d.ts +0 -0
package/src/commands/tunnel.ts
CHANGED
|
@@ -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`:
|
|
32
|
+
* `tunnel`: provider-backed tunnel in front of a local upstream.
|
|
31
33
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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 &&
|
|
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)
|
|
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
|
|
462
|
+
const providerLogPath = cachePath("tunnel", providerLogFile(name, provider));
|
|
281
463
|
writeFileSync(gateLogPath, "");
|
|
282
|
-
writeFileSync(
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
676
|
+
const providerAlive = providerIsAlive(state);
|
|
677
|
+
const providerPid = state.cloudflared_pid ?? state.provider_pid;
|
|
453
678
|
const cfg = readConfig();
|
|
454
|
-
emit.text(`${gateAlive &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
-
"
|
|
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 +
|
|
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
|
|
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("--
|
|
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/
|
|
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
|
*/
|