locadot 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.1 (2026-09-30)
4
+
5
+ ### Fixed
6
+ - `startup:enable` on ports 80/443 now asks for admin rights. macOS only lets non-root bind ports below 1024 on
7
+ all interfaces, and locadot listens on loopback, so the login LaunchAgent couldn't bind; it now installs a root
8
+ LaunchDaemon (after a password prompt), and `sudo startup:enable` no longer drops the job to your user. On Linux
9
+ a terminal gets a plain `sudo` prompt instead of a desktop-only dialog, and a missing prompt fails with a clear
10
+ message. The dashboard and `doctor` report on macOS that 80/443 need root.
11
+
3
12
  ## 2.6.0 (2026-09-30)
4
13
 
5
14
  ### Added
package/README.md CHANGED
@@ -215,7 +215,7 @@ working on the same URL, since they authenticate with their own tokens. Some det
215
215
  | `locadot clear:logs` | Clear the log file. |
216
216
  | `locadot path` | Show every file locadot uses. `path:logs` and `path:hosts` print a single path. |
217
217
  | `locadot token` | Print the dashboard API token, for scripts and AI agents. |
218
- | `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron; macOS LaunchDaemon with `sudo`, running as you) or logon (macOS LaunchAgent without `sudo`; a hidden script in the Windows Startup folder, no admin needed). |
218
+ | `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron; macOS LaunchDaemon) or logon (macOS LaunchAgent; a hidden script in the Windows Startup folder, no admin needed). On ports below 1024 it asks for your password (sudo in a terminal, the system dialog from the dashboard), since macOS and Linux need root to bind them on loopback; the boot job then runs as root. |
219
219
 
220
220
  ---
221
221
 
@@ -11,6 +11,7 @@ const proxy_control_1 = __importDefault(require("../../lib/proxy-control"));
11
11
  const registry_1 = __importDefault(require("../../lib/registry"));
12
12
  const constants_1 = __importDefault(require("../../constants"));
13
13
  const trust_1 = require("../../utils/trust");
14
+ const startup_1 = require("../../utils/startup");
14
15
  const shared_1 = require("../shared");
15
16
  async function doctor(options) {
16
17
  let failures = 0;
@@ -46,6 +47,9 @@ async function doctor(options) {
46
47
  const canBind = process.getuid?.() === 0 || unprivileged <= Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort);
47
48
  check(canBind || Boolean(info), "Allowed to bind ports 80/443", "Run with sudo, or allow it once: `sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80` (persist in /etc/sysctl.d/).");
48
49
  }
50
+ if (os_1.default.platform() === "darwin" && (0, startup_1.macLowPortsNeedRoot)()) {
51
+ check(process.getuid?.() === 0 || Boolean(info), "Allowed to bind ports 80/443", "macOS lets non-root bind ports below 1024 only on all interfaces, and locadot listens on loopback. Run `sudo locadot start`.");
52
+ }
49
53
  const trusted = await (0, trust_1.isCATrusted)().catch(() => false);
50
54
  check(trusted, "Locadot CA trusted by the system", `Run \`locadot trust\`. Manual steps:\n${(0, trust_1.trustInstructions)()}`);
51
55
  try {
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -8,7 +41,7 @@ exports.systemStatus = systemStatus;
8
41
  const fs_1 = __importDefault(require("fs"));
9
42
  const child_process_1 = require("child_process");
10
43
  const constants_1 = __importDefault(require("../constants"));
11
- const startup_1 = __importDefault(require("../utils/startup"));
44
+ const startup_1 = __importStar(require("../utils/startup"));
12
45
  const trust_1 = require("../utils/trust");
13
46
  let windowsAdmin;
14
47
  /** Root on POSIX; on Windows `net session` only succeeds in an elevated shell. */
@@ -38,9 +71,13 @@ const unprivilegedPortStart = () => {
38
71
  }
39
72
  };
40
73
  exports.unprivilegedPortStart = unprivilegedPortStart;
