locadot 1.6.0-beta.0 → 1.6.0-beta.2

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- The version in `package.json` is still 1.5.7. The owner decides the bump. **2.0.0** is suggested because of the
5
+ The version in `package.json` is 1.6.0-beta.2 (published under the `beta` tag). The owner decides the bump. **2.0.0** is suggested because of the
6
6
  breaking items below.
7
7
 
8
8
  ### Breaking
@@ -28,6 +28,9 @@ breaking items below.
28
28
  - Test suite (`pnpm test`) and a `prepublishOnly` build.
29
29
 
30
30
  ### Fixed
31
+ - Windows: `startup:enable` no longer needs admin rights. It used to fail with "Access is denied". Start at logon now uses the user's Startup folder instead of a scheduled task. (BUG-14)
32
+ - Windows: console windows no longer flash up from the background proxy's `certutil`/`schtasks` checks. Start at logon now runs hidden and writes to the log. (BUG-13)
33
+ - Upstream `Connection`/`Upgrade` headers are no longer forwarded. Apache sites proxied through locadot kept dropping the connection and lost assets. (BUG-12)
31
34
  - `stop`, `restart` and `kill` now actually stop the proxy. (BUG-01)
32
35
  - A failed start is reported with the log tail and exits 1. (BUG-07)
33
36
  - `update` accepts http targets and `add` accepts https ones. (BUG-03)
package/README.md CHANGED
@@ -82,7 +82,7 @@ Options for `add` / `update`:
82
82
  | `locadot clear:logs` | Clear the log file. |
83
83
  | `locadot path` | Show every file locadot uses. `path:logs` and `path:hosts` print a single path. |
84
84
  | `locadot token` | Print the dashboard API token, for scripts and AI agents. |
