locadot 1.6.0-beta.7 → 2.1.0-beta.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/CHANGELOG.md CHANGED
@@ -1,12 +1,18 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 2.1.0-beta.0 (2026-09-27)
4
4
 
5
- The version in `package.json` is 1.6.0-beta.7 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
6
- breaking items below.
5
+ ### Added
6
+ - Remote access: share your whole locadot with another locadot over a Cloudflare named tunnel, a quick tunnel, or any URL.
7
+ Receivers pair with a one-time string (5 minutes) and get viewer, editor or admin access, which the sender can change or
8
+ revoke. The sender's mappings appear in the receiver's locadot; clashing names become `<host>.<remote>.localhost`.
9
+ New commands: `hub*`, `share`, `peers*`, `connect`, `remotes`, `remote:*` and `disconnect`. There are also new dashboard cards.
10
+ - Dashboard: a collapsible sidebar with section links, active-section highlighting and counts. Collapse it to an icon rail
11
+ (remembered per browser); on small screens it is a slide-out drawer.
12
+
13
+ ## 2.0.0 (2026-09-26)
7
14
 
8
15
  ### Changed
9
- - Dashboard: two-column layout, host filter, and a System / Light / Dark theme toggle (saved per browser).
10
16
  - Internal restructure into `cli/commands/*`, `server/*` and `proxy/*` modules. There are no behaviour changes; see `tasks/README.md`.
11
17
 
12
18
  ### Breaking
@@ -18,7 +24,7 @@ breaking items below.
18
24
 
19
25
  ### Added
20
26
  - `locadot tunnel --host <h>` (with `--off`, and `tunnel:install`) shares a mapping on a public `https://*.trycloudflare.com` URL through a Cloudflare quick tunnel, with no account needed. `cloudflared` is downloaded on demand into the state dir. The dashboard has a Cloudflare Tunnel card, a Public URL column and Share/Unshare per row. The API accepts `PUT /api/hosts/:host {"tunnel": true}` and has `POST /api/cloudflared/install`. `--cors` also works for tunnel visitors (the shim, preflights and origin rewriting to the public URL), but their pass-through calls may only reach public addresses; loopback, LAN and metadata addresses are refused at connect time.
21
- - The dashboard is redesigned: a neutral palette that follows the system light/dark theme, a responsive grid and a single Traffic column.
27
+ - The dashboard is redesigned: a two-column layout, a host filter, loading/offline/empty states, cards on small screens, and a system / light / dark theme toggle.
22
28
  - `locadot start --port <http> --https-port <https>` (also on `restart`) runs the proxy on other ports than 80/443. The choice is saved in the state dir, so `add`, `status`, `open` and start-at-boot keep using it; `--port 80 --https-port 443` goes back. Running it while the proxy is up on other ports restarts it on the new ones.
23
29
  - `--cors` per mapping (CLI, API and dashboard). `Origin`/`Referer` are sent as the target's own origin, CORS preflights are answered locally, and any origin may call the domain with credentials. Absolute URLs of other mapped domains in text responses are rewritten to their `.localhost` names, and a mapped caller is sent as its real origin, so a site plus its API both work locally. Pages also get a small script that sends calls to any other origin through the page's own origin (`/__locadot/x/…`), so no mapping per API or third-party domain is needed. (ENH-12, BUG-15)
24
30
  - A control panel at `https://localhost` with a dark theme. You can add, edit and remove mappings, toggle CA trust and start-at-boot, see root/admin and privileged-port status,
