160 Commits

Author SHA1 Message Date
Railly Hugo 1ad573bb95 fix: warn when a route hostname will not resolve (#374)
* fix: report automatic hosts-sync failures on the CLI, not only in proxy.log

The automatic sync ignored the result of `syncHostsFile`, so an unprivileged run
registered a route, skipped the hosts block and said nothing. On resolvers that
do not handle multi-label `.localhost` the app then fails with `ENOTFOUND` and
nothing points at the skipped sync.

The sync runs in the detached daemon, whose stdio is redirected to `proxy.log`,
so the outcome has to cross a process boundary to reach the terminal that caused
it. It crosses on a request: the CLI asks the proxy over loopback after
registering, and the daemon reloads routes, syncs with its own privileges and
setting, and answers on the same connection. An answer carried by its request
cannot be stale, cannot belong to another caller, and cannot be left behind by a
previous daemon, so it needs no timestamp, owner or schema version, and no state
file.

Two guards. A loopback request Host selects the route, so an app that serves the
same path keeps serving it. A loopback peer address is then required, because a
Host header is attacker-supplied and LAN mode binds every interface. The request
carries no data: hostnames come from the daemon's route table, so nothing an
attacker supplies reaches the hosts file.

A daemon older than this route answers 404 in milliseconds, and the CLI then
reads the hosts file to see whether the hostnames resolve. That fallback is also
what happens when an answer does not arrive in time, where claiming failure
would be a guess.

`syncHostsFile` now reports whether the hosts file resolves the given hostnames
rather than whether a write threw. The two differ when the block is already
correct, and it returns early in that case, so a route reload that changes
nothing no longer rewrites the file.

Measured against an unwritable hosts file: warning delivered in 0.07 to 0.09s,
including against a daemon built from main and against a CLI whose own
PORTLESS_SYNC_HOSTS is 0. As root in a container, where the write succeeds:
registration completes in 0.04 to 0.06s, silent, with the hostname resolving.

* fix: skip a hosts sync on exactness, report on coverage

Two questions were collapsed into one. Whether to skip the write is exactness,
because the write rebuilds the block to exactly the route set, so a coverage test
called a superset correct and the skip left a removed route's hostname resolving
forever. What to report is coverage, because the caller asks whether its own
hostname resolves, and answering with exactness makes an unprivileged daemon that
cannot delete someone else's stale entry report failure to every registration
that actually works.

`hosts sync` with no routes now clears the block instead of returning early.
Zero routes is a desired state, and bailing there meant a block left by routes
that are gone survived the command the warning tells people to run.

An older daemon's 404 no longer produces a warning. Success, a failed write and
opt-out all look identical from a 404, and that daemon syncs later on its own
watcher, so reading the hosts file on receipt reported a failure it was about to
not have. Waiting instead only moves the guess later, since an absence at any
deadline still cannot separate a failed write from a user who disabled syncing.
An unreachable proxy is reported, but as an unconfirmed sync rather than a write
that was never observed to fail.

The response is also checked for the X-Portless header the liveness probe already
checks, since proxy.port can be stale or reused and an unrelated local server
answering "disabled" restored the silence this fixes.

Driven as root in a container: a removed route's entry disappears, `hosts sync`
clears an orphaned block, and a redundant sync still leaves the file untouched.
Unprivileged, every path stays between 0.07 and 0.34s.

* fix: warn when a route hostname will not resolve, not when a write failed

Issue #364 asks for a warning at registration time, and the question behind it is
whether the app is about to fail with ENOTFOUND. That is not the same question as
whether /etc/hosts was written. RFC 6761 makes .localhost resolution a SHOULD for
resolvers, and on current macOS and glibc `app.localhost` resolves with no hosts
entry, so a skipped write there changes nothing a user can see. A custom TLD like
`app.test` resolves on neither, so there the skipped write is the whole problem.
Reporting the write cannot tell those apart.

After registering a route the CLI asks the proxy over loopback to sync now, then
reads the resolver once. The request is a trigger: it removes the wait for the
daemon's file watcher, and its answer is not trusted for anything, so it carries
no body. Whether the responder is the daemon we meant, whether it is new enough to
understand the request, and what it claims happened are all irrelevant, because
the resolver is authoritative and local. `checkHostResolution` already existed and
was already used by `portless doctor`.

Order matters: querying before the write can cache a negative that is about to
become false, and macOS caches negatives, so an early probe could break the
resolution it checks.

`syncHostsFile` reports whether the block matches exactly, which is the writer's
own question, and restores the sudo escalation in `hosts sync` that a coverage
return had silently disabled. The weak question now belongs to the caller asking
it. A daemon started with PORTLESS_SYNC_HOSTS=0 no longer gets a special case: if
the hostname resolves the user hears nothing, and if it does not, their app is
broken and they are told.

Measured unprivileged: .localhost silent in 0.076s, .test warned in 0.101s. As
root in a container, where the write succeeds: silent in 0.053s with the hostname
resolving, a removed route's entry gone, a redundant sync leaving the file
untouched, and `hosts sync` failing honestly instead of reporting success.

* fix(cli): give an untriggerable daemon its chance before reporting absence

The trigger discarded the one bit worth keeping: whether a daemon acknowledged
doing the work. Without it, a daemon too old to have the internal route answered
404, the CLI read the resolver immediately, and warned about an absence that daemon
was one debounce away from fixing. Driven against a build of main with a custom
TLD: the warning printed, and the hostname resolved two seconds later.

The trigger now reports acknowledgement. A daemon that acknowledged has already
attempted the write, so one read of the resolver settles it. Nothing acknowledged
means nobody acted on our behalf, and anything that still happens happens on a
watcher's schedule, so the resolver is polled to a bound that clears it.

Only that path pays the bound. Measured unprivileged against a current daemon:
.localhost silent in 0.078s, a failing custom TLD warned in 0.092s.

* fix(cli): keep the watcher's before-image, and stop waiting on a producer that is absent

Three defects, all in the reporting path added by this branch.

The hosts-sync handler read the current route set into `cachedRoutes`, which is
the route watcher's before-image. `reloadRoutes` diffs a fresh load against it to
decide which mDNS records to publish, so overwriting it made the next diff compare
the new route set with itself and a route registered this way never got a `.local`
publisher at all. It reads into a local now.

The trigger reported only whether a daemon acknowledged, so a refused connection
and an unacknowledged answer were treated alike and both paid the poll. Waiting
when nothing is listening spends a ceiling to learn what the refused connection
already said, and it timed out two tests locally, two on Linux CI and seven on
Windows. The trigger now distinguishes an absent listener from a mute one, and
only a mute one is waited on. Measured: a custom TLD with no daemon went from
3.61s to 0.07s and still warns.

A `.local` hostname is served by mDNS and correctly resolves to the machine's LAN
address, so asking whether it resolves to loopback answered no for a hostname that
works. Those names are no longer this warning's business.

* refactor: drop the internal endpoint, wait on the hosts file instead

The endpoint, its two guards, the acknowledgement bit and the version-skew branch existed only to make the failure path fast. Reading the hosts file to know when the daemon has written costs 3.6s on that path and deletes all of it, leaving proxy.ts and types.ts identical to main.

Waiting by reading the file rather than re-querying the resolver also removes the cached-negative hazard that made ordering load-bearing.

Comments rewritten short and stale ones corrected: four still described the request channel that no longer exists.

* fix(cli): make hosts sync state explicit

* fix(portless): authenticate hosts sync requests

* refactor(portless): simplify hosts sync security tests
2026-08-29 20:58:02 -03:00
Chris Tate 2cd0719e15 chore: update immutable lockfile resolution (#396)
Co-authored-by: begininvoke <56797886+begininvoke@users.noreply.github.com>
2026-08-25 11:09:35 -05:00
Chris Tate 713f84dec0 Prepare v0.15.6 release (#394)
- Bump the portless package to 0.15.6.
- Document agent-readable docs, framework flag forwarding, Windows service reliability, and Ctrl+C cleanup.
- Synchronize release notes and keep automated release markers current.
v0.15.6
2026-08-24 11:19:47 -05:00
John Phamous d0712ab140 Fix orphaned child processes after Ctrl+C (#385) 2026-08-24 08:54:01 -07:00
Railly Hugo 2e87792c0d feat(docs): add agent-readable documentation (#387)
* feat(docs): add agent-readable documentation

* fix(docs): retry failed source reads

* fix(docs): preserve social link previews
2026-08-19 16:02:54 -03:00
Railly Hugo 326e8933e4 fix(service): disable Windows task time limit (#381)
* fix(service): disable Windows task time limit

* fix(service): write task XML as UTF-16

* test(ci): match Windows task duration format

* test(ci): remove temporary cuse evidence
2026-08-13 16:44:32 -03:00
Railly Hugo 93acac4e1e fix: inject framework flags through package-manager scripts (#366)
* fix(cli): inject framework flags through package-manager script indirection

Zero-arg mode and explicit delegation run dev servers via <pm> run <script>,
so the framework command lives inside package.json where injectFrameworkFlags
(argv-only) cannot see it. Vite then ignores PORT, listens on 5173, and the
assigned route returns 502 (#285).

Resolve the script from package.json, compute the flags the underlying
framework needs, and forward them through the package manager: after -- for
npm; bun, pnpm, and yarn forward trailing arguments directly. An existing
--port/--host in the script or in user-supplied trailing args suppresses
injection.

Covers portless (zero-arg), portless run, portless run <pm> run <script>,
and portless <name> <pm> run <script> for npm, pnpm, yarn, and bun.

Fixes #285

Co-Authored-By: EfeDurmaz16 <efebarandurmaz05@gmail.com>

* fix(cli): guard script flag forwarding against builds, compound scripts, and --flag=value

Adversarial review of the script-indirection injection found three holes:

- vite build received server flags and aborted with "Unknown option
  --port"; framework build subcommands are now excluded from forwarding.
- compound scripts (vite dev && node other.js) get forwarded flags
  attached to their last command; forwarding is now disabled when the
  script contains shell control operators.
- --port=5000 was not recognized as an existing port flag, so injection
  duplicated it; flag detection now matches the --flag=value form too,
  which also fixes the same duplication on direct invocations.

* docs: document compound-script scope limit for --port injection

* fix(cli): harden package-script flag injection against runner wrappers and glued operators

Two edge cases bypassed the guards added for compound and build scripts:

- `bunx vite build` received server flags: the build check inspected a
  fixed `rawScript[1]`, which a runner wrapper (bunx/npx/yarn dlx) shifts.
  Locate the framework past the runner (reusing the injectFrameworkFlags
  resolution) and inspect the first positional after it.
- `vite dev&&node x` still forwarded: splitCommand only breaks on
  whitespace, so an operator glued to a token stays inside it and the
  exact-token operator check missed it. Match operator characters as a
  substring instead.

* fix(cli): close residual guard bypasses for compound and build scripts

A pre-push review gate found two forms in the same class as the guards
this branch added:

- Newline-separated compound scripts (`vite dev\nnode x`) still forwarded:
  splitCommand collapses newlines, so a per-token operator check never saw
  the separator. Test the raw script string via resolveScriptRaw instead,
  and treat newline/CR as separators alongside the operators.
- A space-separated flag value before the build subcommand
  (`vite --mode production build`) bypassed the build guard: the flag-skip
  loop stopped at the value. Scan every bare positional after the framework
  instead of a single slot.

Also mirror the compound-script scope caveat into the two apps/docs pages
that carry the injection sentence (README and SKILL.md already had it).

* fix(cli): quote-aware compound detection + independent flag injection + hermetic shim

- Compound-script detection is now quote- and redirection-aware: a quoted
  metacharacter ('/foo&bar') or a redirection (>vite.log, 2>&1) no longer
  misreads a single command as compound and skips flag injection (502).
- An existing --port no longer suppresses injecting a missing --host (and
  vice versa); injectFrameworkFlags already checks each independently.
- The integration test shim is written to node_modules/.bin and run with an
  isolated PATH so a real bun installed beside Node cannot shadow it.

Found in review by ctate (#366).

* fix(cli): resolve the framework through package scripts in one place

runApp read path.basename(commandArgs[0]) to decide whether to export HOST.
That is `bun` for `bun run dev`, so the Expo LAN carve-out never fired and
Metro got HOST=127.0.0.1, degrading its HMR websocket. The injectors and the
env binder now share resolveFrameworkBasename.

isCompoundShellScript becomes isUnsafeToAppendArgs and rejects a word-initial
`#`. The shell discards every flag appended after a comment, so injection
reported success and delivered nothing. A `#` inside a word still injects.

All five doc surfaces stated one skip class. The code has four.

* fix(cli): separate framework resolution from append safety

resolvePackageScriptTokens returned null both for "cannot identify" and for
"must not touch", so the Expo carve-out went blind for any script portless
declines to append to. `expo start --port 4567 # note` needs no injection and
still received HOST=127.0.0.1 in LAN mode.

An escaped space before `#` was read as a comment. `--open /foo\ #bar` is one
argument, so word start is now tracked as state.

Docs gain the fifth skip class: runner flags before the script name.

* fix(cli): treat a comment after a line continuation as a comment

The shell joins `vite dev \<newline># note` into `vite dev # note`, so the
comment opens and the appended flags are discarded. The detector cleared word
start on the escaped newline. That is right for `--open /foo\ #bar` and wrong
for a continuation, which the shell removes entirely.

* test(cli): check package-script forwarding against the shell

Seventy-four script shapes, grouped by shell construct, run through what
portless would run. A shim reads back the child's argv to assert one property:
if portless appends flags, the child receives them. A violation is invisible to
any test over the constructed array, because the array is correct and the shell
throws it away.

A second test pins the set of shapes portless declines, so an accidental skip is
as visible as a lost flag. Together they go red against every guard revision on
this branch.

Runs under bun in 1.5 seconds. PORTLESS_DIFFERENTIAL_RUNNERS widens the sweep.

* test(cli): pin framework identity apart from append safety

The corpus reads the child's argv, so it covers forwarded flags and not the
exported environment. Six expo shapes, four of them declined, must all still
resolve to `expo`. Goes red against the revision where resolution and append
safety shared one nullable return.

* test(cli): run the differential sweep where CI runs

The sweep defaulted to bun and neither CI job installs it, so the file skipped
on both runners. It now picks the first package manager present, and the two
pure-function tests no longer sit behind the runner gate.

Windows is excluded and recorded, since the shims are `#!/bin/sh`. Shapes that
invoke a runner by name are excluded when that runner is absent, so an
environment limit is not reported as a defect.

* fix(cli): treat `&>` as a separator, not a redirection

`&>` is a bash extension. Package managers run scripts through `sh`, which is
dash on Debian and Ubuntu, where `vite dev &> out.log --port 4567` backgrounds
the framework and loses every appended flag. Only POSIX descriptor duplication
stays exempt: `2>&1`, `>&2`, `2>&-`. Under /bin/dash the corpus goes from one
violation to zero.

The Expo environment test drove real bun, which CI does not install. It now
uses the package-manager shim helper, covering a plain expo script and one that
supplies its own port and ends in a comment.

* test(cli): give the package-manager shim a working PATH on Windows

The helper forces PATH=/usr/bin:/bin so a real bun cannot shadow the shim. On
Windows that leaves the child without System32, the .cmd shim never runs, and
all six tests using the helper fail with ENOENT on the capture file.
node_modules/.bin is collected before anything on PATH, so hermeticity does not
depend on it. Windows keeps the ambient PATH and POSIX is unchanged.

The helper now reports platform, node_modules/.bin contents, and the CLI exit
code and output when the shim does not run.

* fix(cli): inject server flags only into server subcommands

Framework CLIs reject `--port`/`--host` on anything that does not serve, so
forwarding them through a package script broke working commands: `vite
optimize` exits with `CACError: Unknown option --port`. Excluding `build` by
name did not cover `optimize`, `vp test`, `astro check` or the rest.

Each framework entry now lists its server subcommands and whether a bare
invocation starts a server, and flags are appended only when the subcommand
resolves to one of them. Resolving it needs flag arity: `--mode dev build` is a
build, not a dev server, and mode names collide with subcommand names. Entries
list the flags that consume the next token for the CLIs that accept flags
before the subcommand; a CLI that lists none is not classified once a flag
precedes the subcommand. An unrecognized subcommand is declined, which costs a
missed injection instead of a broken command.

Flags now go before a bare `--` rather than after it. Everything past `--` is
positional data, so a dev server invoked as `vite dev -- --extra` kept port
5173 while the proxy routed to the assigned port. A package script with its own
`--` is skipped instead, since appending cannot reach past it.

The rule sits in `injectFrameworkFlags`, so the argv path stops forwarding into
`vite build` as well.

* docs(cli): note that only server commands get injected flags

The scope limit listed compound, commented, env-prefixed, delegating and
runner-flagged scripts but not the non-server subcommands, nor the invocations
portless declines to classify, so a reader with `"build": "vite build"` had no
way to tell whether the missing port was a bug.

* fix(cli): preserve nested command argument ownership

* test(cli): make LAN bypass checks portable

---------

Co-authored-by: EfeDurmaz16 <efebarandurmaz05@gmail.com>
2026-08-12 22:57:34 -03:00
Railly Hugo d42c741ac6 Prepare v0.15.5 release (#375) v0.15.5 2026-07-30 01:38:44 -03:00
Railly Hugo 15ef06434c fix: support multi-segment custom TLDs (#365)
* fix: support multi-segment custom TLDs

Allow --tld and PORTLESS_TLD to accept multi-segment DNS names such as
dev.example.com, so local URLs can mirror production structure
(myapp.dev.example.com). validateTld now checks per-label DNS rules
(lowercase letters, digits, interior hyphens, 63 chars per label) and
the 253-character total limit instead of rejecting dots outright.

Routing, hostname construction, hosts sync, and per-host SNI certs
already handle dotted TLDs; the validator was the only blocker. Two
edge cases hardened along the way: parseHostnames strips the longest
matching suffix first so overlapping TLDs (example.com plus
dev.example.com) no longer duplicate segments, and parseHostname
rejects final hostnames over the 253-character DNS limit.

Adds regression tests for validation, TLD lists, hostname
construction, proxy routing, and SNI SAN coverage, plus README,
--help, and skill docs.

Unblocks OAuth providers that reject .localhost redirect URIs (#58)
and own-domain resolution over tailnet/LAN DNS.

Fixes #260

Co-Authored-By: KingPsychopath <work@owenabel.com>

* fix: warn on risky multi-segment TLD suffixes and skip invalid persisted TLD entries

- getRiskyTldReason: suffix-match risky TLDs so --tld example.dev still
  warns about preloaded HSTS (exact-match lookup never fired for
  multi-segment values)
- readTldsFromDir: skip invalid persisted entries with a warning instead
  of silently resetting the whole list to localhost when one entry fails
  the tightened validator
- docs: scope the --tld lead to lowercase DNS names, note the app.TLD
  253-char budget, document longest-match for overlapping TLDs in SKILL.md

* fix: narrow risky-TLD suffix matching to tree-wide risks, skip overlong TLDs in parseHostnames

- getRiskyTldReason: suffix-match only local/dev/app, whose risk applies
  to the whole suffix tree (mDNS, HSTS preload with includeSubDomains).
  Ownership-class TLDs (com, org, ...) stay exact-match, so the
  recommended --tld dev.example.com workflow no longer warns.
- RISKY_TLDS: .app is Google HSTS-preloaded like .dev; fix its reason
  string (was misclassified as a DNS-leak pitfall)
- parseHostnames: skip a TLD that pushes app.TLD past the 253-char DNS
  limit with a warning instead of failing the whole call; throw only
  when no TLD survives
- test: WS-over-H2 extended CONNECT routing with a multi-segment TLD
  (covers the #260 x #363 intersection post-merge)

* docs: document multi-segment TLDs on the docs site

Mirror the README multi-segment section on the landing page in condensed
form, add dev.example.com to the --tld row, and align the PORTLESS_TLD
row with SKILL.md wording.

* fix(certs): bound cert cache filename under NAME_MAX for long hostnames

Multi-segment TLDs allow hostnames up to the 253-char DNS limit, whose
naive cert cache filename (${host}-key.pem) exceeds NAME_MAX (255) and
fails generation with ENAMETOOLONG, breaking the TLS handshake for that
host. Bound the sanitized base to 255 minus the longest suffix and, when
it would overflow, keep a readable prefix plus a SHA-256 slice of the full
hostname: deterministic (cache still hits) and collision-resistant.

* docs: correct README claim that custom TLDs reach other devices

Outside LAN mode the proxy binds loopback only, and LAN mode forces the
.local TLD and ignores a custom --tld, so a custom-TLD app cannot be
reached from other devices today. The old wildcard-DNS/tailnet sentence
promised a combination the code does not support.

* fix(proxy): use longest-match TLD suffix in 404 suggestion

With overlapping custom TLDs (example.com and dev.example.com), the 404
page stripped the first-matching suffix instead of the longest, so
missing.dev.example.com suggested `portless missing.dev` and registered
the wrong hostname set. Pick the longest matching suffix; guard the
full-host match so it never slices to an empty string.

Found in review by ctate (#365).

---------

Co-authored-by: KingPsychopath <work@owenabel.com>
2026-07-22 17:55:15 -03:00
Railly Hugo e0c2af5734 fix(proxy): WebSocket over HTTP/2 extended CONNECT (RFC 8441) (#363)
* fix(proxy): proxy WebSocket upgrades over HTTP/2 and plain HTTP (#281)

Browser-initiated WebSocket upgrades failed through the TLS proxy on two
paths. Plain-HTTP upgrades on the TLS port were dropped with a warning,
since a WebSocket client cannot follow the 302 redirect normal requests
get. And WebSockets opened over an HTTP/2 session had no working path at
all: the server never advertised SETTINGS_ENABLE_CONNECT_PROTOCOL and had
no extended CONNECT handling, which broke dev server HMR (Next.js, Vite)
for browsers that negotiate h2 via ALPN.

Advertise RFC 8441 extended CONNECT on the HTTP/2 server and bridge
incoming CONNECT streams with :protocol websocket to a regular HTTP/1.1
Upgrade handshake against the backend: synthesize the Sec-WebSocket-Key
that h2 clients never send, answer with :status 200, drop the backend's
Sec-WebSocket-Accept and hop-by-hop headers, and pipe bytes both ways.
The handler hangs off the compat layer's 'connect' event (Node responds
405 to CONNECT without a listener) and keeps the same loop-detection,
unknown-host, and backend-rejection forwarding as the HTTP/1.1 upgrade
path. Plain-HTTP upgrades on the TLS port are now proxied via
handleUpgrade instead of dropped.

Co-Authored-By: Eric Hurkman <eric.hurkman@savvywealth.com>

* fix(proxy): answer 502 when a backend rejects a WebSocket handshake with non-HTTP data

When the upgrade request to the backend fails before a handshake answer
has been relayed (dead backend, or a malformed rejection such as the bare
"Unauthorized" string Next.js dev writes for an Origin missing from
allowedDevOrigins, which surfaces as an HTTP parse error), the proxy
previously destroyed the client socket with no response. Browsers showed
an undiagnosable handshake failure and the only evidence lived in the
proxy's own log. Write a real 502 response instead, falling back to
destroy once the handshake or a rejection has already been relayed.

Co-Authored-By: Eric Hurkman <eric.hurkman@savvywealth.com>

* fix(proxy): validate the backend's Sec-WebSocket-Accept before bridging h2 CONNECT

The h2 client never sees the key the proxy synthesizes for the backend
hop, so the proxy is the only party that can verify the RFC 6455 accept
hash. A 101 with the wrong hash now answers 502 instead of opening a
tunnel to something that is not speaking WebSocket.

Validation approach from #278.

Co-Authored-By: sanjevirau <2032047+sanjevirau@users.noreply.github.com>

* docs(cli): note WebSocket support over both protocol versions in the proxy help

---------

Co-authored-by: Eric Hurkman <eric.hurkman@savvywealth.com>
Co-authored-by: sanjevirau <2032047+sanjevirau@users.noreply.github.com>
2026-07-17 17:55:50 -03:00
Chris Tate 74c98682ed Prepare v0.15.4 release (#362) v0.15.4 2026-07-16 14:28:02 -05:00
Chris Tate fc767825a1 Bind proxy to loopback by default (#361)
- Limit proxy and redirect listeners to loopback outside LAN mode
- Preserve explicit LAN exposure and document listener behavior
- Add socket-level regression coverage
2026-07-16 14:09:19 -05:00
Chris Tate 0d8a70f9b7 Prepare v0.15.3 release (#358) v0.15.3 2026-07-14 09:03:47 -05:00
Chris Tate 8d04fb866a fix WSL sudo state and Windows CA trust (#357)
* fix WSL sudo state and Windows CA trust

- Resolve state paths from the original sudo user home.
- Trust the local CA in both Linux and Windows stores under WSL.
- Add coverage and user-facing documentation.

Co-authored-by: gerardbalaoro <gerardbalaoro@users.noreply.github.com>

* fix WSL and Windows CA cleanup

- Clean partial WSL trust states while retaining the CA identity for safe retries.
- Treat Windows store errors as failures and delete only the exact SHA-1 fingerprint.

Co-authored-by: gerardbalaoro <gerardbalaoro@users.noreply.github.com>

* fix Linux CA trust refresh retries

- Preserve CA identity when the Linux trust refresh fails.
- Retry pending trust refreshes after the source certificate is removed.

Co-authored-by: gerardbalaoro <gerardbalaoro@users.noreply.github.com>

---------

Co-authored-by: gerardbalaoro <gerardbalaoro@users.noreply.github.com>
2026-07-14 08:28:56 -05:00
Railly Hugo 0726da1693 Prepare v0.15.2 release (#356)
Patch release bundling the three fixes merged since v0.15.1:

- Tailscale funnel hostname routing (#352)
- IPv6-only dev server 502s (#353)
- Worktree prefix in multi-app mode (#355)
v0.15.2
2026-07-13 19:50:54 -05:00
Railly Hugo 4209ca8358 fix(proxy): route requests addressed to a route's tailscale hostname (#352)
* fix(proxy): route requests addressed to a route's tailscale hostname

findRoute only matched r.hostname, so inbound Tailscale Serve/Funnel
traffic (Host: <device>.<tailnet>.ts.net) never resolved to its app and
got the portless 404 page, even though the route table already stores
the funnel URL. Match the tailscaleUrl hostname as an exact match, in
both the request and WebSocket upgrade paths.

Fixes #297. Fixes #343.

* fix(proxy): disambiguate tailscale routes by port in findRoute

Multiple apps can share one .ts.net hostname on different HTTPS ports
(443, 8443, 10000). findRoute matched hostname only, so a request to
<device>.ts.net:8443 resolved to the first route on that hostname.

Match the tailscaleUrl authority (hostname and port) as a more specific
tier before the hostname match, which stays as a non-breaking fallback.

* fix(proxy): normalize request authority before matching tailscale routes

findRoute compared the raw Host against a route's tailscaleUrl authority,
which URL() had already lowercased and stripped of the default :443. So a
mixed-case Host never matched and returned the 404 page, and an explicit
<device>.ts.net:443 missed the authority tier and fell through to the
port-insensitive hostname tier, resolving to the wrong app when several
routes share a .ts.net hostname on different ports.

Normalize the request authority (lowercase, drop an explicit :443) and
lowercase the local hostname comparisons so every tier matches the
already-normalized stored authority.
2026-07-13 17:10:48 -03:00
Railly Hugo 7f5cb5ecb7 fix(cli): apply worktree prefix in multi-app mode (#355)
* fix(cli): apply worktree prefix in multi-app mode

handleDefaultMulti built each app's hostname straight from the package
label or config name, without the worktree prefix that handleDefaultSingle
already applies. So a bare `portless` in a monorepo git worktree produced
the same <name>.localhost hostnames as the main checkout and collided
across worktrees, even though the README documents that the worktree
prefix still applies.

Extract the shared applyWorktreePrefix helper and use it in both paths;
multi-app mode now emits <branch>.<name>.localhost like single-app mode.

Fixes #269.

* test(cli): cover multi-app worktree-prefix routing end-to-end

Drive the built CLI in multi-app mode inside a fake git worktree and
assert every registered route hostname carries the branch prefix
(<branch>.<pkg>.<project>.<tld>). Reverting handleDefaultMulti's worktree
detection turns this red, which the applyWorktreePrefix unit tests could
not catch: they exercised the helper, not the changed multi-app path.
2026-07-13 13:23:21 -03:00
Railly Hugo 7c9be3c7d7 fix(proxy): dial upstreams over both loopback families (#353)
The proxy hardcoded 127.0.0.1 as the upstream address, so apps that
bind localhost on Node 17+ (which resolves verbatim and may yield ::1
only, e.g. Vite's default server.host) got ECONNREFUSED and a 502 on
every request. Dial with autoSelectFamily over a fixed loopback address
list in both the request and WebSocket upgrade paths, and use the same
connection helper in isPortListening so ::1-only apps are not reported
dead.

Fixes #320.
2026-07-10 17:17:17 -03:00
Chris Tate 5940d3cb88 Prepare v0.15.1 release (#345)
* Prepare v0.15.1 release

- Bump the portless package version to 0.15.1
- Add release notes for multi-TLD proxy support
- Move release markers to the latest changelog entry

* Fix Windows route lock contention

- Extend route lock acquisition under heavy contention
- Reduce retry backoff cap for more frequent lock checks
- Give the concurrent route test enough time on hosted Windows
v0.15.1
2026-06-30 11:15:53 -05:00
Chris Tate 527578e155 Add multi-TLD proxy support (#344)
* Add multi-TLD proxy support

- Allow repeated --tld flags and comma separated PORTLESS_TLD values

- Register routes, service state, and TLS handling across configured TLDs

- Update docs, CLI help, and tests for multi-TLD behavior

* Fix multi-TLD workspace env handling
2026-06-30 10:58:42 -05:00
Chris Tate 8404575c30 Prepare v0.15.0 release (#338)
- Bump the portless package version to 0.15.0

- Add release notes for doctor diagnostics and proxy fixes

- Mirror the changelog entry in the docs site
v0.15.0
2026-06-24 14:14:34 -05:00
Chris Tate a112baef25 feat: add portless doctor (#337)
* feat: add portless doctor

- Add read-only health diagnostics for proxy, routes, DNS, CA trust, and LAN prerequisites

- Document the doctor command in CLI help, README, docs, and the portless skill

- Cover doctor behavior with focused CLI tests

* fix: harden doctor diagnostics

* fix: handle custom certs in doctor

* Fix doctor diagnostics edge cases

* Fix doctor nested state directory check
2026-06-24 12:55:52 -05:00
Chris Tate e4f65fa715 Fix HTTP/2 Host forwarding and --force takeover race (#328)
- Restore Host from :authority when forwarding h2 requests to HTTP/1.1 backends; Host-dependent apps saw 127.0.0.1 for all browser traffic
- Ownership-check route removal during exit cleanup so a --force takeover no longer ends with the new owner's route deregistered (dead URL)
- Note proxy auto-start in --help; regression tests for both fixes
2026-06-10 11:28:13 -05:00
Chris Tate 8cf5440504 Prepare v0.14.0 release (#324)
- Bump portless package version to 0.14.0

- Add release notes for ngrok sharing and tunnel lifecycle

- Add matching docs changelog entry
v0.14.0
2026-06-04 15:44:12 -05:00
Chris Tate cd0329dd84 Add ngrok sharing support (#323)
* Add ngrok sharing support

- Add an ngrok tunnel provider with CLI preflight, URL capture, and cleanup.

- Wire --ngrok, PORTLESS_NGROK, route metadata, and child URL env support.

- Document usage and cover behavior with provider, route, and CLI tests.

* Fix ngrok tunnel lifecycle
2026-06-04 14:49:51 -05:00
Chris Tate 551434f0e2 Prepare v0.13.1 release (#316)
* feat(service): support configurable startup services

* Persist service install proxy options across launchd, systemd, and Task Scheduler.
* Report installed service port, TLS, LAN, TLD, wildcard, and state directory in status.
* Document configurable service installs in CLI help, README, docs, and agent skill.

Co-authored-by: Houssam Elbadissi <35589610+skaldebane@users.noreply.github.com>
Co-authored-by: Romeo Scagliarini <22684678+ItalianScallian@users.noreply.github.com>
Co-authored-by: Neef Rehman <29544565+neefrehman@users.noreply.github.com>

* Fix service install config handling

* fix(service): reject unsupported LAN service installs

* fix(service): stop existing proxy on port changes

* fix windows ci failures

* Prepare v0.13.1 release

---------

Co-authored-by: Houssam Elbadissi <35589610+skaldebane@users.noreply.github.com>
Co-authored-by: Romeo Scagliarini <22684678+ItalianScallian@users.noreply.github.com>
Co-authored-by: Neef Rehman <29544565+neefrehman@users.noreply.github.com>
v0.13.1
2026-05-27 18:04:28 -05:00
Chris Tate cd26f70a69 feat(service): support configurable startup services (#315)
* feat(service): support configurable startup services

* Persist service install proxy options across launchd, systemd, and Task Scheduler.
* Report installed service port, TLS, LAN, TLD, wildcard, and state directory in status.
* Document configurable service installs in CLI help, README, docs, and agent skill.

Co-authored-by: Houssam Elbadissi <35589610+skaldebane@users.noreply.github.com>
Co-authored-by: Romeo Scagliarini <22684678+ItalianScallian@users.noreply.github.com>
Co-authored-by: Neef Rehman <29544565+neefrehman@users.noreply.github.com>

* Fix service install config handling

* fix(service): reject unsupported LAN service installs

* fix(service): stop existing proxy on port changes

* fix windows ci failures

---------

Co-authored-by: Houssam Elbadissi <35589610+skaldebane@users.noreply.github.com>
Co-authored-by: Romeo Scagliarini <22684678+ItalianScallian@users.noreply.github.com>
Co-authored-by: Neef Rehman <29544565+neefrehman@users.noreply.github.com>
2026-05-27 17:48:34 -05:00
Chris Tate 974ae781cb chore: require pnpm release age policy (#307)
* chore: require pnpm release age policy

- Add pnpm minimum release age 2880 and deny dependency build scripts.

- Require Node 24 and pnpm 11 across engines, CI, and version files.

- Refresh docs and lockfile for the runtime upgrade.

* ci: install pnpm after node setup

- Put Node 24 on PATH before pnpm/action-setup installs pnpm 11.

- Keep setup-node cache restore after pnpm is installed.

* ci: run javascript actions on node 24

- Opt CI and release workflows into GitHub JavaScript action execution on Node 24.

- Unblock pnpm/action-setup from installing pnpm 11.

* ci: avoid engine strict pnpm setup

- Remove the root engine-strict npm config.

- Drop the temporary Node 24 JavaScript action runtime override.

* test: use valid ca pem fixture

- Replace the fake CA text used in NODE_EXTRA_CA_CERTS tests with a valid PEM.

- Avoid Windows Node 24 aborting before the capture script runs.
2026-05-20 16:51:31 -05:00
Chris Tate d564c2c097 Prepare v0.13.0 release (#290) v0.13.0 2026-05-07 19:17:39 -05:00
Chris Tate 402e19ddc8 Add OS startup service support (#289)
* Add OS startup service support

Install a native startup service for the HTTPS proxy so clean localhost URLs are available after reboot without repeated sudo prompts.

* Harden service support and integrate with portless clean

- Change requireUnixElevation return type from boolean to void
- Switch macOS KeepAlive to unconditional restart
- Wrap ensureCerts with actionable error message mentioning OpenSSL
- Escape percent signs in Windows batch env values
- Make portless clean uninstall the startup service before removing state
- Add tests for KeepAlive, percent escaping, and tryUninstallService
- Document that portless clean removes the service automatically

* Fix hardcoded task name in tryUninstallService and add handleService tests

Use spec.taskName instead of a literal string in the Windows branch of
tryUninstallService so the query stays consistent if the constant changes.

Add unit tests for handleService dispatch: help output, no-subcommand
help, and unknown subcommand error exit.

* Fix implicit any in service test mock.calls map callbacks

* Fix flaky tryUninstallService test on machines without service installed

Mock fs.existsSync so the runner is actually invoked regardless of
whether the service file exists on the test machine.

* Fix Windows CI failures in service tests

Use path.posix.join for macOS/Linux paths so they produce forward
slashes regardless of the build platform. Fix the "not installed" test
to use a non-zero runner status so it works on win32 (where the code
path checks schtasks query status rather than fs.existsSync).

* Fix startup service lifecycle ownership

Ensure service installation takes over any existing proxy and make clean elevate when protected service entries need removal.

* Fix service uninstall runner error test

* Fix service clean elevation and Windows PATH

Keep clean running in the original user context after elevated service removal, and preserve PATH for Windows startup tasks so runtime OpenSSL lookups work.
2026-05-07 19:02:08 -05:00
Anshuman Bhardwaj 524b3732ea fix: add preflight check for funnel on tailscale (#282) 2026-05-06 11:11:37 -05:00
Chris Tate 4522cf70c9 Prepare v0.12.0 release (#265)
Bump version to 0.12.0 and add changelog entries for Tailscale sharing
and Funnel support.
v0.12.0
2026-04-29 11:05:17 -05:00
Chris Tate f8c7edce3c Add Tailscale sharing and Funnel support (#262)
* Add Tailscale sharing and Funnel support

Add --tailscale and --funnel flags that let any portless app be shared
over Tailscale with zero framework config. Each app is root-mounted on
its own Tailscale HTTPS port (443, 8443, 8444, ...) so no basePath
configuration is needed.

- New tailscale.ts module with readiness checks, port allocation,
  serve/funnel registration, and injectable TailscaleCommandRunner
  for testing
- --tailscale / --funnel flags work as global flags, per-app flags
  (portless myapp --tailscale next dev), and env vars
  (PORTLESS_TAILSCALE=1, PORTLESS_FUNNEL=1)
- Tailscale readiness is checked before proxy auto-start so failures
  are reported immediately with actionable messages
- portless list shows tailnet URLs alongside local URLs
- portless prune and portless clean remove stale tailscale serve
  registrations
- PORTLESS_TAILSCALE_URL injected into child process environment
- RouteMapping extended with optional tailscale metadata
- 41 new tests covering the tailscale module, CLI flags, and route
  metadata persistence

* fixes

* fixes
2026-04-28 20:54:45 -05:00
Chris Tate b4f89a5031 Prepare v0.11.1 release (#256)
* Fix zombie process orphaning on CLI crash

Spawn child processes with detached:true on Unix so they get their own
process group. Signal handlers now kill the entire group via
process.kill(-pid) instead of just the immediate child, preventing
orphaned dev servers from surviving CLI crashes or kill -9.

Add `portless prune` command as a safety net to find and kill orphaned
dev servers left behind by dead CLI sessions. It reads stale route
entries, checks if something is still listening on each port, and
terminates the orphan.

Add e2e tests covering both the SIGKILL cleanup path and the prune
command.

* Prepare v0.11.1 release
v0.11.1
2026-04-28 01:19:14 -05:00
Chris Tate 8252927d7e Fix zombie process orphaning on CLI crash (#255)
Spawn child processes with detached:true on Unix so they get their own
process group. Signal handlers now kill the entire group via
process.kill(-pid) instead of just the immediate child, preventing
orphaned dev servers from surviving CLI crashes or kill -9.

Add `portless prune` command as a safety net to find and kill orphaned
dev servers left behind by dead CLI sessions. It reads stale route
entries, checks if something is still listening on each port, and
terminates the orphan.

Add e2e tests covering both the SIGKILL cleanup path and the prune
command.
2026-04-28 01:07:09 -05:00
Chris Tate 3dfcb3cb5a Prepare v0.11.0 release (#253)
Bump version to 0.11.0 and add changelog entries for config file
support, zero-arg mode, multi-app orchestration, turborepo
integration, Rsbuild support, and the state directory security fix.
v0.11.0
2026-04-27 15:34:21 -05:00
Chris Tate a183e394a8 Config file support, zero-arg mode, and multi-app orchestration (#251)
* Add portless.json config file and zero-arg mode

Adds an optional portless.json for configuring app name, script, and
port without embedding portless in package.json scripts. Running bare
`portless` from a workspace root auto-discovers pnpm packages and
starts all dev scripts concurrently through the proxy. The --script
flag overrides the default "dev" script for a single invocation.

* Use npm scope as project base for multi-app names

In multi-app mode, package names are now composed as subdomains of the
npm scope (e.g., @og-sdk/docs becomes docs.og-sdk.localhost). Falls
back to inferring the project name from the workspace root when
packages are unscoped.

* Pass through scripts that already invoke portless

In multi-app mode, if a package's dev script starts with `portless`,
run it as-is without registering a route or injecting PORT/HOST. The
embedded portless invocation handles its own name and routing.

* Fix multi-app PATH and deduplicate project name

Augment PATH with node_modules/.bin from each package's directory so
local binaries (tsup, next, vite, etc.) are found. Skip the project
suffix when the package name already matches it (avoids names like
portless.portless).

* Fix proxy start prompting for trust on every launch

Cache the CA fingerprint in a marker file (ca.trusted) after a
successful trust so subsequent proxy starts skip the OS security
check and avoid re-triggering the macOS authentication dialog.
Also switch docs and example dev scripts to clean "next dev".

* Skip proxy route for build-only tools, support portless config in package.json

Non-server commands (tsup, tsc, esbuild, etc.) are auto-detected and
run without a proxy route. Adds "proxy" config field for explicit
control. Also supports a "portless" key in package.json — either as
an object or a string shorthand for the app name.

* Address PR review: config fixes, proxy auto-start, and refactors

- Rewrite splitCommand to respect quoted arguments
- Use frequency count for commonScope instead of first match
- Extract ensureProxyRunning helper; call from multi-app mode
- Remove loadConfig directory walk-up (check cwd only)
- Deduplicate clearTrustMarker call in generateCA
- Remove proxy field from config (system-level, not app-level)
- Fix early script filter to respect per-app overrides
- Add portless key to google-oauth example
- Replace invalid JSON comments with markdown headings in docs
- Extract spawnProxiedApp/spawnTaskApp helpers from handleDefaultMulti

* Address PR review: docs fixes, config precedence, proxy option

- Fix docs claiming config walks up directories (it does not)
- Clean up resolveAppConfig to use explicit field return
- Add JSDoc to isServerCommand explaining denylist design
- Add comment on sequential proxy spawning (port collision)
- Handle backslash escapes in splitCommand
- Remove non-null assertion on discovered proxy state
- Pass appConfig through to avoid redundant disk read
- Fix config precedence: package.json portless key wins (closest)
- Implement proxy: false config option for non-server scripts

* Add package manager delegation, turborepo support, and fix TLS state discovery

- Add detectPackageManager and resolveScriptCommand to delegate script
  execution to the correct package manager (pnpm/yarn/bun/npm) instead
  of parsing shell scripts directly. This enables reliable turborepo
  integration where `portless` is the dev script.
- Fix discoverState inferring tls=false when the proxy is running on
  port 443 but state files in /tmp were cleaned by the OS.
- Re-discover proxy state in multi-app mode when proxy is already running.
- Add turborepo integration docs and tests.

* Address PR 251 review: child lifecycle, shell safety, line buffering, proxy dedup, multi-PM workspaces, config errors

- Log exit codes per child and print summary in multi-app mode
- Spawn child processes directly without shell for PM-delegated commands
- Buffer partial lines in prefixed output streams, flush on end
- Extract resolveProxyDesiredState helper to deduplicate proxy config
- Support npm/yarn/bun workspaces via package.json "workspaces" field
- Replace process.exit in config validation with ConfigValidationError

* Address PR review: SIGKILL fallback, YAML flow sequences, glob wildcards, stream fixes

- Add 5s SIGKILL timeout after SIGTERM in multi-app cleanup to prevent
  hanging when children ignore SIGTERM
- Handle YAML flow sequence syntax (packages: [a, b]) in pnpm workspace
  parser, with JSDoc documenting the supported YAML subset
- Fall through from multi-app to single-app mode when no workspace
  packages have the target script, so workspace roots with their own
  portless.json still work
- Refactor expandSingleGlob into recursive expandSegments to support
  mid-path wildcards (packages/*/src) and prefix wildcards (apps/team-*)
- Use StringDecoder in prefixStream for correct multi-byte UTF-8 handling
  across chunk boundaries, and preserve empty lines from child output

* Eliminate /tmp state dir, fix symlink LPE, and clean up multi-app output

Move all proxy state from /tmp/portless to ~/.portless, fixing both the
macOS /tmp cleanup problem (repeated trust prompts) and the symlink LPE
vulnerability (GHSA-p67g-wf2j-g3h5). Add lstatSync symlink guard in
fixOwnership as defense-in-depth. Remove the interactive "Start proxy?"
prompt in favor of auto-starting. Rewrite multi-app startup output so
URLs stand out clearly. Also addresses PR 251 review items: re-throw
ConfigValidationError, fix splitCommand backslash handling in single
quotes, warn on unknown config keys, and remove unused export.

* Fix macOS CA cleanup when multiple certs share the same CN

Loop delete-certificate calls to remove all matching "portless Local CA"
entries from each keychain. The previous single call failed with
"is ambiguous, matches more than one certificate" when duplicate CAs
had accumulated from repeated trust prompts.

* Auto-trust CA after proxy auto-start

When the proxy is auto-started in single-app or multi-app mode and the
CA is not yet trusted, run handleTrust which has proper sudo elevation
logic. This prevents the scenario where a new user cancels the trust
dialog during proxy start and is left with browser cert errors and no
clear recovery path.

* Fix prettier formatting in configuration docs

* Use turbo for multi-app orchestration

When turbo.json is present, portless delegates to `turbo run <script>`
instead of spawning each app individually. Per-app PORT, HOST, and
PORTLESS_URL are injected via a lightweight --require loader so turbo
retains dependency ordering, output grouping, and task graph awareness.

Falls back to direct spawning when turbo is absent or when the config
sets `"turbo": false`.

* Fix turbo loader double-escape, cleanup guards, and direct-spawn route cleanup

- Remove redundant manual backslash escape in loaderSource; JSON.stringify
  already handles escaping for JS string literals
- Quote loader path in buildNodeOptions when it contains spaces
- Add cleanedUp guard to runWithTurbo and runWithDirectSpawn cleanup
  functions to prevent double execution on signal + explicit call
- Strip trailing \r in prefixStream so Windows \r\n output renders cleanly
- Track routes in runWithDirectSpawn and remove them in the signal handler,
  matching the pattern already used by runWithTurbo

* Fix doc gaps: state dir, CA path, turbo field, pkg portless key, hostname convention

- State directory docs still showed the old 3-tier table (port < 1024
  -> /tmp/portless). All state now lives in ~/.portless; updated
  SKILL.md and docs configuration page.
- CA path references in README and SKILL.md still mentioned
  /tmp/portless/ca.pem; simplified to ~/.portless/ca.pem.
- Added the `turbo` config field to all three config tables.
- Documented the package.json "portless" key as a standalone feature
  (was only shown in the Turborepo section).
- Documented the multi-app hostname convention
  (<package>.<project>.localhost derived from common npm scope).

* Make bare portless just work everywhere, monochrome output

- Remove config gates: bare `portless` and `portless run` resolve the
  dev script from package.json without requiring portless.json
- Extend PORTLESS=0 skip path to handle zero-arg mode
- Monochrome CLI output: bold for headers/errors, dim for warnings/muted,
  no color codes
- Multi-app display: show full package name (with scope), alphabetized,
  URLs dimmed
- Update docs, help text, and tests to reflect zero-config defaults

* Improve multi-app output and turbo integration

- Show full scoped package names in startup list, alphabetized
- Hide task-only packages from output (they still run)
- Use root package.json dev script for turbo when available,
  preserving user's flags like --concurrency and --filter
- Pass extra args after -- through to turbo

* Restore red/yellow ANSI colors for errors and warnings
2026-04-27 15:21:57 -05:00
Chris Tate 291dffb661 Switch release workflow to npm trusted publishing (#252)
Remove token-based auth in favor of OIDC. Add idempotent GitHub
release creation so a failed release can be retried without
re-publishing to npm.
2026-04-27 15:17:31 -05:00
Chris Tate adfe8d3a62 Add Rsbuild support (#244) (#250)
Rsbuild ignores PORT/HOST environment variables and requires --port and
--host CLI flags. Add rsbuild to the framework flag injection mapping so
portless can control which port and host Rsbuild binds to.
2026-04-26 01:03:41 -05:00
Chris Tate 6d65878536 Harden release workflow permissions (#249)
* Harden release workflow permissions

* Use Release environment for publishing
2026-04-26 00:04:54 -05:00
Chris Tate 08ce521bee adds trusted publishing (#239) 2026-04-20 00:30:58 -05:00
Chris Tate 0ddac0f995 Prepare v0.10.3 (#236) v0.10.3 2026-04-15 23:55:47 -05:00
Chris Tate 7e8a4945ea Fix stale TLD persisting across proxy restart (#235)
* Fix stale TLD persisting across proxy restart

proxy stop removed PID and port files but left proxy.tld, proxy.tls,
and proxy.lan on disk. On the next proxy start without flags,
discoverState read the stale proxy.lan marker, set persistedLanMode
to true, and defaultProxyConfig forced the TLD back to .local.

Clear all transient proxy state markers (TLD, TLS, LAN) in both the
daemon's SIGTERM cleanup handler and every success path in stopProxy.

* Clear state markers on corrupted PID file cleanup
2026-04-15 23:39:23 -05:00
Chris Tate aec0dd7e16 Prepare v0.10.2 release (#234) v0.10.2 2026-04-13 18:16:47 -05:00
Chris Tate 9afe81268c Fix CI failures on Node 20 and non-interactive auto-start (#233)
* Fix CI failures on Node 20 and non-interactive auto-start

Two fixes:

1. proxy.test.ts: Skip the stream reset cancellation test on Node < 22.11
   where streamResetBurst/streamResetRate options are not available (silently
   ignored, causing GOAWAY after ~1000 resets).

2. cli.ts: Restore non-interactive auto-start for unprivileged ports. PR #232
   changed the non-interactive guard to block all auto-start when stdin is not
   a TTY, but sudo is only needed for privileged ports. When sudo is not
   required, no user prompt is needed, so auto-start should proceed regardless
   of TTY status.

* Skip stream reset test on Windows

The rapid 1500-request burst overwhelms the test backend on Windows CI,
causing a 502 on the final alive-check request. The feature itself is
OS-independent; this is a test infrastructure limitation.
2026-04-13 18:04:56 -05:00
Chris Tate 82a3b93bae Fix proxy auto-start in non-interactive terminals and with previous config (#232)
- Non-interactive environments (no TTY, CI=1) now exit with a clear error instead of prompting, so task runners like turborepo fail early with manual start instructions (#224)
- Auto-start reads persisted state files (proxy.port, proxy.tls, proxy.tld, proxy.lan) so a reboot does not silently revert to default configuration; explicit env vars and flags still take priority (#225)
- Fix discoverState() fallback to read TLS/TLD markers from disk instead of hardcoding tls:false and the default TLD

Closes #224, closes #225
2026-04-13 17:25:30 -05:00
Chris Tate ed3aa18e7c Fix ERR_HTTP2_PROTOCOL_ERROR from stream reset flood during HMR (#231)
Browsers send rapid RST_STREAM CANCEL frames during Vite/Nuxt HMR and
page navigations. Node's default stream reset tolerance (~1000 cumulative
resets) causes GOAWAY INTERNAL_ERROR, killing the HTTP/2 session and
surfacing as ERR_HTTP2_PROTOCOL_ERROR in Chrome.

- Add streamResetBurst/streamResetRate to createSecureServer (Node 22.11+;
  silently ignored on older versions)
- Absorb session-level and per-stream errors from abrupt disconnects
- Use res.destroy() for mid-stream backend errors to send proper RST_STREAM
  instead of a content-length mismatch

Fixes #217. Fixes #221.
2026-04-13 17:20:54 -05:00
Chris Tate 203a3e0cf5 Fix lock contention when running parallel commands (#174) (#230)
* Fix lock contention when running parallel commands (#174)

Replace the fixed-delay retry strategy (20 retries * 50ms = 1s budget)
with exponential backoff and jitter (10ms-500ms delays, 5s budget).
The old approach caused "Failed to acquire route lock" errors in
monorepos running many portless commands in parallel.

* Fix Windows CI: use file URL for ESM import in worker script

On Windows, path.resolve produces backslash-separated paths which
Node.js ESM does not accept in import specifiers. Convert to a
file:// URL with pathToFileURL so the worker script loads correctly
on all platforms.

Also reorder assertions so lock failures surface before the ENOENT
on routes.json.
2026-04-13 17:14:40 -05:00
Chris Tate 01cba606a4 Fix proxy startup failure when macOS security command is slow (#228) (#229)
The macOS `security` CLI can hang or be very slow when the Keychain
Services daemon (`securityd`) is unresponsive, causing ETIMEDOUT errors
that cascade into a "Proxy failed to start" failure.

Three changes fix this:

1. Increase macOS `security` timeouts: verify-cert 5s to 15s,
   add-trusted-cert (with GUI dialog) 30s to 120s, root trust 30s to 60s.

2. Pass --skip-trust to the daemon process so it does not re-run the
   same trust flow the parent already attempted. This eliminates the
   double-timeout that caused waitForProxy to give up.

3. Return an actionable ETIMEDOUT error message that suggests restarting
   securityd instead of the raw "spawnSync security ETIMEDOUT".

Closes #228
2026-04-13 16:50:21 -05:00
Chris Tate 34c2c5dff3 Automatically inject NODE_EXTRA_CA_CERTS for child processes (#220)
* Automatically inject NODE_EXTRA_CA_CERTS for child processes

Fixes Node.js server-side fetches failing with "self-signed certificate in certificate chain" when making HTTPS requests to portless-proxied services.

## Problem
Node.js uses its own hardcoded CA bundle and ignores the system trust store. When server-side code (like Next.js Server Components) makes fetch requests to portless HTTPS URLs, Node.js rejects the portless CA as untrusted, even though browsers work fine.

## Changes
- Automatically inject `NODE_EXTRA_CA_CERTS` pointing to the portless CA cert when spawning child processes
- Only set when TLS is active and user hasn't already configured it
- Added comprehensive tests covering TLS enabled/disabled and user override scenarios
- Updated documentation to reflect automatic injection

## Implementation
The fix adds the CA cert path to the child process environment alongside existing variables like `PORT`, `HOST`, and `PORTLESS_URL`. This tells Node.js to trust the portless CA in addition to its built-in certificate bundle.

Fixes #218

* Fix CI: use script files for CA cert tests, show var in log line

- Write capture script to a file instead of using `node -e` with inline
  JS, fixing cmd.exe quoting failures on Windows
- Extract runWithMockProxy helper to deduplicate test setup
- Move caEnv computation before the Running: log line so
  NODE_EXTRA_CA_CERTS is visible in the status output

* Address review feedback on NODE_EXTRA_CA_CERTS injection

- Quote the CA path in the Running: log line to handle spaces
- Add test for ca.pem missing (exercises the fs.existsSync guard)
- Add comment noting process.env coupling for future-proofing
2026-04-11 08:55:57 -05:00