locadot 2.0.0 → 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/dist/index.js CHANGED
@@ -9,6 +9,8 @@ const commands_1 = __importDefault(require("./cli/commands"));
9
9
  const localhost_1 = require("./lib/localhost");
10
10
  const registry_1 = require("./lib/registry");
11
11
  const proxy_control_1 = require("./lib/proxy-control");
12
+ const links_1 = require("./lib/links");
13
+ const remotes_1 = require("./lib/remotes");
12
14
  const logger_1 = __importDefault(require("./utils/logger"));
13
15
  const package_json_1 = require("../package.json");
14
16
  const program = new commander_1.Command();
@@ -20,7 +22,11 @@ const run = (fn, { exit = true } = {}) => async (...args) => {
20
22
  process.exit(process.exitCode ?? 0);
21
23
  }
22
24
  catch (error) {
23
- const known = error instanceof localhost_1.InputError || error instanceof proxy_control_1.ProxyError || error instanceof registry_1.RegistryError;
25
+ const known = error instanceof localhost_1.InputError ||
26
+ error instanceof proxy_control_1.ProxyError ||
27
+ error instanceof registry_1.RegistryError ||
28
+ error instanceof links_1.LinkError ||
29
+ error instanceof remotes_1.RemoteError;
24
30
  logger_1.default.error(known ? error.message : `❌ ${error?.stack || error}`);
25
31
  process.exit(1);
26
32
  }