41
- /** macOS (since 10.14) and Windows let any user bind 80/443; Linux depends on the sysctl. */
74
+ /** Windows lets any user bind 80/443; macOS only on the wildcard address; Linux depends on the sysctl. */
42
75
  const canBindPrivileged = () => {
43
- if (process.platform !== "linux" || (0, exports.isElevated)())
76
+ if (process.platform === "win32" || (0, exports.isElevated)())
77
+ return true;
78
+ if (process.platform === "darwin")
79
+ return !(0, startup_1.macLowPortsNeedRoot)();
80
+ if (process.platform !== "linux")
44
81
  return true;
45
82
  const start = (0, exports.unprivilegedPortStart)();
46
83
  return start !== null && start <= Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort);
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.windowsLauncherScript = exports.macPlist = exports.macDirs = void 0;
6
+ exports.macLowPortsNeedRoot = exports.privilege = exports.elevate = exports.windowsLauncherScript = exports.macPlist = exports.macDirs = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const os_1 = __importDefault(require("os"));
9
9
  const fs_1 = __importDefault(require("fs"));
@@ -135,6 +135,38 @@ function execSudo(cmd) {
135
135
  });
136
136
  });
137
137
  }
138
+ /**
139
+ * In a terminal, plain sudo asks for the password right there. Elsewhere (the dashboard's proxy) sudo-prompt shows
140
+ * the system dialog: osascript on macOS, pkexec or kdesudo on Linux, which servers and SSH sessions often lack.
141
+ */
142
+ const elevate = async (cmd, command) => {
143
+ if (process.stdin.isTTY) {
144
+ logger_1.default.info("The boot job for ports below 1024 runs as root; sudo will ask for your password.");
145
+ const result = (0, child_process_1.spawnSync)("sudo", ["sh", "-c", cmd], { stdio: "inherit", windowsHide: true });
146
+ if (!result.error) {
147
+ if (result.status === 0)
148
+ return;
149
+ throw new Error(`sudo failed (${result.status ?? result.signal}).`);
150
+ }
151
+ }
152
+ try {
153
+ await execSudo(`sh -c ${quote(cmd)}`);
154
+ }
155
+ catch (error) {
156
+ throw new Error(`This needs admin rights and no password prompt is available here (${String(error?.message || error).trim()}). ` +
157
+ `Run \`${command}\` in a terminal.`);
158
+ }
159
+ };
160
+ exports.elevate = elevate;
161
+ // Tests swap this out.
162
+ exports.privilege = { run: exports.elevate };
163
+ /**
164
+ * macOS (10.14+) lets any user bind ports below 1024, but only on the wildcard address. locadot listens on loopback
165
+ * by default, so 80/443 still need root there.
166
+ */
167
+ const macLowPortsNeedRoot = () => Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort) < 1024 &&
168
+ constants_1.default.server.bind.some((host) => host !== "0.0.0.0" && host !== "::");
169
+ exports.macLowPortsNeedRoot = macLowPortsNeedRoot;
138
170
  /** Linux needs root for ports below 1024 unless the sysctl allows otherwise. */
