portless 0.6.0 → 0.7.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025 Vercel Inc.
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -129,7 +129,7 @@ portless proxy start --cert ./cert.pem --key ./key.pem
129
129
  sudo portless trust
130
130
  ```
131
131
 
132
- On Linux, `portless trust` supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and openSUSE (via `update-ca-certificates` or `update-ca-trust`).
132
+ On Linux, `portless trust` supports Debian/Ubuntu, Arch, Fedora/RHEL/CentOS, and openSUSE (via `update-ca-certificates` or `update-ca-trust`). On Windows, it uses `certutil` to add the CA to the system trust store.
133
133
 
134
134
  ## Commands
135
135
 
@@ -187,7 +187,7 @@ HOST Always 127.0.0.1
187
187
  PORTLESS_URL Public URL (e.g. https://myapp.localhost)
188
188
  ```
189
189
 
190
- > **Reserved names:** `run`, `alias`, `hosts`, `list`, `trust`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
190
+ > **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, and `proxy` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name from your project, or `portless --name <name> <cmd>` to force any name including reserved ones.
191
191
 
192
192
  ## Safari / DNS
193
193
 
@@ -251,4 +251,4 @@ pnpm format # Format all files with Prettier
251
251
  ## Requirements
252
252
 
253
253
  - Node.js 20+
254
- - macOS or Linux
254
+ - macOS, Linux, or Windows
@@ -1,6 +1,7 @@
1
1
  // src/utils.ts
2
2
  import * as fs from "fs";
