@sunshinelife83/hearth 1.0.0 → 1.1.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 CHANGED
@@ -81,7 +81,7 @@ hearth init
81
81
  Non-interactive (scripts, second machine):
82
82
 
83
83
  ```bash
84
- hearth init --yes --use both --roots ~/personal,~/work --public-url https://your-tunnel-host.example.com
84
+ hearth init --yes --use both --roots ~/personal,~/work --public-url https://xxx.ngrok-free.dev
85
85
  ```
86
86
 
87
87
  Or one line from a checkout (checks Node, packs, installs, then runs setup
@@ -103,7 +103,8 @@ During setup, Hearth asks for:
103
103
  - which Coding Agents Hearth may use
104
104
 
105
105
  If you select ChatGPT, setup also asks which local project folders it may open
106
- and for your public HTTPS base URL from a tunnel or reverse proxy you control.
106
+ and for your public HTTPS base URL your static ngrok domain (see
107
+ [Managed Tunnel](#managed-tunnel) below).
107
108
  A Coding Agents-only setup asks
108
109
  neither question: local commands use the current Git project, or the current
109
110
  directory outside a repository.
@@ -111,11 +112,11 @@ directory outside a repository.
111
112
  Use the public origin without `/mcp` during setup:
112
113
 
113
114
  ```text
114
- https://your-tunnel-host.example.com
115
+ https://xxx.ngrok-free.dev
115
116
  ```
116
117
 
117
118
  You will configure your MCP client with the public `/mcp` URL after setup.
118
- Run `hearth serve` when using ChatGPT. For Coding Agents, setup prints a
119
+ Run `hearth serve --ngrok` when using ChatGPT. For Coding Agents, setup prints a
119
120
  `skills` command and lets the Skills CLI handle installation.
120
121
 
121
122
  When the client connects, Hearth opens an Owner password approval page. Enter
@@ -140,10 +141,10 @@ The default local endpoint is:
140
141
  http://127.0.0.1:7176/mcp
141
142
  ```
142
143
 
143
- Most users should connect through a public HTTPS tunnel:
144
+ Most users should connect through the managed ngrok tunnel:
144
145
 
145
146
  ```text
146
- https://your-tunnel-host.example.com/mcp
147
+ https://xxx.ngrok-free.dev/mcp
147
148
  ```
148
149
 
149
150
  ChatGPT, Claude, and generic MCP clients all use the same `/mcp` endpoint.
@@ -191,24 +192,24 @@ The dashboard performs no privileged execution of its own: it shows the same
191
192
  policy-gated state the MCP surface sees, and agents/tasks are still driven
192
193
  from your MCP client.
193
194
 
194
- ## Direct Exposure Without a Relay
195
+ ## Managed Tunnel
195
196
 
196
- No software can give your PC a public URL with zero outside help: ChatGPT must
197
- reach a public IP over valid HTTPS. What Hearth removes is the *relay
198
- middleman*. If you have a domain and an inbound route to this machine:
197
+ Hearth exposes this PC through ngrok the only remote-access path. Each PC
198
+ gets its stable ngrok domain, and `hearth serve --ngrok` is server, URL, and
199
+ tunnel in one command:
199
200
 
200
201
  ```bash
201
- hearth expose # reports this PC's identity and exactly what's missing
202
- hearth id # stable per-PC identity (hearth-xxxx...)
202
+ ngrok config add-authtoken <your-token> # once; token from dashboard.ngrok.com
203
+ hearth ngrok setup --domain xxx.ngrok-free.dev
204
+ hearth serve --ngrok
205
+ hearth ngrok status # health: binary, auth, live domain match
206
+ hearth id # stable per-PC identity (hearth-xxxx...)
203
207
  ```
204
208
 
205
- Then: point your domain at the machine, forward TCP 443 (and 80 for issuance),
206
- issue a certificate with certbot webroot against `tls.acmeDir`, set
207
- `tls.certFile`/`tls.keyFile`, run `hearth config set publicBaseUrl
208
- https://your-domain`, and restart serve. Hearth terminates TLS itself and
209
- serves the ACME challenge path. Without an inbound route + domain, traffic
210
- needs *some* relay — run your own (e.g. on your VPS), never one you don't
211
- control.
209
+ Only AI endpoints (`/mcp`, OAuth, discovery, health) are reachable through
210
+ the tunnel: the landing page and dashboard 404 remotely by Host and stay
211
+ localhost-only. `hearth doctor` checks the binary, auth, domain match, and
212
+ child liveness.
212
213
 
213
214
  ## Mental Model
214
215
 
@@ -220,11 +221,10 @@ connected client like a trusted coding partner with access to your machine.
220
221
 
221
222
  For a normal ChatGPT coding session:
222
223
 
223
- 1. Start your tunnel.
224
- 2. Run `hearth serve`.
225
- 3. Connect the MCP client to your public `/mcp` URL.
226
- 4. Approve the connection with the Owner password.
227
- 5. Ask ChatGPT to open a project inside one of your allowed roots.
224
+ 1. Run `hearth serve --ngrok`.
225
+ 2. Connect the MCP client to your public `/mcp` URL.
226
+ 3. Approve the connection with the Owner password.
227
+ 4. Ask ChatGPT to open a project inside one of your allowed roots.
228
228
 
229
229
  ## Platform Support
230
230
 
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import { generateOwnerToken, loadHearthFiles, setHearthConfigValue, setHearthCon
19
19
  import { expandHomePath } from "./roots.js";
20
20
  import { readReviewRef } from "./review-checkpoints.js";
21
21
  import { shutdownHttpServer } from "./server-shutdown.js";
22
+ import { NGROK_AGENT_API, checkNgrokAuth, findNgrok, isNgrokChildAlive, ngrokInstallHint, ngrokPaths, ngrokVersion, normalizeNgrokDomain, parseAgentTunnels, startNgrokChild, validateManagedNgrok, validateNgrokDomain, waitForAgentDomain, } from "./tunnel-ngrok.js";
22
23
  const require = createRequire(import.meta.url);
23
24
  // Keep in sync with "engines.node" in package.json and the documented range.
24
25
  const SUPPORTED_NODE_RANGE = ">=22.19 <27";
@@ -29,7 +30,7 @@ async function main(argv) {
29
30
  switch (command) {
30
31
  case "serve":
31
32
  await ensureConfigured();
32
- await serve();
33
+ await serve(args);
33
34
  return;
34
35
  case "mcp":
35
36
  await runMcp();
@@ -49,15 +50,15 @@ async function main(argv) {
49
50
  case "agents":
50
51
  await runAgentsCommand(args);
51
52
  return;
52
- case "expose":
53
- await runExpose();
54
- return;
55
53
  case "id":
56
54
  runMachineId();
57
55
  return;
58
56
  case "connect":
59
57
  await runConnect(args);
60
58
  return;
59
+ case "ngrok":
60
+ await runNgrokCli(args);
61
+ return;
61
62
  case "show-changes":
62
63
  await runShowChanges(args);
63
64
  return;
@@ -79,8 +80,8 @@ function normalizeCommand(command) {
79
80
  || command === "config"
80
81
  || command === "agents"
81
82
  || command === "show-changes"
82
- || command === "expose"
83
83
  || command === "connect"
84
+ || command === "ngrok"
84
85
  || command === "id")
85
86
  return command;
86
87
  if (command === "help" || command === "--help" || command === "-h")
@@ -264,22 +265,22 @@ async function runInit({ force, yes, roots, publicUrl, use, providers }) {
264
265
  }
265
266
  else if (nonInteractive) {
266
267
  if (!files.config.server.publicBaseUrl) {
267
- throw new Error("hearth init --yes for ChatGPT requires --public-url https://your-tunnel-host.example.com (origin only, without /mcp).");
268
+ throw new Error("hearth init --yes for ChatGPT requires --public-url https://xxx.ngrok-free.dev (origin only, without /mcp).");
268
269
  }
269
270
  publicBaseUrl = normalizePublicBaseUrl(files.config.server.publicBaseUrl);
270
271
  }
271
272
  else {
272
273
  prompts.note([
273
- `Point your HTTPS tunnel or reverse proxy to http://127.0.0.1:${port}.`,
274
- "Paste its public URL below.",
274
+ `Point ngrok at http://127.0.0.1:${port} (see \`hearth ngrok setup\`),`,
275
+ "then paste its public URL below.",
275
276
  "",
276
- "Example: https://your-tunnel-host.example.com",
277
+ "Example: https://your-domain.ngrok-free.dev",
277
278
  ].join("\n"), "Connect ChatGPT");
278
279
  publicBaseUrl = normalizePublicBaseUrl(await textPrompt({
279
280
  message: files.config.server.publicBaseUrl
280
281
  ? `What public URL will ChatGPT connect to? Press Enter to keep ${files.config.server.publicBaseUrl}`
281
282
  : "What public URL will ChatGPT connect to?",
282
- placeholder: files.config.server.publicBaseUrl ?? "https://your-tunnel-host.example.com",
283
+ placeholder: files.config.server.publicBaseUrl ?? "https://xxx.ngrok-free.dev",
283
284
  defaultValue: files.config.server.publicBaseUrl ?? "",
284
285
  validate: validateRequiredPublicBaseUrl,
285
286
  }));
@@ -442,7 +443,7 @@ async function runMcp() {
442
443
  const { runStdioServer } = await import("./stdio-server.js");
443
444
  await runStdioServer(loadConfig());
444
445
  }
445
- function logServeBanner(config, localAgentProviders, scheme) {
446
+ function logServeBanner(config, localAgentProviders, liveDomain) {
446
447
  let machineId = "unknown";
447
448
  try {
448
449
  const { loadMachineIdentity } = require("./machine-id.js");
@@ -452,24 +453,28 @@ function logServeBanner(config, localAgentProviders, scheme) {
452
453
  // Banner must never fail because identity storage is unavailable.
453
454
  }
454
455
  const publicMcpUrl = new URL("/mcp", config.publicBaseUrl).toString();
455
- console.log(`hearth listening on ${scheme}://${config.host}:${config.port}/mcp`);
456
+ console.log(`hearth listening on http://${config.host}:${config.port}/mcp`);
456
457
  console.log(`public MCP URL: ${publicMcpUrl}`);
458
+ if (liveDomain) {
459
+ console.log(`tunnel: ngrok ${liveDomain} live (managed — server, URL, and tunnel in one command)`);
460
+ }
461
+ else if (config.tunnel.provider === "ngrok" && config.tunnel.domain) {
462
+ console.log(`tunnel: ngrok ${config.tunnel.domain} saved (pass --ngrok to serve through it)`);
463
+ }
457
464
  console.log(`machine: ${machineId} (hearth id, diagnostic label only — not a security boundary)`);
458
- console.log(`dashboard: ${scheme}://${config.host}:${config.port}/dashboard`);
465
+ console.log(`dashboard: http://${config.host}:${config.port}/dashboard`);
459
466
  console.log(`public base url: ${config.publicBaseUrl}`);
460
467
  console.log(`allowed roots: ${config.allowedRoots.join(", ")}`);
461
468
  console.log(`allowed hosts: ${config.allowedHosts.join(", ")}`);
462
469
  if (config.allowedHosts.includes("*")) {
463
470
  console.warn("warning: Host header allowlist is disabled because server.allowedHosts contains '*'");
464
471
  }
465
- if (scheme === "https")
466
- console.log("tls: native termination from tls.certFile/tls.keyFile");
467
472
  console.log("auth: Owner password approval required");
468
473
  console.log(`logging: ${config.logging.level} ${config.logging.format}`);
469
474
  console.log(`subagent providers: ${formatLocalAgentProviderStatusSummary(localAgentProviders)}`);
470
475
  console.log("next: run `hearth connect` for ChatGPT / Claude / generic MCP steps");
471
476
  }
472
- async function serve() {
477
+ async function serve(argv = []) {
473
478
  const sqliteStatus = checkSqliteNative();
474
479
  if (sqliteStatus !== "ok") {
475
480
  throw new Error([
@@ -482,33 +487,31 @@ async function serve() {
482
487
  }
483
488
  const { createServer } = await import("./server.js");
484
489
  const config = loadConfig();
485
- const { app, close, localAgentProviders } = createServer(config);
486
- const tlsOn = Boolean(config.tls.certFile && config.tls.keyFile);
487
- if (Boolean(config.tls.certFile) !== Boolean(config.tls.keyFile)) {
488
- throw new Error("tls.certFile and tls.keyFile must be set together (or both left null).");
489
- }
490
- let httpServer;
491
- if (tlsOn) {
492
- const { readFileSync } = await import("node:fs");
493
- const { createServer: createHttpsServer } = await import("node:https");
494
- const { expandHomePath } = await import("./roots.js");
495
- httpServer = createHttpsServer({
496
- key: readFileSync(expandHomePath(config.tls.keyFile)),
497
- cert: readFileSync(expandHomePath(config.tls.certFile)),
498
- }, app).listen(config.port, config.host, () => {
499
- logServeBanner(config, localAgentProviders, "https");
500
- });
490
+ const useNgrok = argv.includes("--ngrok");
491
+ if (useNgrok && config.tunnel.provider !== "ngrok") {
492
+ throw new Error("`serve --ngrok` needs a saved ngrok domain. Run `hearth ngrok setup` first.");
501
493
  }
502
- else {
503
- httpServer = app.listen(config.port, config.host, () => {
504
- logServeBanner(config, localAgentProviders, "http");
505
- });
494
+ if (!useNgrok && config.tunnel.provider === "ngrok" && config.tunnel.domain) {
495
+ console.log(`tunnel: ngrok domain ${config.tunnel.domain} saved but --ngrok not passed; serving locally only.`);
506
496
  }
497
+ const managedNgrok = useNgrok ? await startManagedNgrok(config) : undefined;
498
+ const { app, close, localAgentProviders } = createServer(config);
499
+ const httpServer = app.listen(config.port, config.host, () => {
500
+ logServeBanner(config, localAgentProviders, managedNgrok?.domain ?? null);
501
+ });
507
502
  let shuttingDown = false;
508
503
  const shutdown = async () => {
509
504
  if (shuttingDown)
510
505
  return;
511
506
  shuttingDown = true;
507
+ if (managedNgrok) {
508
+ try {
509
+ await managedNgrok.stop();
510
+ }
511
+ catch (error) {
512
+ console.error(`ngrok shutdown failed: ${error instanceof Error ? error.message : String(error)}`);
513
+ }
514
+ }
512
515
  await shutdownHttpServer(httpServer, close);
513
516
  process.exit(0);
514
517
  };
@@ -561,7 +564,9 @@ async function runDoctor({ fix }) {
561
564
  console.log(`Subagents: ${config.subagents.enabled ? "enabled" : "disabled"}`);
562
565
  console.log(`Subagent providers: ${formatLocalAgentProviderStatusSummary(providers)}`);
563
566
  console.log(`Tool mode: ${config.toolMode}`);
567
+ console.log(`Tunnel: ${describeNgrokConfig(config.tunnel)}`);
564
568
  const warnings = [];
569
+ warnings.push(...checkNgrokConfig({ ...config, trustProxy: config.oauth.trustProxy }));
565
570
  if (config.allowedHosts.includes("*"))
566
571
  warnings.push("server.allowedHosts contains '*': Host header checks are disabled (local debugging only).");
567
572
  if (config.allowedRoots.length === 0)
@@ -604,13 +609,16 @@ async function runConnect(args) {
604
609
  const lines = [
605
610
  `machine: ${identity.id} (${identity.hostname})`,
606
611
  `public MCP URL (use this in remote clients): ${publicMcpUrl}`,
612
+ config.tunnel.provider === "ngrok" && config.tunnel.domain
613
+ ? `tunnel: managed ngrok (${config.tunnel.domain}) — \`hearth serve --ngrok\` starts everything`
614
+ : "tunnel: none managed (`hearth ngrok setup` provisions this PC's static domain)",
607
615
  `local MCP URL: ${localMcpUrl}`,
608
616
  `dashboard: http://${config.host}:${config.port}/dashboard`,
609
617
  `tool mode: ${config.toolMode} (ChatGPT works with either; Claude Desktop prefers tools.mode claude)`,
610
618
  "",
611
619
  ];
612
620
  if (wanted === "all" || wanted === "chatgpt") {
613
- lines.push("ChatGPT:", ` 1. hearth serve (keep running) + tunnel pointing at http://${config.host}:${config.port} (proxy the whole origin, not only /mcp).`, ` 2. Add connector URL: ${publicMcpUrl}`, " 3. Approve with the Owner password from ~/.hearth/auth.json (hearth init prints it).", "");
621
+ lines.push("ChatGPT:", ` 1. hearth serve --ngrok (keep running; it serves this PC's static domain).`, ` 2. Add connector URL: ${publicMcpUrl}`, " 3. Approve with the Owner password from ~/.hearth/auth.json (hearth init prints it).", "");
614
622
  }
615
623
  if (wanted === "all" || wanted === "claude") {
616
624
  lines.push("Claude (Desktop / Code with remote MCP):", ` 1. Ensure oauth.allowedRedirectHosts includes claude.ai (current: ${config.oauth.allowedRedirectHosts.join(", ")}).`, ` 2. Add MCP server URL: ${publicMcpUrl} and approve with the Owner password.`, " 3. For local-only use without OAuth: hearth token create claude-local, then `hearth mcp` as a stdio server with that bearer.", "");
@@ -622,6 +630,259 @@ async function runConnect(args) {
622
630
  lines.push("The machine id is a diagnostic label, not a security boundary: copying stateDir copies the identity, so it cannot prove which PC answered or prevent cloning.");
623
631
  console.log(lines.join("\n"));
624
632
  }
633
+ function printNgrokHelp() {
634
+ console.log([
635
+ "Hearth ngrok (managed per-PC tunnel — the only remote-access path)",
636
+ "",
637
+ "Usage:",
638
+ " hearth ngrok setup [--domain <static-domain>] [--yes]",
639
+ " hearth ngrok status [--json]",
640
+ " hearth serve --ngrok Start the server with the tunnel (plain serve stays local-only)",
641
+ "",
642
+ "setup saves this PC's static ngrok domain (e.g. xxx.ngrok-free.dev),",
643
+ "syncs server.publicBaseUrl, and enables server.trustProxy. Your ngrok",
644
+ "authtoken stays in ngrok's own config: run `ngrok config add-authtoken`",
645
+ "once if setup reports missing auth.",
646
+ ].join("\n"));
647
+ }
648
+ async function runNgrokCli(args) {
649
+ const [subcommand, ...rest] = args;
650
+ if (!subcommand || subcommand === "help" || subcommand === "--help" || subcommand === "-h") {
651
+ printNgrokHelp();
652
+ return;
653
+ }
654
+ if (subcommand === "status") {
655
+ await runNgrokStatus(rest.includes("--json"));
656
+ return;
657
+ }
658
+ if (subcommand === "setup") {
659
+ await runNgrokSetup(rest);
660
+ return;
661
+ }
662
+ throw new Error(`Unknown ngrok command: ${subcommand}. Usage: hearth ngrok <setup|status>`);
663
+ }
664
+ function describeNgrokConfig(tunnel) {
665
+ if (tunnel.provider !== "ngrok")
666
+ return "none (local-only until `hearth ngrok setup`)";
667
+ if (!tunnel.domain)
668
+ return "ngrok (incomplete — re-run `hearth ngrok setup`)";
669
+ return `ngrok ${tunnel.domain}`;
670
+ }
671
+ function checkNgrokConfig(config) {
672
+ const warnings = [];
673
+ if (config.tunnel.provider !== "ngrok")
674
+ return warnings;
675
+ const binary = findNgrok();
676
+ if (!binary) {
677
+ warnings.push("tunnel.provider is ngrok but no ngrok binary is on PATH.");
678
+ return warnings;
679
+ }
680
+ if (!config.tunnel.domain) {
681
+ warnings.push("Managed ngrok tunnel is incomplete (domain missing). Re-run `hearth ngrok setup`.");
682
+ return warnings;
683
+ }
684
+ let publicHost = "";
685
+ try {
686
+ publicHost = new URL(config.publicBaseUrl).hostname.toLowerCase();
687
+ }
688
+ catch {
689
+ // loadConfig already guarantees a parseable publicBaseUrl.
690
+ }
691
+ if (publicHost !== config.tunnel.domain.toLowerCase()) {
692
+ warnings.push(`tunnel.domain ${config.tunnel.domain} does not match publicBaseUrl host ${publicHost}. Re-run \`hearth ngrok setup\`.`);
693
+ }
694
+ if (!config.trustProxy) {
695
+ warnings.push("Managed ngrok needs server.trustProxy=true so rate limits see real client IPs. Re-run `hearth ngrok setup`.");
696
+ }
697
+ const childPid = isNgrokChildAlive(ngrokPaths(config.stateDir).pidPath);
698
+ if (childPid) {
699
+ warnings.push(`A serve-managed ngrok child (pid ${childPid}) looks alive while serve may not be running; restart serve --ngrok to reconcile.`);
700
+ }
701
+ return warnings;
702
+ }
703
+ async function runNgrokStatus(json) {
704
+ const files = loadHearthFiles();
705
+ if (!files.configExists || (!files.authExists && !process.env.HEARTH_OAUTH_OWNER_TOKEN)) {
706
+ throw new Error("Hearth is not configured. Run `hearth init` first, then `hearth ngrok setup`.");
707
+ }
708
+ const config = loadConfig();
709
+ const tunnel = config.tunnel;
710
+ if (tunnel.provider !== "ngrok" || !tunnel.domain) {
711
+ if (json) {
712
+ console.log(JSON.stringify({ provider: tunnel.provider, configured: false }));
713
+ return;
714
+ }
715
+ console.log("No managed ngrok tunnel. Run `hearth ngrok setup --domain <static-domain>` on this PC.");
716
+ return;
717
+ }
718
+ const binary = findNgrok();
719
+ let version = "unknown";
720
+ if (binary) {
721
+ try {
722
+ version = ngrokVersion(binary);
723
+ }
724
+ catch {
725
+ // Version is advisory; the auth and agent checks below are authoritative.
726
+ }
727
+ }
728
+ const auth = binary ? checkNgrokAuth(binary) : { ok: false, output: "ngrok not on PATH" };
729
+ const childPid = isNgrokChildAlive(ngrokPaths(config.stateDir).pidPath);
730
+ let agentUrl;
731
+ let agentError;
732
+ try {
733
+ const response = await fetch(`${NGROK_AGENT_API}/tunnels`);
734
+ if (!response.ok)
735
+ throw new Error(`agent API returned ${response.status}`);
736
+ const live = parseAgentTunnels(await response.json());
737
+ agentUrl = live.map((tunnel) => tunnel.publicUrl).join(", ") || undefined;
738
+ }
739
+ catch (error) {
740
+ agentError = error instanceof Error ? error.message : String(error);
741
+ }
742
+ if (json) {
743
+ console.log(JSON.stringify({
744
+ provider: "ngrok",
745
+ configured: true,
746
+ domain: tunnel.domain,
747
+ publicMcpUrl: new URL("/mcp", config.publicBaseUrl).toString(),
748
+ ngrok: binary ? { binary, version, authOk: auth.ok } : null,
749
+ childPid: childPid ?? null,
750
+ agentUrl: agentUrl ?? null,
751
+ agentError: agentError ?? null,
752
+ }));
753
+ return;
754
+ }
755
+ console.log([
756
+ `tunnel: ngrok ${tunnel.domain}`,
757
+ `public MCP URL: ${new URL("/mcp", config.publicBaseUrl).toString()}`,
758
+ `ngrok: ${binary ? `${binary} (${version}), auth ${auth.ok ? "ok" : "MISSING — run `ngrok config add-authtoken`"}` : "NOT FOUND on PATH"}`,
759
+ childPid ? `serve-managed child: running (pid ${childPid})` : "serve-managed child: not running (start with `hearth serve --ngrok`)",
760
+ agentUrl ? `agent serving: ${agentUrl}` : `agent: not reachable (${agentError ?? "is ngrok running?"})`,
761
+ ].join("\n"));
762
+ }
763
+ function parseNgrokSetupArgs(args) {
764
+ const options = { yes: false };
765
+ for (let index = 0; index < args.length; index += 1) {
766
+ const arg = args[index];
767
+ if (arg === "--yes" || arg === "-y")
768
+ options.yes = true;
769
+ else if (arg === "--domain")
770
+ options.domain = args[++index];
771
+ else if (arg.startsWith("--domain="))
772
+ options.domain = arg.slice("--domain=".length);
773
+ else
774
+ throw new Error(`Unknown ngrok setup option: ${arg}. Usage: hearth ngrok setup [--domain <static-domain>] [--yes]`);
775
+ }
776
+ return options;
777
+ }
778
+ async function runNgrokSetup(args) {
779
+ const options = parseNgrokSetupArgs(args);
780
+ const files = loadHearthFiles();
781
+ if (!files.configExists || (!files.authExists && !process.env.HEARTH_OAUTH_OWNER_TOKEN)) {
782
+ throw new Error("Hearth is not configured. Run `hearth init` first, then `hearth ngrok setup`.");
783
+ }
784
+ const config = loadConfig();
785
+ const binary = findNgrok();
786
+ if (!binary)
787
+ throw new Error(ngrokInstallHint());
788
+ try {
789
+ ngrokVersion(binary);
790
+ }
791
+ catch {
792
+ // Version is advisory; the auth check below is authoritative.
793
+ }
794
+ const auth = checkNgrokAuth(binary);
795
+ if (!auth.ok) {
796
+ throw new Error([
797
+ "ngrok has no authtoken configured.",
798
+ "Run `ngrok config add-authtoken <your-token>` once (token from https://dashboard.ngrok.com/get-started/your-authtoken), then re-run `hearth ngrok setup`.",
799
+ ].join("\n"));
800
+ }
801
+ let domain = options.domain?.trim();
802
+ if (domain === undefined) {
803
+ if (options.yes || !input.isTTY || !output.isTTY) {
804
+ domain = config.tunnel.domain ?? undefined;
805
+ if (!domain)
806
+ throw new Error("Non-interactive setup needs --domain <static-domain>.");
807
+ }
808
+ else {
809
+ prompts.note("Hearth binds this PC to its static ngrok domain (free accounts get one stable xxx.ngrok-free.dev). Only AI endpoints are reachable through it; the dashboard stays localhost-only.", "Managed ngrok");
810
+ domain = await textPrompt({
811
+ message: config.tunnel.domain
812
+ ? `Which static ngrok domain should this PC serve? Press Enter to keep ${config.tunnel.domain}`
813
+ : "Which static ngrok domain should this PC serve?",
814
+ placeholder: "xxx.ngrok-free.dev",
815
+ defaultValue: config.tunnel.domain ?? "",
816
+ validate: validateNgrokDomain,
817
+ });
818
+ }
819
+ }
820
+ const domainError = validateNgrokDomain(domain);
821
+ if (domainError)
822
+ throw new Error(domainError);
823
+ const normalizedDomain = normalizeNgrokDomain(domain);
824
+ setHearthConfigValues([
825
+ { path: ["tunnel", "provider"], value: "ngrok" },
826
+ { path: ["tunnel", "domain"], value: normalizedDomain },
827
+ { path: ["server", "publicBaseUrl"], value: `https://${normalizedDomain}` },
828
+ { path: ["server", "trustProxy"], value: true },
829
+ ]);
830
+ const lines = [
831
+ `Domain: ${normalizedDomain}`,
832
+ `Public MCP URL: https://${normalizedDomain}/mcp`,
833
+ "server.trustProxy was enabled so rate limits see real client IPs via x-forwarded-for.",
834
+ "Note: ngrok free shows a browser interstitial page on HTML traffic; API calls are unaffected. The Owner approval page needs one click-through.",
835
+ ];
836
+ if (options.yes || !input.isTTY || !output.isTTY) {
837
+ console.log(["Hearth ngrok is ready", ...lines].join("\n"));
838
+ }
839
+ else {
840
+ prompts.note(lines.join("\n"), "Hearth ngrok is ready");
841
+ prompts.outro("Run `hearth serve --ngrok` — server and tunnel start together. `hearth ngrok status` checks health.");
842
+ }
843
+ }
844
+ /**
845
+ * Validate managed-ngrok config and start the supervised agent.
846
+ * Fail-fast: an ngrok setup that cannot serve the saved domain must stop
847
+ * `serve`, never leave Hearth reachable locally but dark publicly.
848
+ */
849
+ async function startManagedNgrok(config) {
850
+ const binary = findNgrok();
851
+ const problems = validateManagedNgrok({
852
+ domain: config.tunnel.domain,
853
+ publicBaseUrl: config.publicBaseUrl,
854
+ trustProxy: config.oauth.trustProxy,
855
+ binary,
856
+ });
857
+ if (problems.length > 0) {
858
+ throw new Error(["Managed ngrok is misconfigured:", ...problems.map((problem) => ` - ${problem}`)].join("\n"));
859
+ }
860
+ if (binary && !checkNgrokAuth(binary).ok) {
861
+ throw new Error("ngrok has no authtoken configured. Run `ngrok config add-authtoken <your-token>`, then restart serve.");
862
+ }
863
+ const paths = ngrokPaths(config.stateDir);
864
+ console.log(`tunnel: starting managed ngrok for ${config.tunnel.domain} ...`);
865
+ const supervised = startNgrokChild({
866
+ binary: binary,
867
+ port: config.port,
868
+ pidPath: paths.pidPath,
869
+ onLog: (line) => console.error(`[ngrok] ${line}`),
870
+ });
871
+ supervised.process.once("exit", (code) => {
872
+ if (code !== 0 && code !== null) {
873
+ console.error(`[ngrok] agent exited with code ${code}; public URL is dark while serve keeps running locally. Restart serve --ngrok or run \`hearth ngrok status\`.`);
874
+ }
875
+ });
876
+ try {
877
+ await waitForAgentDomain(config.tunnel.domain);
878
+ }
879
+ catch (error) {
880
+ await supervised.stop().catch(() => undefined);
881
+ throw error instanceof Error ? error : new Error(String(error));
882
+ }
883
+ console.log(`tunnel: ngrok serving https://${config.tunnel.domain} (static domain confirmed live)`);
884
+ return { stop: supervised.stop, domain: config.tunnel.domain };
885
+ }
625
886
  function runConfigCommand(args) {
626
887
  const [subcommand, key, ...rest] = args;
627
888
  const files = loadHearthFiles();
@@ -649,76 +910,17 @@ function runMachineId() {
649
910
  console.log(JSON.stringify(identity, null, 2));
650
911
  });
651
912
  }
652
- /**
653
- * Relay-free exposure report: tells the owner exactly what stands between
654
- * this PC and a direct public URL, without routing through any tunnel
655
- * service. Honest by design: a public URL needs (1) an inbound route to this
656
- * machine and (2) a domain with a valid certificate. Hearth automates
657
- * everything after those two owner-provided prerequisites.
658
- */
659
- async function runExpose() {
660
- const config = loadConfig();
661
- const { loadMachineIdentity } = await import("./machine-id.js");
662
- const identity = loadMachineIdentity(config.stateDir);
663
- const { execFileSync } = await import("node:child_process");
664
- const { existsSync } = await import("node:fs");
665
- const { expandHomePath } = await import("./roots.js");
666
- let publicIp;
667
- try {
668
- const out = execFileSync("dig", ["+short", "+time=5", "myip.opendns.com", "@resolver1.opendns.com"], {
669
- encoding: "utf8",
670
- timeout: 10_000,
671
- }).trim().split("\n").pop()?.trim();
672
- if (out && /^[0-9a-fA-F.:]+$/.test(out))
673
- publicIp = out;
674
- }
675
- catch {
676
- // dig absent or blocked: report without a public IP guess.
677
- }
678
- const certSet = Boolean(config.tls.certFile && config.tls.keyFile);
679
- const certPresent = certSet
680
- && existsSync(expandHomePath(config.tls.certFile))
681
- && existsSync(expandHomePath(config.tls.keyFile));
682
- const publicUrl = new URL(config.publicBaseUrl);
683
- const directHttps = publicUrl.protocol === "https:" && certSet;
684
- const lines = [
685
- `machine: ${identity.id} (${identity.hostname}, ${identity.platform}/${identity.arch})`,
686
- `bind: ${config.host}:${config.port} (hearth serve)`,
687
- `public base url: ${config.publicBaseUrl}`,
688
- `public MCP URL (keep one URL per PC — reusing it elsewhere splits approvals and audit): ${new URL("/mcp", config.publicBaseUrl).toString()}`,
689
- `public IP seen from here: ${publicIp ?? "unknown (dig unavailable or blocked)"}`,
690
- `native TLS: ${!certSet ? "off (tls.certFile/tls.keyFile unset)" : certPresent ? "cert + key present" : "CONFIGURED BUT FILES MISSING"}`,
691
- `ACME webroot: ${config.tls.acmeDir ?? "unset"}`,
692
- "",
693
- "OAuth tokens are bound to this machine's resource URL and Owner password.",
694
- "Run `hearth connect` for ChatGPT / Claude / generic MCP steps.",
695
- "",
696
- directHttps && certPresent
697
- ? "status: DIRECT — publicBaseUrl is https and TLS material is present. Forward TCP 443 to this machine, point your domain's A/AAAA record at the public IP, restart serve."
698
- : "status: NOT directly reachable — to drop the relay you need:",
699
- ...(!directHttps || !certPresent ? [
700
- " 1. A domain whose A/AAAA record points at this machine (public IP above).",
701
- " 2. TCP 443 (and TCP 80 for issuance) forwarded to this machine.",
702
- " 3. Certificates, e.g.: certbot certonly --webroot -w <dir> -d <domain>",
703
- " with tls.acmeDir set to <dir> so Hearth serves the HTTP-01 challenge.",
704
- " 4. tls.certFile/tls.keyFile set to the issued files,",
705
- " hearth config set publicBaseUrl https://<domain>, then restart serve.",
706
- ] : []),
707
- "",
708
- "Without an inbound route + domain, some relay must carry the traffic;",
709
- "that relay can be your own VPS — never a third party you don't control.",
710
- ];
711
- console.log(lines.join("\n"));
712
- }
713
913
  function printHelp() {
714
914
  console.log([
715
915
  "Hearth",
716
916
  "",
717
917
  "Usage:",
718
918
  " hearth Run first-time setup if needed, then start the server",
719
- " hearth serve Start the server",
919
+ " hearth serve [--ngrok] Start the server (add --ngrok to also start the managed tunnel)",
720
920
  " hearth init [--force] [--yes --use chatgpt|coding-agents|both --roots <csv> --public-url <url> --providers <csv>]",
721
921
  " hearth doctor [--fix] Show config, runtime, and native dependency status",
922
+ " hearth ngrok setup [--domain <static-domain>] [--yes]",
923
+ " hearth ngrok status [--json] Managed per-PC ngrok tunnel",
722
924
  " hearth connect [chatgpt|claude|generic] Print copy-paste MCP connection steps for this PC",
723
925
  " hearth config get Print persisted config",
724
926
  " hearth config set publicBaseUrl <url|null> (origin only, without /mcp)",
@@ -728,7 +930,6 @@ function printHelp() {
728
930
  " hearth agents continue <id> [--model <model>] [--effort <level>] <prompt>",
729
931
  " hearth agents show <id>",
730
932
  " hearth agents daemon <status|stop|logs>",
731
- " hearth expose Relay-free exposure report for this PC (own domain + TLS)",
732
933
  " hearth id Print this machine's stable Hearth identity",
733
934
  " hearth -v, --version Print the installed version",
734
935
  "",
@@ -739,7 +940,7 @@ function printHelp() {
739
940
  " hearth connect",
740
941
  "",
741
942
  "For temporary tunnels:",
742
- " hearth config set publicBaseUrl https://your-tunnel-host.example.com",
943
+ " hearth config set publicBaseUrl https://xxx.ngrok-free.dev",
743
944
  " hearth serve",
744
945
  ].join("\n"));
745
946
  }
@@ -1029,7 +1230,7 @@ function validatePublicBaseUrl(value) {
1029
1230
  : "Use an http or https URL.";
1030
1231
  }
1031
1232
  catch {
1032
- return "Enter a valid URL, for example https://your-tunnel-host.example.com.";
1233
+ return "Enter a valid URL, for example https://xxx.ngrok-free.dev.";
1033
1234
  }
1034
1235
  }
1035
1236
  function assertSupportedNode() {