139
171
  const linuxNeedsRoot = () => {
140
172
  if (process.getuid?.() === 0)
@@ -168,7 +200,7 @@ class Startup {
168
200
  const line = `@reboot ${env} ${[command, ...args].map(quote).join(" ")} >> ${quote(constants_1.default.paths.LOGS)} 2>&1 ${CRON_MARK}`;
169
201
  const edit = cronEdit(line);
170
202
  if (linuxNeedsRoot()) {
171
- await execSudo(`sh -c ${quote(edit)}`);
203
+ await exports.privilege.run(edit, "locadot startup:enable");
172
204
  method = "root-crontab";
173
205
  }
174
206
  else {
@@ -182,23 +214,41 @@ class Startup {
182
214
  // LaunchAgents at login and LaunchDaemons at boot on its own.
183
215
  const target = macTarget();
184
216
  const argv = [command, ...args.slice(0, -1), target.state];
217
+ const needsRoot = (0, exports.macLowPortsNeedRoot)();
185
218
  if (target.root) {
186
- // Root mode: a LaunchDaemon starts at boot, before anyone logs in, as the sudo user when there is one.
219
+ // Root mode: a LaunchDaemon starts at boot, before anyone logs in, as the sudo user when there is one
220
+ // and the ports don't need root.
221
+ const user = needsRoot ? undefined : target.user;
187
222
  fs_1.default.mkdirSync(exports.macDirs.daemons, { recursive: true });
188
- fs_1.default.writeFileSync(daemonPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, user: target.user, home: target.user ? target.home : undefined, env: userPortVars() }));
223
+ fs_1.default.writeFileSync(daemonPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, user, home: target.user ? target.home : undefined, env: userPortVars() }));
189
224
  // launchd skips daemon plists that aren't root-owned or are group/world writable.
190
225
  fs_1.default.chmodSync(daemonPath(), 0o644);
191
226
  fs_1.default.rmSync(agentPath(target.home), { force: true });
192
227
  launchctlEnable("system");
193
228
  method = "launch-daemon";
194
229
  }
230
+ else if (needsRoot) {
231
+ // A LaunchAgent runs as the user and couldn't bind, so install a root LaunchDaemon, asking for the password.
232
+ const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "locadot-daemon-"));
233
+ const tmp = path_1.default.join(dir, `${MAC_LABEL}.plist`);
234
+ fs_1.default.writeFileSync(tmp, (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, home: target.home, env: userPortVars() }));
235
+ try {
236
+ await exports.privilege.run(`mkdir -p ${quote(exports.macDirs.daemons)} && install -m 644 -o root -g wheel ${quote(tmp)} ${quote(daemonPath())} && ` +
237
+ `(launchctl enable system/${MAC_LABEL} || true)`, "locadot startup:enable");
238
+ }
239
+ finally {
240
+ fs_1.default.rmSync(dir, { recursive: true, force: true });
241
+ }
242
+ fs_1.default.rmSync(agentPath(target.home), { force: true });
243
+ method = "launch-daemon";
244
+ }
195
245
  else {
196
246
  // macOS (10.14+) lets unprivileged processes bind 80/443, so a user LaunchAgent is enough. It starts at login.
197
247
  fs_1.default.mkdirSync(path_1.default.dirname(agentPath()), { recursive: true });
198
248
  fs_1.default.writeFileSync(agentPath(), (0, exports.macPlist)(MAC_LABEL, argv, { state: target.state, env: userPortVars() }));
199
249
  fs_1.default.chmodSync(agentPath(), 0o644);
200
250
  if (fs_1.default.existsSync(daemonPath()))
201
- await execSudo(`rm -f ${quote(daemonPath())}`);
251
+ await exports.privilege.run(`rm -f ${quote(daemonPath())}`, "locadot startup:enable");
202
252
  launchctlEnable(`gui/${process.getuid?.()}`);
203
253
  method = "launch-agent";
204
254
  }
@@ -230,7 +280,7 @@ class Startup {
230
280
  break;
231
281
  case "linux":
232
282
  if (method === "root-crontab")
233
- await execSudo(`sh -c ${quote(cronEdit())}`);
283
+ await exports.privilege.run(cronEdit(), "locadot startup:disable");
234
284
  else
235
285
  (0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit", windowsHide: true });
236
286
  break;
@@ -242,7 +292,7 @@ class Startup {
242
292
  if (target.root)
243
293
  fs_1.default.rmSync(daemonPath(), { force: true });
244
294
  else
245
- await execSudo(`rm -f ${quote(daemonPath())}`);
295
+ await exports.privilege.run(`rm -f ${quote(daemonPath())}`, "locadot startup:disable");
246
296
  }
247
297
  fs_1.default.rmSync(markerIn(target.state), { force: true });
248
298
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "description": "Secure your local development environment with HTTPS and custom domains like dev.localhost.",
5
5
  "homepage": "https://www.npmjs.com/package/locadot",
6
6
  "main": "dist/index.js",