3
3
  function fixOwnership(...paths) {
4
+ if (process.platform === "win32") return;
4
5
  const uid = process.env.SUDO_UID;
5
6
  const gid = process.env.SUDO_GID;
6
7
  if (!uid || process.getuid?.() !== 0) return;
@@ -544,7 +545,9 @@ function createProxyServer(options) {
544
545
  // src/hosts.ts
545
546
  import * as fs2 from "fs";
546
547
  import * as dns from "dns";
547
- var HOSTS_PATH = "/etc/hosts";
548
+ import * as path from "path";
549
+ var isWindows = process.platform === "win32";
550
+ var HOSTS_PATH = isWindows ? path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "drivers", "etc", "hosts") : "/etc/hosts";
548
551
  var MARKER_START = "# portless-start";
549
552
  var MARKER_END = "# portless-end";
550
553
  function readHostsFile() {
@@ -626,18 +629,19 @@ import * as http3 from "http";
626
629
  import * as https from "https";
627
630
  import * as net2 from "net";
628
631
  import * as os from "os";
629
- import * as path from "path";
632
+ import * as path2 from "path";
630
633
  import * as readline from "readline";
631
634
  import { execSync, spawn } from "child_process";
635
+ var isWindows2 = process.platform === "win32";
632
636
  var DEFAULT_PROXY_PORT = 1355;
633
637
  var PRIVILEGED_PORT_THRESHOLD = 1024;
634
- var SYSTEM_STATE_DIR = "/tmp/portless";
635
- var USER_STATE_DIR = path.join(os.homedir(), ".portless");
638
+ var SYSTEM_STATE_DIR = path2.join(os.tmpdir(), "portless");
639
+ var USER_STATE_DIR = path2.join(os.homedir(), ".portless");
636
640
  var MIN_APP_PORT = 4e3;
637
641
  var MAX_APP_PORT = 4999;
638
642
  var RANDOM_PORT_ATTEMPTS = 50;
639
643
  var SOCKET_TIMEOUT_MS = 500;
640
- var LSOF_TIMEOUT_MS = 5e3;
644
+ var PID_LOOKUP_TIMEOUT_MS = 5e3;
641
645
  var WAIT_FOR_PROXY_MAX_ATTEMPTS = 20;
642
646
  var WAIT_FOR_PROXY_INTERVAL_MS = 250;
643
647
  var SIGNAL_CODES = {
@@ -658,11 +662,12 @@ function getDefaultPort() {
658
662
  }
659
663
  function resolveStateDir(port) {
660
664
  if (process.env.PORTLESS_STATE_DIR) return process.env.PORTLESS_STATE_DIR;
665
+ if (isWindows2) return USER_STATE_DIR;
661
666
  return port < PRIVILEGED_PORT_THRESHOLD ? SYSTEM_STATE_DIR : USER_STATE_DIR;
662
667
  }
663
668
  function readPortFromDir(dir) {
664
669
  try {
665
- const raw = fs3.readFileSync(path.join(dir, "proxy.port"), "utf-8").trim();
670
+ const raw = fs3.readFileSync(path2.join(dir, "proxy.port"), "utf-8").trim();
666
671
  const port = parseInt(raw, 10);
667
672
  return isNaN(port) ? null : port;
668
673
  } catch {
@@ -672,13 +677,13 @@ function readPortFromDir(dir) {
672
677
  var TLS_MARKER_FILE = "proxy.tls";
673
678
  function readTlsMarker(dir) {
674
679
  try {
675
- return fs3.existsSync(path.join(dir, TLS_MARKER_FILE));
680
+ return fs3.existsSync(path2.join(dir, TLS_MARKER_FILE));
676
681
  } catch {
677
682
  return false;
678
683
  }
679
684
  }
680
685
  function writeTlsMarker(dir, enabled) {
681
- const markerPath = path.join(dir, TLS_MARKER_FILE);
686
+ const markerPath = path2.join(dir, TLS_MARKER_FILE);
682
687
  if (enabled) {
683
688
  fs3.writeFileSync(markerPath, "1", { mode: 420 });
684
689
  } else {
@@ -712,14 +717,14 @@ function validateTld(tld) {
712
717
  var TLD_FILE = "proxy.tld";
713
718
  function readTldFromDir(dir) {
714
719
  try {
715
- const raw = fs3.readFileSync(path.join(dir, TLD_FILE), "utf-8").trim();
720
+ const raw = fs3.readFileSync(path2.join(dir, TLD_FILE), "utf-8").trim();
716
721
  return raw || DEFAULT_TLD;
717
722
  } catch {
718
723
  return DEFAULT_TLD;
719
724
  }
720
725
  }
721
726
  function writeTldFile(dir, tld) {
722
- const filePath = path.join(dir, TLD_FILE);
727
+ const filePath = path2.join(dir, TLD_FILE);
723
728
  if (tld === DEFAULT_TLD) {
724
729
  try {
725
730
  fs3.unlinkSync(filePath);
@@ -827,11 +832,34 @@ function isProxyRunning(port, tls = false) {
827
832
  req.end();
828
833
  });
829
834
  }
835
+ function parsePidFromNetstat(output, port) {
836
+ for (const line of output.split(/\r?\n/)) {
837
+ if (!line.includes("LISTENING")) continue;
838
+ const parts = line.trim().split(/\s+/);
839
+ if (parts.length < 5) continue;
840
+ const localAddr = parts[1];
841
+ const lastColon = localAddr.lastIndexOf(":");
842
+ if (lastColon === -1) continue;
843
+ const addrPort = parseInt(localAddr.substring(lastColon + 1), 10);
844
+ if (addrPort === port) {
845
+ const pid = parseInt(parts[parts.length - 1], 10);
846
+ if (!isNaN(pid) && pid > 0) return pid;
847
+ }
848
+ }
849
+ return null;
850
+ }
830
851
  function findPidOnPort(port) {
831
852
  try {
853
+ if (isWindows2) {
854
+ const output2 = execSync("netstat -ano -p tcp", {
855
+ encoding: "utf-8",
856
+ timeout: PID_LOOKUP_TIMEOUT_MS
857
+ });
858
+ return parsePidFromNetstat(output2, port);
859
+ }
832
860
  const output = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
833
861
  encoding: "utf-8",
834
- timeout: LSOF_TIMEOUT_MS
862
+ timeout: PID_LOOKUP_TIMEOUT_MS
835
863
  });
836
864
  const pid = parseInt(output.trim().split("\n")[0], 10);
837
865
  return isNaN(pid) ? null : pid;
@@ -855,11 +883,11 @@ function collectBinPaths(cwd) {
855
883
  const dirs = [];
856
884
  let dir = cwd;
857
885
  for (; ; ) {
858
- const bin = path.join(dir, "node_modules", ".bin");
886
+ const bin = path2.join(dir, "node_modules", ".bin");
859
887
  if (fs3.existsSync(bin)) {
860
888
  dirs.push(bin);
861
889
  }
862
- const parent = path.dirname(dir);
890
+ const parent = path2.dirname(dir);
863
891
  if (parent === dir) break;
864
892
  dir = parent;
865
893
  }
@@ -868,12 +896,15 @@ function collectBinPaths(cwd) {
868
896
  function augmentedPath(env) {
869
897
  const base = (env ?? process.env).PATH ?? "";
870
898
  const bins = collectBinPaths(process.cwd());
871
- return bins.length > 0 ? bins.join(path.delimiter) + path.delimiter + base : base;
899
+ return bins.length > 0 ? bins.join(path2.delimiter) + path2.delimiter + base : base;
872
900
  }
873
901
  function spawnCommand(commandArgs, options) {
874
902
  const env = { ...options?.env ?? process.env, PATH: augmentedPath(options?.env) };
875
- const shellCmd = commandArgs.map(shellEscape).join(" ");
876
- const child = spawn("/bin/sh", ["-c", shellCmd], {
903
+ const child = isWindows2 ? spawn(commandArgs[0], commandArgs.slice(1), {
904
+ stdio: "inherit",
905
+ env,
906
+ shell: true
907
+ }) : spawn("/bin/sh", ["-c", commandArgs.map(shellEscape).join(" ")], {
877
908
  stdio: "inherit",
878
909
  env
879
910
  });
@@ -925,7 +956,7 @@ var FRAMEWORKS_NEEDING_PORT = {
925
956
  function injectFrameworkFlags(commandArgs, port) {
926
957
  const cmd = commandArgs[0];
927
958
  if (!cmd) return;
928
- const basename2 = path.basename(cmd);
959
+ const basename2 = path2.basename(cmd);
929
960
  const framework = FRAMEWORKS_NEEDING_PORT[basename2];
930
961
  if (!framework) return;
931
962
  if (!commandArgs.includes("--port")) {
@@ -955,7 +986,7 @@ function prompt(question) {
955
986
 
956
987
  // src/routes.ts
957
988
  import * as fs4 from "fs";
958
- import * as path2 from "path";
989
+ import * as path3 from "path";
959
990
  var STALE_LOCK_THRESHOLD_MS = 1e4;
960
991
  var LOCK_MAX_RETRIES = 20;
961
992
  var LOCK_RETRY_DELAY_MS = 50;
@@ -988,10 +1019,10 @@ var RouteStore = class _RouteStore {
988
1019
  onWarning;
989
1020
  constructor(dir, options) {
990
1021
  this.dir = dir;
991
- this.routesPath = path2.join(dir, "routes.json");
992
- this.lockPath = path2.join(dir, "routes.lock");
993
- this.pidPath = path2.join(dir, "proxy.pid");
994
- this.portFilePath = path2.join(dir, "proxy.port");
1022
+ this.routesPath = path3.join(dir, "routes.json");
1023
+ this.lockPath = path3.join(dir, "routes.lock");
1024
+ this.pidPath = path3.join(dir, "proxy.pid");
1025
+ this.portFilePath = path3.join(dir, "proxy.port");
995
1026
  this.onWarning = options?.onWarning;
996
1027
  }
997
1028
  isSystemDir() {
@@ -1149,6 +1180,7 @@ export {
1149
1180
  cleanHostsFile,
1150
1181
  getManagedHostnames,
1151
1182
  checkHostResolution,
1183
+ isWindows2 as isWindows,
1152
1184
  PRIVILEGED_PORT_THRESHOLD,
1153
1185
  getDefaultPort,
1154
1186
  resolveStateDir,
package/dist/cli.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  isErrnoException,
20
20
  isHttpsEnvEnabled,
21
21
  isProxyRunning,
22
+ isWindows,
22
23
  parseHostname,
23
24
  prompt,
24
25
  readTldFromDir,
@@ -30,7 +31,7 @@ import {
30
31
  waitForProxy,
31
32
  writeTldFile,
32
33
  writeTlsMarker
33
- } from "./chunk-AB3HUERH.js";
34
+ } from "./chunk-Y6FWHU6F.js";
34
35
 
35
36
  // src/cli.ts
36
37
  import chalk from "chalk";
@@ -219,9 +220,24 @@ function isCATrusted(stateDir) {
219
220
  return isCATrustedMacOS(caCertPath);
220
221
  } else if (process.platform === "linux") {
221
222
  return isCATrustedLinux(stateDir);
223
+ } else if (process.platform === "win32") {
224
+ return isCATrustedWindows(caCertPath);
222
225
  }
223
226
  return false;
224
227
  }
228
+ function isCATrustedWindows(caCertPath) {
229
+ try {
230
+ const fingerprint = openssl(["x509", "-in", caCertPath, "-noout", "-fingerprint", "-sha1"]).trim().replace(/^.*=/, "").replace(/:/g, "").toLowerCase();
231
+ const result = execFileSync("certutil", ["-store", "-user", "Root"], {
232
+ encoding: "utf-8",
233
+ timeout: 1e4,
234
+ stdio: ["pipe", "pipe", "pipe"]
235
+ });
236
+ return result.replace(/\s/g, "").toLowerCase().includes(fingerprint);
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
225
241
  function isCATrustedMacOS(caCertPath) {
226
242
  try {
227
243
  const isRoot = (process.getuid?.() ?? -1) === 0;
@@ -466,6 +482,12 @@ function trustCA(stateDir) {
466
482
  fs.copyFileSync(caCertPath, dest);
467
483
  execFileSync(config.updateCommand, [], { stdio: "pipe", timeout: 3e4 });
468
484
  return { trusted: true };
485
+ } else if (process.platform === "win32") {
486
+ execFileSync("certutil", ["-addstore", "-user", "Root", caCertPath], {
487
+ stdio: "pipe",
488
+ timeout: 3e4
489
+ });
490
+ return { trusted: true };
469
491
  }
470
492
  return { trusted: false, error: `Unsupported platform: ${process.platform}` };
471
493
  } catch (err) {
@@ -584,6 +606,25 @@ function detectWorktreeViaCli(cwd) {
584
606
  });
585
607
  const worktreeCount = listOutput.split("\n").filter((l) => l.startsWith("worktree ")).length;
586
608
  if (worktreeCount <= 1) return null;
609
+ const gitDir = path2.resolve(
610
+ cwd,
611
+ execFileSync2("git", ["rev-parse", "--git-dir"], {
612
+ cwd,
613
+ encoding: "utf-8",
614
+ timeout: 5e3,
615
+ stdio: ["ignore", "pipe", "ignore"]
616
+ }).trim()
617
+ );
618
+ const gitCommonDir = path2.resolve(
619
+ cwd,
620
+ execFileSync2("git", ["rev-parse", "--git-common-dir"], {
621
+ cwd,
622
+ encoding: "utf-8",
623
+ timeout: 5e3,
624
+ stdio: ["ignore", "pipe", "ignore"]
625
+ }).trim()
626
+ );
627
+ if (gitDir === gitCommonDir) return null;
587
628
  const branch = execFileSync2("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
588
629
  cwd,
589
630
  encoding: "utf-8",
@@ -611,7 +652,7 @@ function detectWorktreeViaFilesystem(startDir) {
611
652
  const match = content.match(/^gitdir:\s*(.+)$/);
612
653
  if (!match) return null;
613
654
  const gitdir = match[1];
614
- if (!gitdir.match(/\/worktrees\/[^/]+$/)) return null;
655
+ if (!gitdir.match(/[/\\]worktrees[/\\][^/\\]+$/)) return null;
615
656
  const branch = readBranchFromHead(path2.resolve(dir, gitdir));
616
657
  const prefix = branchToPrefix(branch ?? "");
617
658
  if (!prefix) return null;
@@ -636,6 +677,8 @@ function readBranchFromHead(gitdir) {
636
677
  }
637
678
 
638
679
  // src/cli.ts
680
+ var HOSTS_DISPLAY = isWindows ? "hosts file" : "/etc/hosts";
681
+ var SUDO_PREFIX = isWindows ? "" : "sudo ";
639
682
  var DEBOUNCE_MS = 100;
640
683
  var POLL_INTERVAL_MS = 3e3;
641
684
  var EXIT_TIMEOUT_MS = 2e3;
@@ -692,7 +735,11 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
692
735
  console.error(chalk.blue("Stop the existing proxy first:"));
693
736
  console.error(chalk.cyan(" portless proxy stop"));
694
737
  console.error(chalk.blue("Or check what is using the port:"));
695
- console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
738
+ console.error(
739
+ chalk.cyan(
740
+ isWindows ? ` netstat -ano | findstr :${proxyPort}` : ` lsof -ti tcp:${proxyPort}`
741
+ )
742
+ );
696
743
  } else if (err.code === "EACCES") {
697
744
  console.error(chalk.red(`Permission denied for port ${proxyPort}.`));
698
745
  console.error(chalk.blue("Either run with sudo:"));
@@ -744,7 +791,7 @@ function startProxyServer(store, proxyPort, tld, tlsOptions) {
744
791
  }
745
792
  async function stopProxy(store, proxyPort, _tls) {
746
793
  const pidPath = store.pidPath;
747
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
794
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
748
795
  const sudoHint = needsSudo ? "sudo " : "";
749
796
  if (!fs3.existsSync(pidPath)) {
750
797
  if (await isProxyRunning(proxyPort)) {
@@ -760,24 +807,38 @@ async function stopProxy(store, proxyPort, _tls) {
760
807
  console.log(chalk.green(`Killed process ${pid}. Proxy stopped.`));
761
808
  } catch (err) {
762
809
  if (isErrnoException(err) && err.code === "EPERM") {
763
- console.error(chalk.red("Permission denied. The proxy was started with sudo."));
810
+ console.error(
811
+ chalk.red("Permission denied. The proxy was started with elevated privileges.")
812
+ );
764
813
  console.error(chalk.blue("Stop it with:"));
765
- console.error(chalk.cyan(" sudo portless proxy stop"));
814
+ console.error(
815
+ chalk.cyan(
816
+ isWindows ? " Run portless proxy stop as Administrator" : " sudo portless proxy stop"
817
+ )
818
+ );
766
819
  } else {
767
820
  const message = err instanceof Error ? err.message : String(err);
768
821
  console.error(chalk.red(`Failed to stop proxy: ${message}`));
769
822
  console.error(chalk.blue("Check if the process is still running:"));
770
- console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
823
+ console.error(
824
+ chalk.cyan(
825
+ isWindows ? ` netstat -ano | findstr :${proxyPort}` : ` lsof -ti tcp:${proxyPort}`
826
+ )
827
+ );
771
828
  }
772
829
  }
773
- } else if (process.getuid?.() !== 0) {
830
+ } else if (!isWindows && process.getuid?.() !== 0) {
774
831
  console.error(chalk.red("Cannot identify the process. It may be running as root."));
775
832
  console.error(chalk.blue("Try stopping with sudo:"));
776
833
  console.error(chalk.cyan(" sudo portless proxy stop"));
777
834
  } else {
778
835
  console.error(chalk.red(`Could not identify the process on port ${proxyPort}.`));
779
836
  console.error(chalk.blue("Try manually:"));
780
- console.error(chalk.cyan(` sudo kill "$(lsof -ti tcp:${proxyPort})"`));
837
+ console.error(
838
+ chalk.cyan(
839
+ isWindows ? " taskkill /F /PID <pid>" : ` sudo kill "$(lsof -ti tcp:${proxyPort})"`
840
+ )
841
+ );
781
842
  }
782
843
  } else {
783
844
  console.log(chalk.yellow("Proxy is not running."));
@@ -821,14 +882,20 @@ async function stopProxy(store, proxyPort, _tls) {
821
882
  console.log(chalk.green("Proxy stopped."));
822
883
  } catch (err) {
823
884
  if (isErrnoException(err) && err.code === "EPERM") {
824
- console.error(chalk.red("Permission denied. The proxy was started with sudo."));
885
+ console.error(
886
+ chalk.red("Permission denied. The proxy was started with elevated privileges.")
887
+ );
825
888
  console.error(chalk.blue("Stop it with:"));
826
889
  console.error(chalk.cyan(` ${sudoHint}portless proxy stop`));
827
890
  } else {
828
891
  const message = err instanceof Error ? err.message : String(err);
829
892
  console.error(chalk.red(`Failed to stop proxy: ${message}`));
830
893
  console.error(chalk.blue("Check if the process is still running:"));
831
- console.error(chalk.cyan(` lsof -ti tcp:${proxyPort}`));
894
+ console.error(
895
+ chalk.cyan(
896
+ isWindows ? ` netstat -ano | findstr :${proxyPort}` : ` lsof -ti tcp:${proxyPort}`
897
+ )
898
+ );
832
899
  }
833
900
  }
834
901
  }
@@ -878,7 +945,7 @@ portless
878
945
  }
879
946
  if (!await isProxyRunning(proxyPort, tls2)) {
880
947
  const defaultPort = getDefaultPort();
881
- const needsSudo = defaultPort < PRIVILEGED_PORT_THRESHOLD;
948
+ const needsSudo = !isWindows && defaultPort < PRIVILEGED_PORT_THRESHOLD;
882
949
  const wantHttps = isHttpsEnvEnabled();
883
950
  if (needsSudo) {
884
951
  if (!process.stdin.isTTY) {
@@ -1135,8 +1202,8 @@ ${chalk.bold("Usage:")}
1135
1202
  ${chalk.cyan("portless alias --remove <name>")} Remove a static route
1136
1203
  ${chalk.cyan("portless list")} Show active routes
1137
1204
  ${chalk.cyan("portless trust")} Add local CA to system trust store
1138
- ${chalk.cyan("portless hosts sync")} Add routes to /etc/hosts (fixes Safari)
1139
- ${chalk.cyan("portless hosts clean")} Remove portless entries from /etc/hosts
1205
+ ${chalk.cyan("portless hosts sync")} Add routes to ${HOSTS_DISPLAY} (fixes Safari)
1206
+ ${chalk.cyan("portless hosts clean")} Remove portless entries from ${HOSTS_DISPLAY}
1140
1207
 
1141
1208
  ${chalk.bold("Examples:")}
1142
1209
  portless proxy start # Start proxy on port 1355
@@ -1191,7 +1258,7 @@ ${chalk.bold("Environment variables:")}
1191
1258
  PORTLESS_APP_PORT=<number> Use a fixed port for the app (same as --app-port)
1192
1259
  PORTLESS_HTTPS=1 Always enable HTTPS (set in .bashrc / .zshrc)
1193
1260
  PORTLESS_TLD=<tld> Use a custom TLD (e.g. test, dev; default: localhost)
1194
- PORTLESS_SYNC_HOSTS=1 Auto-sync /etc/hosts (auto-enabled for custom TLDs)
1261
+ PORTLESS_SYNC_HOSTS=1 Auto-sync ${HOSTS_DISPLAY} (auto-enabled for custom TLDs)
1195
1262
  PORTLESS_STATE_DIR=<path> Override the state directory
1196
1263
  PORTLESS=0 Run command directly without proxy
1197
1264
 
@@ -1203,11 +1270,11 @@ ${chalk.bold("Child process environment:")}
1203
1270
  ${chalk.bold("Safari / DNS:")}
1204
1271
  .localhost subdomains auto-resolve in Chrome, Firefox, and Edge.
1205
1272
  Safari relies on the system DNS resolver, which may not handle them.
1206
- Auto-syncs /etc/hosts for custom TLDs (e.g. --tld test). For .localhost,
1273
+ Auto-syncs ${HOSTS_DISPLAY} for custom TLDs (e.g. --tld test). For .localhost,
1207
1274
  set PORTLESS_SYNC_HOSTS=1 to enable. To manually sync:
1208
- ${chalk.cyan("sudo portless hosts sync")}
1275
+ ${chalk.cyan(`${SUDO_PREFIX}portless hosts sync`)}
1209
1276
  Clean up later with:
1210
- ${chalk.cyan("sudo portless hosts clean")}
1277
+ ${chalk.cyan(`${SUDO_PREFIX}portless hosts clean`)}
1211
1278
 
1212
1279
  ${chalk.bold("Skip portless:")}
1213
1280
  PORTLESS=0 pnpm dev # Runs command directly without proxy
@@ -1220,7 +1287,7 @@ ${chalk.bold("Reserved names:")}
1220
1287
  process.exit(0);
1221
1288
  }
1222
1289
  function printVersion() {
1223
- console.log("0.6.0");
1290
+ console.log("0.7.0");
1224
1291
  process.exit(0);
1225
1292
  }
1226
1293
  async function handleTrust() {
@@ -1362,14 +1429,14 @@ ${chalk.bold("Examples:")}
1362
1429
  async function handleHosts(args) {
1363
1430
  if (args[1] === "--help" || args[1] === "-h") {
1364
1431
  console.log(`
1365
- ${chalk.bold("portless hosts")} - Manage /etc/hosts entries for .localhost subdomains.
1432
+ ${chalk.bold("portless hosts")} - Manage ${HOSTS_DISPLAY} entries for .localhost subdomains.
1366
1433
 
1367
1434
  Safari relies on the system DNS resolver, which may not handle .localhost
1368
- subdomains. This command adds entries to /etc/hosts as a workaround.
1435
+ subdomains. This command adds entries to ${HOSTS_DISPLAY} as a workaround.
1369
1436
 
1370
1437
  ${chalk.bold("Usage:")}
1371
- ${chalk.cyan("sudo portless hosts sync")} Add current routes to /etc/hosts
1372
- ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1438
+ ${chalk.cyan(`${SUDO_PREFIX}portless hosts sync`)} Add current routes to ${HOSTS_DISPLAY}
1439
+ ${chalk.cyan(`${SUDO_PREFIX}portless hosts clean`)} Remove portless entries from ${HOSTS_DISPLAY}
1373
1440
 
1374
1441
  ${chalk.bold("Auto-sync:")}
1375
1442
  Auto-enabled for custom TLDs (e.g. --tld test). For .localhost, set
@@ -1379,10 +1446,14 @@ ${chalk.bold("Auto-sync:")}
1379
1446
  }
1380
1447
  if (args[1] === "clean") {
1381
1448
  if (cleanHostsFile()) {
1382
- console.log(chalk.green("Removed portless entries from /etc/hosts."));
1449
+ console.log(chalk.green(`Removed portless entries from ${HOSTS_DISPLAY}.`));
1383
1450
  } else {
1384
- console.error(chalk.red("Failed to update /etc/hosts (requires sudo)."));
1385
- console.error(chalk.cyan(" sudo portless hosts clean"));
1451
+ console.error(
1452
+ chalk.red(
1453
+ `Failed to update ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : " (requires sudo)."}`
1454
+ )
1455
+ );
1456
+ console.error(chalk.cyan(` ${SUDO_PREFIX}portless hosts clean`));
1386
1457
  process.exit(1);
1387
1458
  }
1388
1459
  return;
@@ -1391,16 +1462,18 @@ ${chalk.bold("Auto-sync:")}
1391
1462
  console.log(`
1392
1463
  ${chalk.bold("Usage: portless hosts <command>")}
1393
1464
 
1394
- ${chalk.cyan("sudo portless hosts sync")} Add current routes to /etc/hosts
1395
- ${chalk.cyan("sudo portless hosts clean")} Remove portless entries from /etc/hosts
1465
+ ${chalk.cyan(`${SUDO_PREFIX}portless hosts sync`)} Add current routes to ${HOSTS_DISPLAY}
1466
+ ${chalk.cyan(`${SUDO_PREFIX}portless hosts clean`)} Remove portless entries from ${HOSTS_DISPLAY}
1396
1467
  `);
1397
1468
  process.exit(0);
1398
1469
  }
1399
1470
  if (args[1] !== "sync") {
1400
1471
  console.error(chalk.red(`Error: Unknown hosts subcommand "${args[1]}".`));
1401
1472
  console.error(chalk.blue("Usage:"));
1402
- console.error(chalk.cyan(" portless hosts sync # Add routes to /etc/hosts"));
1403
- console.error(chalk.cyan(" portless hosts clean # Remove portless entries"));
1473
+ console.error(
1474
+ chalk.cyan(` ${SUDO_PREFIX}portless hosts sync # Add routes to ${HOSTS_DISPLAY}`)
1475
+ );
1476
+ console.error(chalk.cyan(` ${SUDO_PREFIX}portless hosts clean # Remove portless entries`));
1404
1477
  process.exit(1);
1405
1478
  }
1406
1479
  const { dir } = await discoverState();
@@ -1414,13 +1487,17 @@ ${chalk.bold("Usage: portless hosts <command>")}
1414
1487
  }
1415
1488
  const hostnames = routes.map((r) => r.hostname);
1416
1489
  if (syncHostsFile(hostnames)) {
1417
- console.log(chalk.green(`Synced ${hostnames.length} hostname(s) to /etc/hosts:`));
1490
+ console.log(chalk.green(`Synced ${hostnames.length} hostname(s) to ${HOSTS_DISPLAY}:`));
1418
1491
  for (const h of hostnames) {
1419
1492
  console.log(chalk.cyan(` 127.0.0.1 ${h}`));
1420
1493
  }
1421
1494
  } else {
1422
- console.error(chalk.red("Failed to update /etc/hosts (requires sudo)."));
1423
- console.error(chalk.cyan(" sudo portless hosts sync"));
1495
+ console.error(
1496
+ chalk.red(
1497
+ `Failed to update ${HOSTS_DISPLAY}${isWindows ? " (run as Administrator)." : " (requires sudo)."}`
1498
+ )
1499
+ );
1500
+ console.error(chalk.cyan(` ${SUDO_PREFIX}portless hosts sync`));
1424
1501
  process.exit(1);
1425
1502
  }
1426
1503
  }
@@ -1520,10 +1597,12 @@ ${chalk.bold("Usage:")}
1520
1597
  const syncDisabled = process.env.PORTLESS_SYNC_HOSTS === "0" || process.env.PORTLESS_SYNC_HOSTS === "false";
1521
1598
  if (tld !== DEFAULT_TLD && syncDisabled) {
1522
1599
  console.warn(
1523
- chalk.yellow(`Warning: .${tld} domains require /etc/hosts entries to resolve to 127.0.0.1.`)
1600
+ chalk.yellow(
1601
+ `Warning: .${tld} domains require ${HOSTS_DISPLAY} entries to resolve to 127.0.0.1.`
1602
+ )
1524
1603
  );
1525
1604
  console.warn(chalk.yellow("Hosts sync is disabled. To add entries manually, run:"));
1526
- console.warn(chalk.cyan(" sudo portless hosts sync"));
1605
+ console.warn(chalk.cyan(` ${SUDO_PREFIX}portless hosts sync`));
1527
1606
  }
1528
1607
  const useHttps = wantHttps || !!(customCertPath && customKeyPath);
1529
1608
  const stateDir = resolveStateDir(proxyPort);
@@ -1534,7 +1613,7 @@ ${chalk.bold("Usage:")}
1534
1613
  if (isForeground) {
1535
1614
  return;
1536
1615
  }
1537
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1616
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1538
1617
  const sudoPrefix = needsSudo ? "sudo " : "";
1539
1618
  const portFlag = proxyPort !== getDefaultPort() ? ` -p ${proxyPort}` : "";
1540
1619
  console.log(chalk.yellow(`Proxy is already running on port ${proxyPort}.`));
@@ -1545,7 +1624,7 @@ ${chalk.bold("Usage:")}
1545
1624
  );
1546
1625
  return;
1547
1626
  }
1548
- if (proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1627
+ if (!isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD && (process.getuid?.() ?? -1) !== 0) {
1549
1628
  console.error(chalk.red(`Error: Port ${proxyPort} requires sudo.`));
1550
1629
  console.error(chalk.blue("Either run with sudo:"));
1551
1630
  console.error(chalk.cyan(" sudo portless proxy start -p 80"));
@@ -1642,7 +1721,8 @@ ${chalk.bold("Usage:")}
1642
1721
  const child = spawn(process.execPath, daemonArgs, {
1643
1722
  detached: true,
1644
1723
  stdio: ["ignore", logFd, logFd],
1645
- env: process.env
1724
+ env: process.env,
1725
+ windowsHide: true
1646
1726
  });
1647
1727
  child.unref();
1648
1728
  } finally {
@@ -1651,7 +1731,7 @@ ${chalk.bold("Usage:")}
1651
1731
  if (!await waitForProxy(proxyPort, void 0, void 0, useHttps)) {
1652
1732
  console.error(chalk.red("Proxy failed to start (timed out waiting for it to listen)."));
1653
1733
  console.error(chalk.blue("Try starting the proxy in the foreground to see the error:"));
1654
- const needsSudo = proxyPort < PRIVILEGED_PORT_THRESHOLD;
1734
+ const needsSudo = !isWindows && proxyPort < PRIVILEGED_PORT_THRESHOLD;
1655
1735
  console.error(chalk.cyan(` ${needsSudo ? "sudo " : ""}portless proxy start --foreground`));
1656
1736
  if (fs3.existsSync(logPath)) {
1657
1737
  console.error(chalk.gray(`Logs: ${logPath}`));
@@ -1674,12 +1754,7 @@ async function handleRunMode(args) {
1674
1754
  let baseName;
1675
1755
  let nameSource;
1676
1756
  if (parsed.name) {
1677
- const sanitized = sanitizeForHostname(parsed.name);
1678
- if (!sanitized) {
1679
- console.error(chalk.red(`Error: --name value "${parsed.name}" produces an empty hostname.`));
1680
- process.exit(1);
1681
- }
1682
- baseName = sanitized;
1757
+ baseName = parsed.name;
1683
1758
  nameSource = "--name flag";
1684
1759
  } else {
1685
1760
  const inferred = inferProjectName();
package/dist/index.d.ts CHANGED
@@ -101,7 +101,8 @@ declare class RouteStore {
101
101
 
102
102
  /**
103
103
  * When running under sudo, fix file ownership so the real user can
104
- * read/write the file later without sudo. No-op when not running as root.
104
+ * read/write the file later without sudo. No-op on Windows or when not
105
+ * running as root.
105
106
  */
106
107
  declare function fixOwnership(...paths: string[]): void;
107
108
  /** Type guard for Node.js system errors with an error code. */
package/dist/index.js CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  parseHostname,
20
20
  removeBlock,
21
21
  syncHostsFile
22
- } from "./chunk-AB3HUERH.js";
22
+ } from "./chunk-Y6FWHU6F.js";
23
23
  export {
24
24
  DIR_MODE,
25
25
  FILE_MODE,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portless",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Replace port numbers with stable, named .localhost URLs. For humans and agents.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,19 +22,9 @@
22
22
  },
23
23
  "os": [
24
24
  "darwin",
25
- "linux"
25
+ "linux",
26
+ "win32"
26
27
  ],
27
- "scripts": {
28
- "build": "tsup",
29
- "dev": "tsup --watch",
30
- "lint": "eslint src/",
31
- "lint:fix": "eslint src/ --fix",
32
- "prepublishOnly": "cp ../../README.md . && pnpm build",
33
- "test": "vitest run",
34
- "test:coverage": "vitest run --coverage",
35
- "test:watch": "vitest",
36
- "typecheck": "tsc --noEmit"
37
- },
38
28
  "keywords": [
39
29
  "local",
40
30
  "development",
@@ -61,5 +51,15 @@
61
51
  "tsup": "^8.0.1",
62
52
  "typescript": "^5.3.3",
63
53
  "vitest": "^4.0.18"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "dev": "tsup --watch",
58
+ "lint": "eslint src/",
59
+ "lint:fix": "eslint src/ --fix",
60
+ "test": "vitest run",
61
+ "test:coverage": "vitest run --coverage",
62
+ "test:watch": "vitest",
63
+ "typecheck": "tsc --noEmit"
64
64
  }
65
- }
65
+ }