@@ -104,4 +110,83 @@ program.command("path:hosts").description("Show the registry file path").action(
104
110
  program.command("startup:enable").description("Start locadot on boot/logon").action(run(() => commands_1.default.enableStartup()));
105
111
  program.command("startup:disable").description("Don't start locadot on boot/logon").action(run(() => commands_1.default.disableStartup()));
106
112
  program.command("startup:status").description("Is start on boot enabled?").action(run(() => commands_1.default.statusStartup()));
113
+ const roleOption = () => new commander_1.Option("-r, --role <role>", "viewer, editor or admin").choices(["viewer", "editor", "admin"]).makeOptionMandatory();
114
+ program
115
+ .command("hub")
116
+ .description("Show remote access status")
117
+ .option("--json", "Machine-readable output")
118
+ .action(run((options) => commands_1.default.hub(options)));
119
+ program
120
+ .command("hub:setup")
121
+ .description("Share this locadot on your own domain via a named Cloudflare tunnel")
122
+ .requiredOption("-d, --domain <domain>", "Public domain, e.g. hub.example.com")
123
+ .option("-t, --tunnel <name>", "Cloudflare tunnel name", "locadot")
124
+ .action(run((options) => commands_1.default.hubSetup(options)));
125
+ program
126
+ .command("hub:quick")
127
+ .description("Share this locadot on a random trycloudflare.com URL (no account, URL changes on restart)")
128
+ .action(run(() => commands_1.default.hubQuick()));
129
+ program
130
+ .command("hub:manual")
131
+ .description("Share this locadot behind a URL something else already forwards (ngrok, a reverse proxy, tests)")
132
+ .requiredOption("-u, --url <url>", "Public base URL that forwards to this proxy's HTTP port")
133
+ .action(run((options) => commands_1.default.hubManual(options)));
134
+ program.command("hub:off").description("Turn off remote access").action(run(() => commands_1.default.hubOff()));
135
+ program
136
+ .command("share")
137
+ .description("Create a one-time pairing code for someone to connect to this locadot")
138
+ .addOption(roleOption())
139
+ .option("--hosts <hosts>", "Comma separated hosts the viewer may see (viewer role only)")
140
+ .action(run((options) => commands_1.default.share(options)));
141
+ program
142
+ .command("peers")
143
+ .description("List everyone paired with this locadot")
144
+ .option("--json", "Machine-readable output")
145
+ .action(run((options) => commands_1.default.peers(options)));
146
+ program
147
+ .command("peers:role <id> <role>")
148
+ .description("Change a peer's role")
149
+ .option("--hosts <hosts>", "Comma separated hosts (viewer role only)")
150
+ .action(run((id, role, options) => commands_1.default.peersRole(id, role, options)));
151
+ program.command("peers:revoke <id>").description("Revoke a peer").action(run((id) => commands_1.default.peersRevoke(id)));
152
+ program
153
+ .command("connect <string>")
154
+ .description("Connect to a remote locadot using its pairing string")
155
+ .option("--name <name>", "Local name for this remote (defaults to the sender's hostname)")
156
+ .action(run((value, options) => commands_1.default.connect(value, options)));
157
+ program
158
+ .command("remotes")
159
+ .description("List remotes this locadot is connected to")
160
+ .option("--json", "Machine-readable output")
161
+ .action(run((options) => commands_1.default.remotes(options)));
162
+ program.command("remote:sync <name>").description("Refresh role and available hosts for a remote").action(run((name) => commands_1.default.remoteSync(name)));
163
+ program
164
+ .command("remote:alias <name> <remoteHost> <localHost>")
165
+ .description("Map a local .localhost name to a host on a remote")
166
+ .action(run((name, remoteHost, localHost) => commands_1.default.remoteAlias(name, remoteHost, localHost)));
167
+ program
168
+ .command("remote:url <name> <url>")
169
+ .description("Update a remote's URL (e.g. after its quick tunnel changed)")
170
+ .action(run((name, url) => commands_1.default.remoteUrl(name, url)));
171
+ program
172
+ .command("remote:add <name>")
173
+ .description("Add a mapping on a remote")
174
+ .addOption(hostOption())
175
+ .requiredOption("-t, --target <url>", "Any upstream: 3000, 127.0.0.1:8080, http://192.168.1.5:8080, https://google.com")
176
+ .option("-k, --insecure", "Don't verify the TLS certificate of an https target")
177
+ .option("--cors", "Send Origin/Referer as the target's own and let any origin call this domain")
178
+ .action(run((name, options) => commands_1.default.remoteAdd(name, options)));
179
+ program
180
+ .command("remote:update <name>")
181
+ .description("Update a mapping on a remote")
182
+ .addOption(hostOption())
183
+ .option("-t, --target <url>", "New upstream")
184
+ .option("--cors", "Send Origin/Referer as the target's own and let any origin call this domain")
185
+ .option("--no-cors", "Turn --cors off again")
186
+ .action(run((name, options) => commands_1.default.remoteUpdate(name, options)));
187
+ program
188
+ .command("remote:rm <name> <host>")
189
+ .description("Remove a mapping on a remote")
190
+ .action(run((name, host) => commands_1.default.remoteRm(name, host)));
191
+ program.command("disconnect <name>").description("Disconnect from a remote").action(run((name) => commands_1.default.disconnect(name)));
107
192
  program.parseAsync(process.argv);
@@ -0,0 +1,28 @@
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
+ const file_1 = __importDefault(require("../utils/file"));
7
+ /** Persists how the sender is reachable (`HUB_FILE`). No secrets in here; see links.ts for those. */
8
+ class HubConfigStore {
9
+ static read() {
10
+ const raw = file_1.default.read("HUB_FILE");
11
+ if (!raw)
12
+ return undefined;
13
+ try {
14
+ const data = JSON.parse(raw);
15
+ return data && typeof data === "object" ? data : undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ static write(config) {
22
+ file_1.default.writeAtomic("HUB_FILE", JSON.stringify(config, null, 2));
23
+ }
24
+ static clear() {
25
+ file_1.default.remove("HUB_FILE");
26
+ }
27
+ }
28
+ exports.default = HubConfigStore;
@@ -0,0 +1,163 @@
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.LinkError = exports.INVITE_TTL_MS = void 0;
7
+ const crypto_1 = __importDefault(require("crypto"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const constants_1 = __importDefault(require("../constants"));
10
+ const file_1 = __importDefault(require("../utils/file"));
11
+ exports.INVITE_TTL_MS = 5 * 60000;
12
+ class LinkError extends Error {
13
+ constructor(status, message) {
14
+ super(message);
15
+ this.status = status;
16
+ }
17
+ }
18
+ exports.LinkError = LinkError;
19
+ const sha256Hex = (value) => crypto_1.default.createHash("sha256").update(value).digest("hex");
20
+ /** Constant-time compare of a stored hex digest against a fresh secret. */
21
+ const matchesHash = (storedHex, secret) => {
22
+ const stored = Buffer.from(storedHex, "hex");
23
+ const candidate = Buffer.from(sha256Hex(secret), "hex");
24
+ return stored.length === candidate.length && crypto_1.default.timingSafeEqual(stored, candidate);
25
+ };
26
+ const load = () => {
27
+ const raw = file_1.default.read("LINKS_FILE");
28
+ if (!raw)
29
+ return { invites: [], peers: [] };
30
+ try {
31
+ const data = JSON.parse(raw);
32
+ return {
33
+ invites: Array.isArray(data?.invites) ? data.invites : [],
34
+ peers: Array.isArray(data?.peers) ? data.peers : [],
35
+ };
36
+ }
37
+ catch {
38
+ return { invites: [], peers: [] };
39
+ }
40
+ };
41
+ const save = (data) => {
42
+ file_1.default.writeAtomic("LINKS_FILE", JSON.stringify(data, null, 2));
43
+ fs_1.default.chmodSync(constants_1.default.paths.LINKS_FILE, 0o600);
44
+ };
45
+ const CODE_PATTERN = /^lnk_([0-9a-f]{8})\.(.+)$/i;
46
+ const TOKEN_PATTERN = /^lpt_([0-9a-f]{8})\.(.+)$/i;
47
+ class Links {
48
+ static createInvite(input) {
49
+ const id = crypto_1.default.randomBytes(4).toString("hex");
50
+ const secret = crypto_1.default.randomBytes(32).toString("base64url");
51
+ const now = new Date();
52
+ const invite = {
53
+ id,
54
+ codeHash: sha256Hex(secret),
55
+ role: input.role,
56
+ hosts: input.hosts,
57
+ createdAt: now.toISOString(),
58
+ expiresAt: new Date(now.getTime() + exports.INVITE_TTL_MS).toISOString(),
59
+ };
60
+ const data = load();
61
+ data.invites.push(invite);
62
+ save(data);
63
+ return { invite, code: `lnk_${id}.${secret}` };
64
+ }
65
+ static inviteString(baseUrl, code) {
66
+ return `${baseUrl.replace(/\/$/, "")}/#${code}`;
67
+ }
68
+ /** One-time: the invite is removed once a matching id is resolved, success or not. */
69
+ static redeem(code, name) {
70
+ const match = CODE_PATTERN.exec(String(code || "").trim());
71
+ if (!match)
72
+ throw new LinkError(401, "Invalid or expired code.");
73
+ const [, id, secret] = match;
74
+ const data = load();
75
+ const index = data.invites.findIndex((invite) => invite.id === id);
76
+ if (index === -1)
77
+ throw new LinkError(401, "Invalid or expired code.");
78
+ const invite = data.invites[index];
79
+ data.invites.splice(index, 1);
80
+ save(data);
81
+ if (Date.parse(invite.expiresAt) < Date.now())
82
+ throw new LinkError(401, "Invalid or expired code.");
83
+ if (!matchesHash(invite.codeHash, secret))
84
+ throw new LinkError(401, "Invalid or expired code.");
85
+ const peerId = crypto_1.default.randomBytes(4).toString("hex");
86
+ const tokenSecret = crypto_1.default.randomBytes(32).toString("base64url");
87
+ const peer = {
88
+ id: peerId,
89
+ name: String(name || "peer"),
90
+ role: invite.role,
91
+ hosts: invite.hosts,
92
+ tokenHash: sha256Hex(tokenSecret),
93
+ createdAt: new Date().toISOString(),
94
+ };
95
+ const fresh = load();
96
+ fresh.peers.push(peer);
97
+ save(fresh);
98
+ return { peer, token: `lpt_${peerId}.${tokenSecret}` };
99
+ }
100
+ /** Updates lastSeen at most once a minute so every proxied request doesn't rewrite the file. */
101
+ static authenticate(token) {
102
+ if (!token)
103
+ return undefined;
104
+ const match = TOKEN_PATTERN.exec(token.trim());
105
+ if (!match)
106
+ return undefined;
107
+ const [, id, secret] = match;
108
+ const data = load();
109
+ const peer = data.peers.find((p) => p.id === id);
110
+ if (!peer || !matchesHash(peer.tokenHash, secret))
111
+ return undefined;
112
+ const now = Date.now();
113
+ if (!peer.lastSeen || now - Date.parse(peer.lastSeen) >= 60000) {
114
+ peer.lastSeen = new Date(now).toISOString();
115
+ save(data);
116
+ }
117
+ return peer;
118
+ }
119
+ static list() {
120
+ const data = load();
121
+ const now = Date.now();
122
+ const invites = data.invites.filter((invite) => Date.parse(invite.expiresAt) >= now);
123
+ if (invites.length !== data.invites.length)
124
+ save({ ...data, invites });
125
+ return { invites, peers: data.peers };
126
+ }
127
+ static setRole(peerId, role, hosts) {
128
+ const data = load();
129
+ const peer = data.peers.find((p) => p.id === peerId);
130
+ if (!peer)
131
+ throw new LinkError(404, "Peer not found.");
132
+ peer.role = role;
133
+ peer.hosts = hosts;
134
+ save(data);
135
+ return peer;
136
+ }
137
+ static revoke(peerId) {
138
+ const data = load();
139
+ const peers = data.peers.filter((p) => p.id !== peerId);
140
+ if (peers.length !== data.peers.length)
141
+ save({ ...data, peers });
142
+ }
143
+ static revokeInvite(id) {
144
+ const data = load();
145
+ const invites = data.invites.filter((invite) => invite.id !== id);
146
+ if (invites.length !== data.invites.length)
147
+ save({ ...data, invites });
148
+ }
149
+ static can(peer, permission) {
150
+ if (permission === "read")
151
+ return true;
152
+ if (permission === "write")
153
+ return peer.role === "editor" || peer.role === "admin";
154
+ return peer.role === "admin"; // delete, settings
155
+ }
156
+ static visibleHosts(peer, all) {
157
+ if (peer.role !== "viewer")
158
+ return all;
159
+ const mine = new Set(peer.hosts || []);
160
+ return all.filter((host) => mine.has(host));
161
+ }
162
+ }
163
+ exports.default = Links;
@@ -76,7 +76,7 @@ class Localhost {
76
76
  return ["localhost", "127.0.0.1", "::1", "[::1]"].includes(hostname) || hostname.endsWith(".localhost");
77
77
  }
78
78
  /** Any HTTP response (even 404/500) counts as up: the upstream is reachable. */
79
- static probe(target, timeoutMs = 1500, insecure = false) {
79
+ static probe(target, timeoutMs = 1500, insecure = false, headers = {}) {
80
80
  const started = Date.now();
81
81
  return new Promise((resolve) => {
82
82
  let url;
@@ -87,7 +87,7 @@ class Localhost {
87
87
  return resolve({ up: false, error: "invalid target" });
88
88
  }
89
89
  const client = url.protocol === "https:" ? https_1.default : http_1.default;
90
- const req = client.request(url, { method: "HEAD", timeout: timeoutMs, rejectUnauthorized: !insecure }, (res) => {
90
+ const req = client.request(url, { method: "HEAD", timeout: timeoutMs, rejectUnauthorized: !insecure, headers }, (res) => {
91
91
  res.resume();
92
92
  resolve({ up: true, status: res.statusCode, ms: Date.now() - started });
93
93
  });
@@ -0,0 +1,272 @@
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.RemoteError = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const constants_1 = __importDefault(require("../constants"));
10
+ const file_1 = __importDefault(require("../utils/file"));
11
+ const registry_1 = __importDefault(require("./registry"));
12
+ const localhost_1 = __importDefault(require("./localhost"));
13
+ const hosts_1 = require("./hosts");
14
+ class RemoteError extends Error {
15
+ constructor(status, message) {
16
+ super(message);
17
+ this.status = status;
18
+ }
19
+ }
20
+ exports.RemoteError = RemoteError;
21
+ const apiBase = (url) => `${url}/_locadot/v1`;
22
+ const safeJson = (text) => {
23
+ try {
24
+ return JSON.parse(text);
25
+ }
26
+ catch {
27
+ return undefined;
28
+ }
29
+ };
30
+ /** Talks to a sender's `/_locadot/v1` API. Never logs the body: it may carry the token. */
31
+ async function call(url, opts = {}) {
32
+ let res;
33
+ try {
34
+ res = await fetch(url, {
35
+ method: opts.method || "GET",
36
+ headers: {
37
+ "Content-Type": "application/json",
38
+ ...(opts.token ? { Authorization: `Bearer ${opts.token}` } : {}),
39
+ },
40
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
41
+ signal: AbortSignal.timeout(10000),
42
+ });
43
+ }
44
+ catch (error) {
45
+ throw new RemoteError(0, `can't reach ${url}: ${error?.message || error}`);
46
+ }
47
+ const text = await res.text();
48
+ const data = text ? safeJson(text) : undefined;
49
+ if (!res.ok)
50
+ throw new RemoteError(res.status, (data && data.error) || res.statusText);
51
+ return data;
52
+ }
53
+ const readFile = () => {
54
+ const raw = file_1.default.read("REMOTES_FILE");
55
+ if (!raw)
56
+ return { remotes: {} };
57
+ const data = safeJson(raw);
58
+ if (data && typeof data.remotes === "object")
59
+ return data;
60
+ return { remotes: {} };
61
+ };
62
+ const writeFile = (data) => {
63
+ file_1.default.writeAtomic("REMOTES_FILE", JSON.stringify(data, null, 2));
64
+ fs_1.default.chmodSync(constants_1.default.paths.REMOTES_FILE, 0o600);
65
+ };
66
+ const toLabel = (raw) => {
67
+ const label = raw
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9-]+/g, "-")
70
+ .replace(/^-+|-+$/g, "")
71
+ .slice(0, 30);
72
+ return label || "remote";
73
+ };
74
+ const uniqueName = (base, existing) => {
75
+ if (!existing.has(base))
76
+ return base;
77
+ let i = 2;
78
+ while (existing.has(`${base}-${i}`))
79
+ i++;
80
+ return `${base}-${i}`;
81
+ };
82
+ class Remotes {
83
+ static parseInvite(value) {
84
+ const trimmed = String(value || "").trim();
85
+ const idx = trimmed.lastIndexOf("/#");
86
+ if (idx === -1)
87
+ throw new RemoteError(400, "Invalid pairing string.");
88
+ const url = trimmed.slice(0, idx).replace(/\/+$/, "");
89
+ const code = trimmed.slice(idx + 2);
90
+ if (!/^lnk_[0-9a-f]{8}\.[A-Za-z0-9_-]+$/.test(code))
91
+ throw new RemoteError(400, "Invalid pairing code.");
92
+ let parsed;
93
+ try {
94
+ parsed = new URL(url);
95
+ }
96
+ catch {
97
+ throw new RemoteError(400, "Invalid pairing URL.");
98
+ }
99
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
100
+ throw new RemoteError(400, "Invalid pairing URL.");
101
+ return { url, code };
102
+ }
103
+ static list() {
104
+ return Object.values(readFile().remotes);
105
+ }
106
+ static get(name) {
107
+ return readFile().remotes[name];
108
+ }
109
+ static mustGet(name) {
110
+ const remote = Remotes.get(name);
111
+ if (!remote)
112
+ throw new RemoteError(404, `No remote named "${name}".`);
113
+ return remote;
114
+ }
115
+ static async connect(value, opts = {}) {
116
+ const { url, code } = Remotes.parseInvite(value);
117
+ // Checked before redeeming: the code works once, and the name ends up in hostnames (app.<name>.localhost).
118
+ const wanted = opts.name?.trim().toLowerCase();
119
+ if (wanted && !/^[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/.test(wanted)) {
120
+ throw new RemoteError(400, "Name must be a DNS label: a-z, 0-9 and -, up to 30 characters.");
121
+ }
122
+ const data = await call(`${apiBase(url)}/connect`, { method: "POST", body: { code, name: os_1.default.hostname() } });
123
+ const store = readFile();
124
+ const existingNames = new Set(Object.keys(store.remotes));
125
+ const base = wanted || toLabel(data.sender.hostname);
126
+ const name = uniqueName(base, existingNames);
127
+ const remote = {
128
+ name,
129
+ url,
130
+ token: data.token,
131
+ peerId: data.peer.id,
132
+ role: data.peer.role,
133
+ hosts: data.peer.hosts,
134
+ sender: data.sender,
135
+ connectedAt: new Date().toISOString(),
136
+ };
137
+ store.remotes[name] = remote;
138
+ writeFile(store);
139
+ const hostsRes = await call(`${apiBase(url)}/hosts`, { token: remote.token });
140
+ const hosts = hostsRes.hosts;
141
+ const now = new Date().toISOString();
142
+ const mapped = [];
143
+ const skipped = [];
144
+ await registry_1.default.mutate((registry) => {
145
+ const taken = new Set(Object.keys(registry.hosts));
146
+ for (const h of hosts) {
147
+ const local = Remotes.localName(name, h.host, taken);
148
+ if (!local) {
149
+ skipped.push(h.host);
150
+ continue;
151
+ }
152
+ taken.add(local);
153
+ registry.hosts[local] = { target: remote.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
154
+ mapped.push({ local, host: h.host });
155
+ }
156
+ });
157
+ return { remote, hosts, mapped, skipped };
158
+ }
159
+ static async sync(name) {
160
+ const remote = Remotes.mustGet(name);
161
+ const who = await call(`${apiBase(remote.url)}/whoami`, { token: remote.token });
162
+ const updated = { ...remote, role: who.peer.role, hosts: who.peer.hosts, sender: who.sender };
163
+ const store = readFile();
164
+ store.remotes[name] = updated;
165
+ writeFile(store);
166
+ const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
167
+ const hosts = hostsRes.hosts;
168
+ const now = new Date().toISOString();
169
+ await registry_1.default.mutate((registry) => {
170
+ const already = new Set();
171
+ for (const entry of Object.values(registry.hosts)) {
172
+ if (entry.remote?.name === name)
173
+ already.add(entry.remote.host);
174
+ }
175
+ const taken = new Set(Object.keys(registry.hosts));
176
+ for (const h of hosts) {
177
+ if (already.has(h.host))
178
+ continue;
179
+ const local = Remotes.localName(name, h.host, taken);
180
+ if (!local)
181
+ continue;
182
+ taken.add(local);
183
+ registry.hosts[local] = { target: updated.url, remote: { name, host: h.host }, createdAt: now, updatedAt: now };
184
+ }
185
+ });
186
+ const registry = registry_1.default.read();
187
+ const mapped = Object.entries(registry.hosts)
188
+ .filter(([, e]) => e.remote?.name === name)
189
+ .map(([local, e]) => ({ local, host: e.remote.host }));
190
+ return { remote: updated, hosts, mapped };
191
+ }
192
+ static async alias(name, remoteHost, localHost) {
193
+ const remote = Remotes.mustGet(name);
194
+ const local = localhost_1.default.requireHost(localHost);
195
+ const hostsRes = await call(`${apiBase(remote.url)}/hosts`, { token: remote.token });
196
+ const hosts = hostsRes.hosts;
197
+ if (!hosts.some((h) => h.host === remoteHost))
198
+ throw new hosts_1.NotFoundError(`"${remoteHost}" is not available on "${name}".`);
199
+ const now = new Date().toISOString();
200
+ await registry_1.default.mutate((registry) => {
201
+ if (registry.hosts[local])
202
+ throw new hosts_1.ConflictError(`❌ ${local} is already mapped to ${registry.hosts[local].target}.`);
203
+ registry.hosts[local] = { target: remote.url, remote: { name, host: remoteHost }, createdAt: now, updatedAt: now };
204
+ });
205
+ }
206
+ static setUrl(name, url) {
207
+ const store = readFile();
208
+ const remote = store.remotes[name];
209
+ if (!remote)
210
+ throw new RemoteError(404, `No remote named "${name}".`);
211
+ store.remotes[name] = { ...remote, url: url.replace(/\/+$/, "") };
212
+ writeFile(store);
213
+ }
214
+ static async disconnect(name) {
215
+ const store = readFile();
216
+ delete store.remotes[name];
217
+ writeFile(store);
218
+ await registry_1.default.mutate((registry) => {
219
+ for (const [host, entry] of Object.entries(registry.hosts)) {
220
+ if (entry.remote?.name === name)
221
+ delete registry.hosts[host];
222
+ }
223
+ });
224
+ }
225
+ static async addHost(name, input) {
226
+ const remote = Remotes.mustGet(name);
227
+ const data = await call(`${apiBase(remote.url)}/hosts`, { method: "POST", token: remote.token, body: input });
228
+ const created = data.host;
229
+ const now = new Date().toISOString();
230
+ await registry_1.default.mutate((registry) => {
231
+ const taken = new Set(Object.keys(registry.hosts));
232
+ const local = Remotes.localName(name, created.host, taken);
233
+ if (local)
234
+ registry.hosts[local] = { target: remote.url, remote: { name, host: created.host }, createdAt: now, updatedAt: now };
235
+ });
236
+ return created;
237
+ }
238
+ static async updateHost(name, host, input) {
239
+ const remote = Remotes.mustGet(name);
240
+ const data = await call(`${apiBase(remote.url)}/hosts/${encodeURIComponent(host)}`, {
241
+ method: "PUT",
242
+ token: remote.token,
243
+ body: input,
244
+ });
245
+ return data.host;
246
+ }
247
+ static async removeHost(name, host) {
248
+ const remote = Remotes.mustGet(name);
249
+ await call(`${apiBase(remote.url)}/hosts/${encodeURIComponent(host)}`, { method: "DELETE", token: remote.token });
250
+ await registry_1.default.mutate((registry) => {
251
+ for (const [local, entry] of Object.entries(registry.hosts)) {
252
+ if (entry.remote?.name === name && entry.remote?.host === host)
253
+ delete registry.hosts[local];
254
+ }
255
+ });
256
+ }
257
+ /** app.localhost → itself if free, else the remote name inserted before "localhost"; undefined if both are taken. */
258
+ static localName(remoteName, host, taken) {
259
+ const direct = localhost_1.default.normalizeHost(host);
260
+ if (!direct)
261
+ return undefined;
262
+ if (!taken.has(direct))
263
+ return direct;
264
+ const labels = direct.split(".");
265
+ labels.splice(labels.length - 1, 0, remoteName);
266
+ const candidate = localhost_1.default.normalizeHost(labels.join("."));
267
+ if (!candidate || taken.has(candidate))
268
+ return undefined;
269
+ return candidate;
270
+ }
271
+ }
272
+ exports.default = Remotes;