85
- | `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron, macOS LaunchAgent) or logon (Windows scheduled task). |
85
+ | `locadot startup:enable` / `startup:disable` / `startup:status` | Start the proxy at boot (Linux cron, macOS LaunchAgent) or logon (a hidden script in the Windows Startup folder, no admin needed). |
86
86
 
87
87
  ---
88
88
 
@@ -280,6 +280,7 @@ class Commands {
280
280
  ? ["open", [url]]
281
281
  : ["xdg-open", [url]];
282
282
  try {
283
+ // windows-hide-exempt: run from the user's terminal (no popup), and hiding `cmd /c start` can hide the browser too.
283
284
  (0, child_process_1.spawn)(command, args, { detached: true, stdio: "ignore" }).on("error", () => { }).unref();
284
285
  }
285
286
  catch { }
@@ -46,7 +46,7 @@ const canBindPrivileged = () => {
46
46
  return start !== null && start <= Math.min(constants_1.default.server.httpPort, constants_1.default.server.httpsPort);
47
47
  };
48
48
  exports.canBindPrivileged = canBindPrivileged;
49
- // Startup.info shells out (crontab/schtasks); don't do that on every 5 s dashboard poll.
49
+ // Startup.info may shell out (crontab); don't do that on every 5 s dashboard poll.
50
50
  const STARTUP_TTL_MS = 30000;
51
51
  let startupCache;
52
52
  const invalidateSystemStatus = () => {
package/dist/server.js CHANGED
@@ -65,6 +65,20 @@ async function startCentralProxy() {
65
65
  const stats = new Map();
66
66
  const proxy = http_proxy_1.default.createProxyServer({});
67
67
  proxy.on("error", (err) => logger_1.default.warn(`proxy error: ${err.message}`));
68
+ // Hop-by-hop headers describe the upstream connection, not ours. Apache sends
69
+ // `Connection: Upgrade, close` + `Upgrade: h2`, which made us close the browser's
70
+ // socket after every response (ERR_TOO_MANY_RETRIES on asset-heavy pages).
71
+ proxy.on("proxyRes", (proxyRes) => {
72
+ if (proxyRes.statusCode === 101)
73
+ return;
74
+ const listed = String(proxyRes.headers.connection || "")
75
+ .split(",")
76
+ .map((name) => name.trim().toLowerCase())
77
+ .filter(Boolean);
78
+ for (const name of [...listed, "connection", "keep-alive", "upgrade", "proxy-connection"]) {
79
+ delete proxyRes.headers[name];
80
+ }
81
+ });
68
82
  const info = {
69
83
  pid: process.pid,
70
84
  version: package_json_1.version,
@@ -3,6 +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 = void 0;
6
7
  const path_1 = __importDefault(require("path"));
7
8
  const os_1 = __importDefault(require("os"));
8
9
  const fs_1 = __importDefault(require("fs"));
@@ -15,6 +16,10 @@ const SERVICE_NAME = "locadot-proxy";
15
16
  const MAC_LABEL = "com.locadot.proxy";
16
17
  const CRON_MARK = "# locadot-proxy";
17
18
  const MARKER = path_1.default.join(constants_1.default.paths.HOME, "startup.json");
19
+ // Per-user Startup folder: unlike an ONLOGON scheduled task it needs no admin rights.
20
+ const windowsLauncher = () => path_1.default.join(process.env.APPDATA || path_1.default.join(os_1.default.homedir(), "AppData", "Roaming"), "Microsoft", "Windows", "Start Menu", "Programs", "Startup", `${SERVICE_NAME}.vbs`);
21
+ // Earlier versions registered an ONLOGON scheduled task and kept the script in the state dir.
22
+ const LEGACY_LAUNCHER = path_1.default.join(constants_1.default.paths.HOME, "startup-hidden.vbs");
18
23
  const plistPath = () => path_1.default.join(os_1.default.homedir(), "Library", "LaunchAgents", `${MAC_LABEL}.plist`);
19
24
  const quote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
20
25
  // Resolved on use, not import: the entry path depends on how locadot was launched.
@@ -22,6 +27,25 @@ const launchArgs = () => {
22
27
  const { command, path: args } = locadot_file_1.default.getStartProxyFile();
23
28
  return { command, args: [...args, "--home", constants_1.default.paths.HOME] };
24
29
  };
30
+ /**
31
+ * Launching node.exe directly at logon opens a console window, and closing it kills the
32
+ * proxy. Explorer runs the .vbs with wscript, which has no console, and Run(…, 0) keeps cmd
33
+ * and node hidden too. cmd also appends the output to the log, like the cron line does.
34
+ */
35
+ const windowsLauncherScript = (command, args) => {
36
+ const env = `set LOCADOT_HTTP_PORT=${constants_1.default.server.httpPort}&& set LOCADOT_HTTPS_PORT=${constants_1.default.server.httpsPort}&&`;
37
+ const line = `cmd /d /c ${env} ${[command, ...args].map((a) => `"${a}"`).join(" ")} >> "${constants_1.default.paths.LOGS}" 2>&1`;
38
+ return `CreateObject("WScript.Shell").Run "${line.replace(/"/g, '""')}", 0, False\r\n`;
39
+ };
40
+ exports.windowsLauncherScript = windowsLauncherScript;
41
+ /** Best effort: deleting a task the user created without elevation needs none. */
42
+ const removeLegacyTask = () => {
43
+ try {
44
+ (0, child_process_1.execFileSync)("schtasks", ["/Delete", "/TN", SERVICE_NAME, "/F"], { stdio: "ignore", windowsHide: true });
45
+ }
46
+ catch { }
47
+ fs_1.default.rmSync(LEGACY_LAUNCHER, { force: true });
48
+ };
25
49
  function execSudo(cmd) {
26
50
  return new Promise((resolve, reject) => {
27
51
  sudo_prompt_1.default.exec(cmd, { name: "Locadot" }, (error, stdout, stderr) => {
@@ -56,10 +80,11 @@ class Startup {
56
80
  let method = "";
57
81
  switch (platform) {
58
82
  case "windows": {
59
- // Per-user logon task: keeps the user's app-data dir; ports 80/443 need no elevation on Windows.
60
- const tr = [command, ...args].map((a) => `"${a}"`).join(" ");
61
- (0, child_process_1.execFileSync)("schtasks", ["/Create", "/TN", SERVICE_NAME, "/TR", tr, "/SC", "ONLOGON", "/F"], { stdio: "inherit" });
62
- method = "schtasks";
83
+ // Runs at the user's logon; ports 80/443 need no elevation on Windows.
84
+ removeLegacyTask();
85
+ fs_1.default.mkdirSync(path_1.default.dirname(windowsLauncher()), { recursive: true });
86
+ fs_1.default.writeFileSync(windowsLauncher(), (0, exports.windowsLauncherScript)(command, args));
87
+ method = "startup-folder";
63
88
  break;
64
89
  }
65
90
  case "linux": {
@@ -71,7 +96,7 @@ class Startup {
71
96
  method = "root-crontab";
72
97
  }
73
98
  else {
74
- (0, child_process_1.execFileSync)("sh", ["-c", edit], { stdio: "inherit" });
99
+ (0, child_process_1.execFileSync)("sh", ["-c", edit], { stdio: "inherit", windowsHide: true });
75
100
  method = "user-crontab";
76
101
  }
77
102
  break;
@@ -98,7 +123,7 @@ class Startup {
98
123
  </plist>`;
99
124
  fs_1.default.mkdirSync(path_1.default.dirname(plistPath()), { recursive: true });
100
125
  fs_1.default.writeFileSync(plistPath(), plist);
101
- (0, child_process_1.execFileSync)("launchctl", ["load", "-w", plistPath()], { stdio: "inherit" });
126
+ (0, child_process_1.execFileSync)("launchctl", ["load", "-w", plistPath()], { stdio: "inherit", windowsHide: true });
102
127
  method = "launch-agent";
103
128
  break;
104
129
  }
@@ -114,21 +139,19 @@ class Startup {
114
139
  const method = Startup.marker()?.method;
115
140
  switch (platform) {
116
141
  case "windows":
117
- try {
118
- (0, child_process_1.execFileSync)("schtasks", ["/Delete", "/TN", SERVICE_NAME, "/F"], { stdio: "ignore" });
119
- }
120
- catch { }
142
+ removeLegacyTask();
143
+ fs_1.default.rmSync(windowsLauncher(), { force: true });
121
144
  break;
122
145
  case "linux":
123
146
  if (method === "root-crontab")
124
147
  await execSudo(`sh -c ${quote(cronEdit())}`);
125
148
  else
126
- (0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit" });
149
+ (0, child_process_1.execFileSync)("sh", ["-c", cronEdit()], { stdio: "inherit", windowsHide: true });
127
150
  break;
128
151
  case "mac":
129
152
  if (fs_1.default.existsSync(plistPath())) {
130
153
  try {
131
- (0, child_process_1.execFileSync)("launchctl", ["unload", "-w", plistPath()], { stdio: "ignore" });
154
+ (0, child_process_1.execFileSync)("launchctl", ["unload", "-w", plistPath()], { stdio: "ignore", windowsHide: true });
132
155
  }
133
156
  catch { }
134
157
  fs_1.default.rmSync(plistPath(), { force: true });
@@ -163,12 +186,11 @@ class Startup {
163
186
  case "linux":
164
187
  if (marker?.method === "root-crontab")
165
188
  return true;
166
- return (0, child_process_1.execFileSync)("sh", ["-c", "crontab -l 2>/dev/null || true"], { encoding: "utf8" }).includes(CRON_MARK);
189
+ return (0, child_process_1.execFileSync)("sh", ["-c", "crontab -l 2>/dev/null || true"], { encoding: "utf8", windowsHide: true }).includes(CRON_MARK);
167
190
  case "mac":
168
191
  return fs_1.default.existsSync(plistPath());
169
192
  case "windows":
170
- (0, child_process_1.execFileSync)("schtasks", ["/Query", "/TN", SERVICE_NAME], { stdio: "ignore" });
171
- return true;
193
+ return fs_1.default.existsSync(windowsLauncher());
172
194
  default:
173
195
  return false;
174
196
  }
@@ -32,7 +32,7 @@ function execSudo(cmd) {
32
32
  }
33
33
  function hasBinary(bin) {
34
34
  try {
35
- (0, child_process_1.execSync)(`command -v ${bin}`, { stdio: "ignore" });
35
+ (0, child_process_1.execSync)(`command -v ${bin}`, { stdio: "ignore", windowsHide: true });
36
36
  return true;
37
37
  }
38
38
  catch {
@@ -75,6 +75,7 @@ function trustNssDbs(certPath) {
75
75
  try {
76
76
  (0, child_process_1.execSync)(`certutil -d "${db}" -A -t "C,," -n "${CA_NAME}" -i "${certPath}"`, {
77
77
  stdio: "ignore",
78
+ windowsHide: true,
78
79
  });
79
80
  }
80
81
  catch {
@@ -87,7 +88,7 @@ function untrustNssDbs() {
87
88
  return;
88
89
  for (const db of nssProfileDirs()) {
89
90
  try {
90
- (0, child_process_1.execSync)(`certutil -d "${db}" -D -n "${CA_NAME}"`, { stdio: "ignore" });
91
+ (0, child_process_1.execSync)(`certutil -d "${db}" -D -n "${CA_NAME}"`, { stdio: "ignore", windowsHide: true });
91
92
  }
92
93
  catch {
93
94
  // Not present in this profile — nothing to remove.
@@ -133,7 +134,7 @@ async function untrustMac() {
133
134
  }
134
135
  function isTrustedMac(certPath) {
135
136
  try {
136
- (0, child_process_1.execSync)(`security verify-cert -c "${certPath}"`, { stdio: "ignore" });
137
+ (0, child_process_1.execSync)(`security verify-cert -c "${certPath}"`, { stdio: "ignore", windowsHide: true });
137
138
  return true;
138
139
  }
139
140
  catch {
@@ -148,7 +149,7 @@ async function untrustWindows() {
148
149
  }
149
150
  function isTrustedWindows() {
150
151
  try {
151
- (0, child_process_1.execSync)(`certutil -store Root "${CA_NAME}"`, { stdio: "ignore" });
152
+ (0, child_process_1.execSync)(`certutil -store Root "${CA_NAME}"`, { stdio: "ignore", windowsHide: true });
152
153
  return true;
153
154
  }
154
155
  catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "locadot",
3
- "version": "1.6.0-beta.0",
3
+ "version": "1.6.0-beta.2",
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",