package/README.md CHANGED
@@ -1,7 +1,16 @@
1
1
  # locadot 🔐
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/locadot.svg)](https://www.npmjs.com/package/locadot)
4
+ [![license](https://img.shields.io/npm/l/locadot.svg)](LICENSE)
5
+
3
6
  HTTPS custom domains for local development. Point `https://app.localhost` at your dev server on port 3000, or
4
- `https://google.localhost` at any upstream URL, and see every mapping on a dashboard at `https://localhost`.
7
+ `https://google.localhost` at any upstream URL, share any of them on a public URL, and manage it all from a dashboard at
8
+ `https://localhost`.
9
+
10
+ <picture>
11
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/dashboard-dark.png">
12
+ <img alt="The locadot dashboard: hosts with health, traffic and public URLs, plus add-host, system and sharing panels" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/dashboard-light.png">
13
+ </picture>
5
14
 
6
15
  ## ✨ Features
7
16
 
@@ -9,8 +18,13 @@ HTTPS custom domains for local development. Point `https://app.localhost` at you
9
18
  Run `locadot trust` once to add the CA to your system and browsers.
10
19
  - 🔁 **Any destination.** A local port (`--port 3000`), a host and port (`--target 192.168.1.5:8080`), or any URL
11
20
  (`--target https://google.com`). Redirects and cookies are rewritten so the browser stays on the `.localhost` name.
12
- - 📋 **Control panel at `https://localhost`.** Add, edit and remove mappings, see health and traffic, and toggle CA trust and
13
- start-at-boot. Includes a token-protected JSON API, so scripts and AI agents can do everything the page does.
21
+ - 🌍 **Share on a public URL.** `locadot tunnel --host app.localhost` (or **Share** in the dashboard) puts a mapping on
22
+ `https://<random>.trycloudflare.com` through a Cloudflare quick tunnel. No account needed.
23
+ - 🧩 **`--cors` for sites that call other domains.** Preflights are answered locally and cross-origin calls are routed
24
+ through the page's own origin, so a production frontend works against its real APIs from `.localhost`.
25
+ - 📋 **Control panel at `https://localhost`.** Add, edit, filter and remove mappings, see health and traffic, share them,
26
+ and toggle CA trust and start-at-boot. Light, dark or system theme. Includes a token-protected JSON API, so scripts and
27
+ AI agents can do everything the page does.
14
28
  - 🔌 **WebSockets** are proxied too, so HMR and dev-server live reload work.
15
29
  - 🩺 **`status` and `doctor`** show why a domain isn't working: port conflicts, permissions, CA trust, a target that isn't answering.
16
30
  - 🛡️ **Local by default.** The proxy binds to `127.0.0.1` / `::1` only. Domains must end in `.localhost`, which browsers
@@ -22,6 +36,7 @@ HTTPS custom domains for local development. Point `https://app.localhost` at you
22
36
  ## 🚀 Quick start
23
37
 
24
38
  ```bash
39
+ npm i -g locadot # or prefix each command with npx
25
40
  npx locadot trust # once: trust the locadot CA (asks for your password)
26
41
  npx locadot add --host app.localhost --port 3000 # https://app.localhost → http://localhost:3000
27
42
  npx locadot add --host google.localhost --target https://google.com
@@ -86,6 +101,30 @@ pass-through calls may only go to public addresses: loopback, LAN, link-local an
86
101
  on the address actually dialled, so the tunnel can't be used to reach your machine or network. The dashboard can also do this:
87
102
  use **Share** / **Unshare** on a row.
88
103
 
104
+ ### Remote access (locadot to locadot)
105
+
106
+ One machine (the sender) publishes its whole locadot on a Cloudflare hostname. Other machines (receivers) connect with a
107
+ short-lived pairing string, and the sender's mappings then show up in the receiver's own locadot and dashboard.
108
+
109
+ | Command | What it does |
110
+ | --- | --- |
111
+ | `locadot hub:setup --domain dev.example.com` | Sender: publish through your own named tunnel on your Cloudflare domain (runs `cloudflared` login, creates the tunnel and routes DNS). The address stays the same. |
112
+ | `locadot hub:quick` | Sender: publish on a trycloudflare URL. No account is needed, but **the URL changes if the tunnel restarts**, so receivers have to run `remote:url`. |
113
+ | `locadot hub` / `hub:off` | Show the hub status and URL, or stop it. |
114
+ | `locadot share --role viewer\|editor\|admin [--hosts a.localhost,b.localhost]` | Sender: print a pairing string. It works once and expires after 5 minutes. `--hosts` limits a viewer to those mappings. |
115
+ | `locadot peers` / `peers:role <id> <role>` / `peers:revoke <id>` | Sender: list the connected machines, change a role, or cut one off. |
116
+ | `locadot connect "<pairing string>" [--name alice]` | Receiver: connect and import the sender's mappings. If a name clashes, `app.localhost` becomes `app.alice.localhost`. |
117
+ | `locadot remotes` / `remote:sync <name>` / `disconnect <name>` | Receiver: list the connections, pull new mappings, or remove the connection and its names. |
118
+ | `locadot remote:alias <name> <remote host> <local host>` | Receiver: give a sender mapping another local name, e.g. `he.localhost`. |
119
+ | `locadot remote:add\|remote:update\|remote:rm <name> …` | Receiver: change mappings on the sender (editor or admin). |
120
+ | `locadot remote:url <name> <url>` | Receiver: point a connection at the sender's new URL. |
121
+
122
+ Roles: **viewer** can only browse (optionally limited to the mappings picked on the invite). **editor** can also add and change
123
+ mappings on the sender. **admin** can also delete them and change sharing. Traffic goes receiver → Cloudflare → sender →
124
+ target, so an editor can reach anything the sender's machine can reach. Only give that role to people you trust. Only hashes
125
+ of the tokens are stored, and failed attempts are rate limited. The sender's dashboard is never reachable through the hub.
126
+ The dashboard has **Remote access** and **Connected machines** cards for all of this.
127
+
89
128
  ### Certificates
90
129
 
91
130
  | Command | What it does |
@@ -108,15 +147,27 @@ use **Share** / **Unshare** on a row.
108
147
  ## 📋 Dashboard & control panel
109
148
 
110
149
  Open `https://localhost` or `http://localhost` (or run `locadot open`). Requests for bare `localhost` / `127.0.0.1` / `::1`
111
- are answered by locadot itself, not proxied. The page is a control panel that follows the system light/dark theme:
150
+ are answered by locadot itself, not proxied. On wide screens the page is a two-column layout: your hosts on the left,
151
+ controls on the right. The button at the top right switches between the system, light and dark themes (saved per browser).
152
+
153
+ <img alt="Hosts table with status, latency, traffic, a shared public URL and per-row actions" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/hosts.png">
112
154
 
113
- - **System:** whether the proxy runs as root/admin, whether ports below 1024 can be bound (with the Linux sysctl fix when they
114
- can't), CA trust with an on/off toggle, start-at-boot with an on/off toggle, ports, bind addresses and state dir. It also has a **Stop proxy** button.
115
- - **Proxies:** add a mapping (host + port / host:port / URL, and optional "Insecure TLS" and "Bypass CORS" boxes), edit a target inline, or remove one. The table shows
116
- each source → destination with up/down, status, latency, its public tunnel URL and traffic (hits, errors, average ms). It refreshes every 5 s.
117
- - **Cloudflare Tunnel:** whether `cloudflared` is installed (with an **Install** button), and a **Share** button per row that puts the mapping on a public URL.
118
- - **Logs:** a live tail with refresh and clear.
119
- - **CLI / API snippets** you can copy.
155
+ - **Hosts:** every mapping with its target, options (`cors`, insecure TLS), up/down status, latency, traffic (hits, errors,
156
+ average ms) and public tunnel URL. Filter the list, edit a target inline (Enter saves, Esc cancels), **Share** / **Unshare**
157
+ or remove it. A host whose target doesn't answer is marked in red. It refreshes every 5 s.
158
+ - **Add host:** host + port / host:port / URL, with optional "Insecure TLS" and "Bypass CORS" boxes.
159
+ - **System:** ports, bind addresses and state dir, whether the proxy runs as root/admin, whether ports below 1024 can be bound
160
+ (with the Linux sysctl fix when they can't), CA trust and start-at-boot toggles, and a **Stop proxy** button.
161
+ - **Sharing:** whether `cloudflared` is installed, with an **Install** button when it isn't.
162
+ - **Logs:** a live tail with refresh and clear. **CLI & API:** snippets you can copy.
163
+
164
+ If the proxy stops, the page says so, disables the controls and reconnects by itself when it's back. On phones and tablets
165
+ hosts become cards:
166
+
167
+ <p>
168
+ <img alt="Dashboard on a phone, light theme" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/mobile.png" width="260">
169
+ <img alt="Dashboard on a phone, dark theme" src="https://raw.githubusercontent.com/avinashid/locadot/main/docs/screenshots/mobile-dark.png" width="260">
170
+ </p>
120
171
 
121
172
  Trust and start-at-boot may need elevation. locadot asks the OS for it (polkit on Linux, the password dialog on macOS, UAC on Windows).
122
173
  If that isn't possible, for example on a headless Linux box, the panel shows the exact command to run instead: `sudo locadot trust`,
@@ -160,6 +211,19 @@ The dashboard is served only for the bare `localhost` host names, which also blo
160
211
 
161
212
  ---
162
213
 
214
+ ## ⬆️ Upgrading from 1.x
215
+
216
+ 2.0 changes a few defaults. Your mappings are migrated automatically.
217
+
218
+ - The proxy listens on `127.0.0.1` and `::1` only. Set `LOCADOT_BIND=0.0.0.0` if you relied on LAN access.
219
+ - The registry is stored in a new format that 1.x can't read. Don't downgrade without backing up `locadot path:hosts`.
220
+ - `-h` means `--host` on every command. Help is `--help`.
221
+ - Bare `localhost` is the dashboard and can't be mapped.
222
+
223
+ See [CHANGELOG.md](CHANGELOG.md) for everything new.
224
+
225
+ ---
226
+
163
227
  ## ⚙️ Configuration
164
228
 
165
229
  All optional, via environment variables. Set them for the CLI; the proxy it starts inherits them.
@@ -38,6 +38,7 @@ const proxy = __importStar(require("./proxy"));
38
38
  const files = __importStar(require("./files"));
39
39
  const system = __importStar(require("./system"));
40
40
  const tunnel = __importStar(require("./tunnel"));
41
+ const remote = __importStar(require("./remote"));
41
42
  const doctor_1 = require("./doctor");
42
- const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, doctor: doctor_1.doctor };
43
+ const Commands = { ...hosts, ...proxy, ...files, ...system, ...tunnel, ...remote, doctor: doctor_1.doctor };
43
44
  exports.default = Commands;
@@ -0,0 +1,276 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.hub = hub;
7
+ exports.hubSetup = hubSetup;
8
+ exports.hubQuick = hubQuick;
9
+ exports.hubManual = hubManual;
10
+ exports.hubOff = hubOff;
11
+ exports.share = share;
12
+ exports.peers = peers;
13
+ exports.peersRole = peersRole;
14
+ exports.peersRevoke = peersRevoke;
15
+ exports.connect = connect;
16
+ exports.remotes = remotes;
17
+ exports.remoteSync = remoteSync;
18
+ exports.remoteAlias = remoteAlias;
19
+ exports.remoteUrl = remoteUrl;
20
+ exports.remoteAdd = remoteAdd;
21
+ exports.remoteUpdate = remoteUpdate;
22
+ exports.remoteRm = remoteRm;
23
+ exports.disconnect = disconnect;
24
+ const http_1 = __importDefault(require("http"));
25
+ const constants_1 = __importDefault(require("../../constants"));
26
+ const proxy_control_1 = __importDefault(require("../../lib/proxy-control"));
27
+ const localhost_1 = require("../../lib/localhost");
28
+ const urls_1 = require("../../lib/urls");
29
+ const registry_1 = __importDefault(require("../../lib/registry"));
30
+ const file_1 = __importDefault(require("../../utils/file"));
31
+ const links_1 = __importDefault(require("../../lib/links"));
32
+ const hub_config_1 = __importDefault(require("../../lib/hub-config"));
33
+ const hub_tunnel_1 = require("../../proxy/hub-tunnel");
34
+ const remotes_1 = __importDefault(require("../../lib/remotes"));
35
+ const shared_1 = require("../shared");
36
+ const ROLES = ["viewer", "editor", "admin"];
37
+ const requireRole = (value) => {
38
+ if (typeof value !== "string" || !ROLES.includes(value)) {
39
+ throw new localhost_1.InputError(`❌ Invalid role "${value}". Use viewer, editor or admin.`);
40
+ }
41
+ return value;
42
+ };
43
+ const splitHosts = (value) => value
44
+ ? value
45
+ .split(",")
46
+ .map((s) => s.trim())
47
+ .filter(Boolean)
48
+ : undefined;
49
+ const requireRunning = () => {
50
+ if (!proxy_control_1.default.running())
51
+ throw new localhost_1.InputError("❌ The proxy isn't running. Run `locadot start`.");
52
+ };
53
+ const apiToken = () => {
54
+ const token = proxy_control_1.default.running() ? file_1.default.read("API_TOKEN")?.trim() : undefined;
55
+ if (!token)
56
+ throw new localhost_1.InputError("❌ No API token found. Run `locadot start`.");
57
+ return token;
58
+ };
59
+ /** Talks to the running proxy's local dashboard API on its HTTP port. */
60
+ const dashboardRequest = (method, path, options = {}) => new Promise((resolve, reject) => {
61
+ const data = options.body !== undefined ? JSON.stringify(options.body) : undefined;
62
+ const req = http_1.default.request({
63
+ host: "127.0.0.1",
64
+ port: constants_1.default.server.httpPort,
65
+ path,
66
+ method,
67
+ headers: {
68
+ Host: "localhost",
69
+ ...(data ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) } : {}),
70
+ ...options.headers,
71
+ },
72
+ timeout: 5000,
73
+ }, (res) => {
74
+ let body = "";
75
+ res.on("data", (chunk) => (body += chunk));
76
+ res.on("end", () => {
77
+ try {
78
+ resolve({ status: res.statusCode || 0, json: body ? JSON.parse(body) : undefined });
79
+ }
80
+ catch {
81
+ reject(new localhost_1.InputError("❌ The proxy didn't answer. Is it running? Try `locadot status`."));
82
+ }
83
+ });
84
+ });
85
+ req.on("timeout", () => req.destroy(new Error("timeout")));
86
+ req.on("error", () => reject(new localhost_1.InputError("❌ The proxy isn't running. Run `locadot start`.")));
87
+ if (data)
88
+ req.write(data);
89
+ req.end();
90
+ });
91
+ const getHub = async () => {
92
+ requireRunning();
93
+ const { status, json } = await dashboardRequest("GET", "/api/hub");
94
+ if (status !== 200)
95
+ throw new localhost_1.InputError("❌ Couldn't read hub status. Try `locadot status`.");
96
+ return json;
97
+ };
98
+ const HUB_EMOJI = { off: "🔒", starting: "⏳", up: "🌍", error: "❌", login: "🔑" };
99
+ async function hub(options) {
100
+ const { hub } = await getHub();
101
+ if (options.json) {
102
+ console.log(JSON.stringify(hub, null, 2));
103
+ return;
104
+ }
105
+ if (!hub.enabled) {
106
+ (0, shared_1.print)("🔒 Remote access is off. Turn it on: locadot hub:setup / hub:quick / hub:manual");
107
+ return;
108
+ }
109
+ (0, shared_1.print)(`${HUB_EMOJI[hub.status] ?? "❔"} ${hub.mode} — ${hub.status}${hub.url ? `: ${hub.url}` : ""}`);
110
+ if (hub.loginUrl)
111
+ (0, shared_1.print)(` Open to finish login: ${hub.loginUrl}`);
112
+ if (hub.error)
113
+ (0, shared_1.print)(` ${hub.error}`);
114
+ }
115
+ async function hubSetup(options) {
116
+ if (!options.domain)
117
+ throw new localhost_1.InputError("❌ Missing --domain <domain>.");
118
+ (0, shared_1.print)(`🔗 Setting up a named tunnel for ${options.domain}…`);
119
+ const config = await (0, hub_tunnel_1.setupNamedTunnel)(options.domain, options.tunnel, (loginUrl) => {
120
+ (0, shared_1.print)(`🔑 Open this URL to log in to Cloudflare: ${loginUrl}`);
121
+ (0, shared_1.print)(" Waiting for login…");
122
+ });
123
+ hub_config_1.default.write(config);
124
+ (0, shared_1.print)(`✅ Hub configured: https://${options.domain}`);
125
+ (0, shared_1.print)(" The running proxy watches this and starts the tunnel automatically. Not running? `locadot start`.");
126
+ }
127
+ async function hubQuick() {
128
+ hub_config_1.default.write({ mode: "quick" });
129
+ (0, shared_1.print)("🌍 Quick tunnel enabled (trycloudflare.com).");
130
+ (0, shared_1.print)("⚠️ The trycloudflare URL changes whenever cloudflared or the proxy restarts.");
131
+ (0, shared_1.print)(" When that happens, tell receivers to run: locadot remote:url <name> <new-url>");
132
+ (0, shared_1.print)(" Check the current URL any time: locadot hub");
133
+ (0, shared_1.print)(" The running proxy watches this and starts the tunnel automatically. Not running? `locadot start`.");
134
+ }
135
+ async function hubManual(options) {
136
+ if (!options.url)
137
+ throw new localhost_1.InputError("❌ Missing --url <url>.");
138
+ let parsed;
139
+ try {
140
+ parsed = new URL(options.url);
141
+ }
142
+ catch {
143
+ throw new localhost_1.InputError(`❌ Invalid URL "${options.url}".`);
144
+ }
145
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
146
+ throw new localhost_1.InputError(`❌ Invalid URL "${options.url}". Use http:// or https://.`);
147
+ }
148
+ hub_config_1.default.write({ mode: "manual", url: options.url.replace(/\/+$/, "") });
149
+ (0, shared_1.print)(`✅ Hub configured: ${parsed.origin}`);
150
+ (0, shared_1.print)(" The running proxy watches this automatically. Not running? `locadot start`.");
151
+ }
152
+ async function hubOff() {
153
+ hub_config_1.default.clear();
154
+ (0, shared_1.print)("🔒 Remote access turned off.");
155
+ }
156
+ async function share(options) {
157
+ const role = requireRole(options.role);
158
+ if (options.hosts && role !== "viewer") {
159
+ (0, shared_1.print)(`⚠️ --hosts is ignored for ${role}s: they see every host.`);
160
+ }
161
+ const hosts = role === "viewer" ? splitHosts(options.hosts) : undefined;
162
+ requireRunning();
163
+ const { status, json } = await dashboardRequest("POST", "/api/invites", {
164
+ headers: { "X-Locadot-Token": apiToken() },
165
+ body: { role, hosts },
166
+ });
167
+ if (status === 409) {
168
+ throw new localhost_1.InputError("❌ The hub isn't up. Set it up first: locadot hub:setup / hub:quick / hub:manual.");
169
+ }
170
+ if (status !== 200 && status !== 201) {
171
+ throw new localhost_1.InputError(`❌ Couldn't create an invite (${status})${json?.error ? `: ${json.error}` : ""}.`);
172
+ }
173
+ (0, shared_1.print)("🔗 Pairing code (valid for 5 minutes, one use):");
174
+ (0, shared_1.print)("");
175
+ (0, shared_1.print)(` ${json.string}`);
176
+ (0, shared_1.print)("");
177
+ (0, shared_1.print)(` Role: ${json.role}${json.hosts ? ` — hosts: ${json.hosts.join(", ")}` : ""}`);
178
+ }
179
+ async function peers(options) {
180
+ const { invites, peers } = links_1.default.list();
181
+ if (options.json) {
182
+ console.log(JSON.stringify({ invites, peers }, null, 2));
183
+ return;
184
+ }
185
+ if (!peers.length)
186
+ (0, shared_1.print)("No peers yet. Create an invite: locadot share --role viewer");
187
+ for (const peer of peers) {
188
+ (0, shared_1.print)(`${peer.id} ${peer.name} ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""} last seen: ${peer.lastSeen ?? "never"}`);
189
+ }
190
+ if (invites.length)
191
+ (0, shared_1.print)(`⏳ Pending invites: ${invites.length}`);
192
+ }
193
+ async function peersRole(id, role, options) {
194
+ const parsedRole = requireRole(role);
195
+ const hosts = parsedRole === "viewer" ? splitHosts(options.hosts) : undefined;
196
+ const peer = links_1.default.setRole(id, parsedRole, hosts);
197
+ (0, shared_1.print)(`✅ ${peer.name} is now ${peer.role}${peer.hosts ? ` [${peer.hosts.join(", ")}]` : ""}.`);
198
+ }
199
+ async function peersRevoke(id) {
200
+ links_1.default.revoke(id);
201
+ (0, shared_1.print)(`🗑️ Revoked peer ${id}.`);
202
+ }
203
+ async function connect(value, options) {
204
+ const { remote, mapped, skipped } = await remotes_1.default.connect(value, { name: options.name });
205
+ (0, shared_1.print)(`✅ Connected to ${remote.name} (${remote.sender.hostname}) as ${remote.role}.`);
206
+ for (const m of mapped)
207
+ (0, shared_1.print)(` ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
208
+ for (const reason of skipped)
209
+ (0, shared_1.print)(` ⚠️ skipped: ${reason}`);
210
+ }
211
+ async function remotes(options) {
212
+ const list = remotes_1.default.list();
213
+ const registry = registry_1.default.read();
214
+ const aliasesFor = (name) => Object.entries(registry.hosts)
215
+ .filter(([, entry]) => entry.remote?.name === name)
216
+ .map(([local, entry]) => ({ local, host: entry.remote.host }));
217
+ if (options.json) {
218
+ console.log(JSON.stringify(list.map(({ token, ...remote }) => ({ ...remote, aliases: aliasesFor(remote.name) })), null, 2));
219
+ return;
220
+ }
221
+ if (!list.length) {
222
+ (0, shared_1.print)("No remotes. Connect to one: locadot connect <pairing-string>");
223
+ return;
224
+ }
225
+ for (const remote of list) {
226
+ (0, shared_1.print)(`${remote.name} ${remote.url} (${remote.role})`);
227
+ for (const alias of aliasesFor(remote.name))
228
+ (0, shared_1.print)(` ${(0, urls_1.urlFor)(alias.local)} → ${alias.host}`);
229
+ }
230
+ }
231
+ async function remoteSync(name) {
232
+ const { mapped } = await remotes_1.default.sync(name);
233
+ (0, shared_1.print)(`✅ ${name} synced.`);
234
+ for (const m of mapped)
235
+ (0, shared_1.print)(` + ${(0, urls_1.urlFor)(m.local)} → ${m.host}`);
236
+ }
237
+ async function remoteAlias(name, remoteHost, localHost) {
238
+ await remotes_1.default.alias(name, remoteHost, localHost);
239
+ (0, shared_1.print)(`✅ ${(0, urls_1.urlFor)(localHost)} → ${remoteHost} (${name})`);
240
+ }
241
+ async function remoteUrl(name, url) {
242
+ let parsed;
243
+ try {
244
+ parsed = new URL(url);
245
+ }
246
+ catch {
247
+ throw new localhost_1.InputError(`❌ Invalid URL "${url}".`);
248
+ }
249
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
250
+ throw new localhost_1.InputError(`❌ Invalid URL "${url}". Use http:// or https://.`);
251
+ }
252
+ remotes_1.default.setUrl(name, url.replace(/\/+$/, ""));
253
+ (0, shared_1.print)(`✅ ${name} now points to ${parsed.origin}.`);
254
+ }
255
+ async function remoteAdd(name, options) {
256
+ if (!options.host)
257
+ throw new localhost_1.InputError("❌ Missing --host <host>.");
258
+ if (!options.target)
259
+ throw new localhost_1.InputError("❌ Missing --target <url>.");
260
+ await remotes_1.default.addHost(name, { host: options.host, target: options.target, insecure: options.insecure, cors: options.cors });
261
+ (0, shared_1.print)(`✅ ${options.host} → ${options.target} on ${name}.`);
262
+ }
263
+ async function remoteUpdate(name, options) {
264
+ if (!options.host)
265
+ throw new localhost_1.InputError("❌ Missing --host <host>.");
266
+ await remotes_1.default.updateHost(name, options.host, { target: options.target, cors: options.cors });
267
+ (0, shared_1.print)(`✅ Updated ${options.host} on ${name}.`);
268
+ }
269
+ async function remoteRm(name, host) {
270
+ await remotes_1.default.removeHost(name, host);
271
+ (0, shared_1.print)(`🗑️ Removed ${host} from ${name}.`);
272
+ }
273
+ async function disconnect(name) {
274
+ await remotes_1.default.disconnect(name);
275
+ (0, shared_1.print)(`🔌 Disconnected from ${name}.`);
276
+ }
@@ -38,6 +38,10 @@ Constants.paths = {
38
38
  // Secret for the dashboard's mutating API; rotated on every proxy start.
39
39
  API_TOKEN: path_1.default.join(PACKAGE_PATH, ".locadot-token"),
40
40
  CONFIG_FILE,
41
+ // Remote access. Sender: public hostname config, invites + peers (0600). Receiver: remotes (0600).
42
+ HUB_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-hub.json"),
43
+ LINKS_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-links.json"),
44
+ REMOTES_FILE: path_1.default.join(PACKAGE_PATH, ".locadot-remotes.json"),
41
45
  };
42
46
  Constants.validPort = validPort;
43
47
  Constants.server = {