portless-ops
Portless local-dev HTTPS proxy: replaces port numbers with named URLs (Caddy/nginx alternative for local dev). Triggers on: portless, local https proxy, named localhost URL, custom TLD, portless alias, portless.json, local CA trust, boot persistence, monorepo routing, Tailscale d
Install
npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/portless-ops
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
git clone https://github.com/0xDarkMatter/claude-mods.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 0xdarkmatter/claude-mods collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Portless Operations
Portless (Vercel Labs) is a local-dev HTTPS proxy that replaces port numbers with named URLs. Replacement for Caddy/nginx in the local-dev role; not for production.
Upstream: vercel-labs/portless (Apache-2.0). The portless repo ships canonical skills in its source tree (not in the npm package). Verbatim copies kept in references/:
references/upstream-portless.md— full CLI reference, integration patterns (zero-config, monorepo, turborepo, worktrees, Tailscale), HTTPS/LAN setup, troubleshootingreferences/upstream-oauth.md— OAuth provider compatibility (Google, Apple, Microsoft, Facebook, GitHub), TLD selection for OAuth, callback URI configuration
This SKILL.md adds operational patterns we've validated in production (Windows specifics, the static-alias-with-supervisor pattern, TLD-reset procedure, supply-chain hygiene). For canonical CLI usage, prefer the upstream reference files.
Mental Model
| Layer | Portless owns | Portless does NOT own |
|---|---|---|
| Routing | hostname → port mapping, HTTPS termination, HTTP/2, CA trust | process supervision (use Process Compose or PM2) |
| Naming | <name>.<tld> shape — one TLD per proxy |
per-service distinct TLDs (not supported) |
| Process spawning | when invoked as portless myapp <cmd> |
crash recovery, restart policy, health checks |
Key shape constraint: portless always renders <alias-name>.<tld>. You can't mix two TLDs in one proxy because TLD is per-instance — a dotted alias like portless alias api.<app> 8108 gets the TLD appended → api.<app>.<tld>.
Install
# Pin a specific version (zero runtime deps, low supply-chain surface)
npm install -g portless@0.13.0
# Verify
portless --version
Record the pinned version in your repo. Upgrades are explicit PRs.
CLI Quick Reference
# example values — substitute your own (TLD, app name, ports)
# Proxy lifecycle
portless proxy start --tld lab --port 443 # HTTPS proxy on 443, *.lab routes
portless proxy start --tld test --port 1355 # Non-privileged port for testing
portless proxy stop
portless trust # Add CA to system trust store
# Aliases (for services portless didn't spawn — PM2, Process Compose, Docker, etc.)
portless alias axiom 8108 # https://axiom.lab → :8108
portless alias axiom 8108 --force # Overwrite existing
portless alias --remove axiom # Note: appends TLD! be careful
# Spawn-mode (portless manages the process)
portless myapp next dev # https://myapp.lab, auto port 4000-4999
portless run pnpm dev # Auto-infer name from package.json
# Discovery (agent-friendly)
portless list # Active routes
portless get axiom # Returns: https://axiom.lab
# Boot persistence
portless service install # OS-native startup task
portless service status
portless service uninstall
The Static-Alias Pattern (portless + external process supervisor)
The common pattern: a process supervisor (Process Compose, PM2, Docker) runs your dev servers on fixed ports. Portless just routes named URLs to those ports.
# Started by Process Compose, listening on <your-port>
# Now make it reachable at https://<your-app>.<your-tld>
portless alias <your-app> <your-port>
Decoupling means:
- Restart the dev server (
pm2 restart <your-app>,process-compose process restart <your-app>) → portless keeps routing transparently - Swap one supervisor for another → portless layer is untouched
Source of truth pattern: keep alias registration in your supervisor config. Example scripts/install.ps1:
$services = (yq '.processes | keys | .[]' process-compose.yaml)
foreach ($svc in $services) {
$port = (yq ".processes.$svc.readiness_probe.http_get.port" process-compose.yaml)
if ($port -and $port -ne "null") {
portless alias $svc $port --force
}
}
TLD Selection
| TLD | When to use | Caveats |
|---|---|---|
.localhost (default) |
Quickest start | Auto-resolves to 127.0.0.1 on most systems |
.lab |
Personal/distinctive | Not IANA-reserved (no DNS collision in practice for local) |
.test |
OAuth-friendly | IANA-reserved; safe |
.dev |
OAuth (Google, Apple) | Google-owned, forces HTTPS — portless handles this fine |
.local |
Avoid | mDNS/Bonjour conflict |
OAuth providers reject .localhost subdomains (not in Public Suffix List). Switch to --tld test or --tld dev for OAuth dev work. See references/upstream-oauth.md for full per-provider setup.
Reset (clean slate)
# Stop proxy
portless proxy stop
# Wipe all aliases (routes.json)
rm ~/.portless/routes.json # Linux/macOS
Remove-Item "$env:USERPROFILE\.portless\routes.json" # PowerShell
# Start fresh with desired TLD
portless proxy start --tld <tld> --port 443
# Re-register aliases from your supervisor config
This is the right pattern when you change TLD — portless alias --remove appends the active TLD which makes it fight you.
Windows-Specific Notes
openssl required on PATH
Portless uses OpenSSL to generate the local CA. Git for Windows ships it:
# Persistent: add to user PATH
$gitBin = "C:\Program Files\Git\usr\bin"
$current = [Environment]::GetEnvironmentVariable("PATH", "User")
if ($current -notlike "*$gitBin*") {
[Environment]::SetEnvironmentVariable("PATH", "$gitBin;$current", "User")
}
Without it: Error: openssl failed: spawnSync openssl ENOENT
Boot persistence
portless service install registers a Task Scheduler entry. Pair it with your supervisor's own boot task (e.g., for Process Compose, register a separate task via scripts/boot-task-install.ps1).
Verify both registered:
Get-ScheduledTask | Where-Object {
$_.TaskName -like "*ortless*" -or $_.TaskName -like "*ompose*"
}
curl vs browser cert handling
curl on Windows uses its own bundled CA store, not the system one. So curl https://<your-app>.<your-tld>/ returns code 000 (cert untrusted) even after portless trust. Browsers work fine because they use the system store.
Test from curl with -k (skip verify), or --cacert ~/.portless/ca.pem:
curl -k https://<your-app>.<your-tld>/ # quick test
curl --cacert ~/.portless/ca.pem https://<your-app>.<your-tld>/ # proper
Common Errors
| Error | Cause | Fix |
|---|---|---|
openssl failed: spawnSync openssl ENOENT |
OpenSSL not on PATH | Add Git's usr/bin to PATH |
Error: No alias found for "foo.lab" (you asked for foo) |
--remove appends TLD; sometimes adds an extra |
Wipe routes.json and re-register |
| Browser shows cert warning | CA not in system trust store | Re-run portless trust (may need admin) |
https://name.lab shows "No app registered" |
Alias not set or proxy stopped | portless list to confirm; re-register if needed |
Safari can't resolve *.lab |
Safari uses system DNS, not Node's resolver | portless hosts sync to write /etc/hosts |
Port 443 conflict on portless proxy start |
Another service bound (Caddy, IIS) | Stop the other service, or use --port 1355 for testing |
Worked Example: Replacing Caddy with portless
A PM2+Caddy to Process Compose+portless migration is worth keeping in its own small repo (e.g. ~/infra/local-stack/), with these key files:
process-compose.yaml— supervisor config with health-checked servicesscripts/cutover.ps1— stops PM2/Caddy, starts portless+PC, registers aliasesdocs/MIGRATION-LOG.md— every issue hit during cutover and how it was solveddocs/SUPPLY-CHAIN.md— pinning + verification procedures
Anti-Patterns
BAD: portless alias name 8000; portless alias name 8001 # second silently fails without --force
GOOD: portless alias name 8001 --force
BAD: use portless as production reverse proxy
GOOD: keep portless as dev-only; production = nginx/Caddy/cloud LB
BAD: rely on portless for crash recovery (it has none for spawned processes)
GOOD: pair portless with Process Compose / PM2 / supervisord for supervision
BAD: change TLD by stopping/starting with different --tld and hoping aliases update
GOOD: stop proxy, wipe routes.json, start with new TLD, re-register from supervisor config
Resources in this skill
references/
upstream-portless.md— canonical portless SKILL.md verbatim (CLI ref, monorepo, turborepo, worktrees, LAN, Tailscale, HTTPS, troubleshooting)upstream-oauth.md— canonical OAuth setup for Google/Apple/Microsoft/Facebook/GitHubtld-selection.md— decision tree for picking the right TLD; trade-offs of.test/.dev/.localhost/custom-ownedwindows-specifics.md— openssl PATH, certutil quirks, curl-vs-browser cert handling, PS 5.1 gotchasintegration-patterns.md— combos with Process Compose / Docker / PM2 / Tailscale / git worktrees
scripts/
install-portless.ps1— verified install: inspect tarball, scan for IOCs from recent attacks, install only if cleanreset-state.ps1— clean state reset (used when changing TLD;--removecan't clear old-TLD aliases)sync-aliases-from-yaml.ps1— derive portless aliases from a process-compose.yaml
assets/
portless.json.simple.json— single-app config templateportless.json.monorepo.json— workspace monorepo with name overridesportless.json.with-custom-tld.json— documents TLD choice in repopackage.json-portless-key.json— alternative: portless config inside package.json
Related Skills
process-compose-ops— the supervisor we pair with portlessmcp-ops— agent-friendly tooling; portlessget <name>provides URL discovery for agentscli-ops— general CLI tool patterns
Files (claude-mods)
-
assets
-
package.json-portless-key.json 797 B
{ "_comment_purpose": "Example showing how to put portless config inside an existing package.json instead of a separate portless.json file", "_comment_usage": "Add the 'portless' key to your existing package.json. String shorthand for just the name, object for full config.", "name": "@myorg/myapp", "version": "1.0.0", "scripts": { "dev": "next dev", "dev:app": "next dev", "build": "next build" }, "_string_shorthand_example": "portless: 'myapp'", "portless": { "name": "myapp", "script": "dev:app" }, "_alternative_string_form": { "_comment": "If you just want to set the name, use a string instead:", "_example": "\"portless\": \"myapp\"" }, "_precedence": "CLI flags > package.json portless key > portless.json app entry > defaults" } -
portless.json.monorepo.json 680 B
{ "_comment_purpose": "Monorepo portless.json — runs all workspace packages with name overrides", "_comment_usage": "Drop in monorepo root. Each apps/<pkg> with its own dev script gets a URL.", "_comment_default_naming": "Without an apps map, hostnames are <pkg>.<project>.<tld>", "_comment_explicit_naming": "With apps map, exact name as written. Keys are relative paths.", "apps": { "apps/web": { "name": "myapp" }, "apps/api": { "name": "api.myapp" }, "apps/docs": { "name": "docs.myapp", "script": "dev:docs" } }, "_optional_turbo": true, "_strip_underscored_keys_before_using": "remove all keys starting with underscore before committing" } -
portless.json.simple.json 386 B
{ "_comment_purpose": "Single-app portless.json — name + script overrides for one project", "_comment_usage": "Drop in repo root. portless picks it up automatically.", "name": "myapp", "_optional_script": "dev", "_optional_appPort": 4123, "_optional_proxy": true, "_strip_underscored_keys_before_using": "remove all keys starting with underscore before committing" } -
portless.json.with-custom-tld.json 493 B
{ "_comment_purpose": "portless.json with documented TLD choice — actual TLD is a proxy-level setting (PORTLESS_TLD env or --tld flag); this file just records the choice for the repo", "name": "myapp", "_tld_choice": "test", "_tld_reason": "IANA-reserved (RFC 6761), OAuth-safe, no DNS collision risk", "_proxy_start_command": "portless proxy start --tld test --port 443", "_strip_underscored_keys_before_using": "remove all keys starting with underscore before committing" }
-
-
references
-
integration-patterns.md 5.3 KB
# Integration Patterns Portless is a routing layer. It pairs with a process supervisor that owns lifecycle. Three common combos: ## Pattern A — Portless + Process Compose (recommended for local dev) The whole stack speaks YAML and gives you health checks, restart policies, dependencies, and an MCP server. ```yaml # process-compose.yaml — supervisor owns processes processes: myapp: command: "uv run python -m myapp" working_dir: "X:/path/to/myapp" readiness_probe: http_get: { host: localhost, port: 8000, path: / } availability: { restart: always } ``` ```powershell # Portless owns routing — aliases derive from supervisor config portless proxy start --tld test portless alias myapp 8000 # https://myapp.test → :8000 ``` **Single source of truth:** `process-compose.yaml`. Aliases derive from it. See the [`process-compose-ops`](../../process-compose-ops/SKILL.md) skill for the supervisor side. ## Pattern B — Portless + Docker When some services run in containers (databases, n8n, custom containers) and others run locally. ```bash # Container started independently, listening on host port 5678 docker run -d -p 5678:5678 --name n8n n8nio/n8n # Make it reachable at a named URL portless alias n8n 5678 # → https://n8n.test ``` This decouples container lifecycle from portless. `docker stop n8n` doesn't affect portless's alias (URL just stops resolving until container's back up). ## Pattern C — Portless + PM2 (legacy, when migration isn't worth it) Same shape as Pattern A: ```javascript // ecosystem.config.js module.exports = { apps: [ { name: 'myapp', script: 'python', args: '-m myapp', cwd: 'X:/path/myapp' } ] }; ``` ```powershell # PM2 owns processes pm2 start ecosystem.config.js # Portless owns routing portless alias myapp 8000 ``` **Note:** PM2 5.x has 15+ known CVEs in its transitive npm dependencies (axios, lodash, tar, minimist...). Process Compose's Go-binary attack model is much narrower. New stacks should pick Pattern A. ## Pattern D — Portless Spawning the Process (no separate supervisor) For zero-config / one-off / monorepo cases, portless can spawn the process itself: ```bash # Run a Next.js dev server through the proxy portless myapp next dev # → https://myapp.test, with auto-assigned port # From a monorepo root, run all packages' dev scripts portless ``` Limitations: - **No crash recovery** — if the process dies, portless does NOT restart it - **No health checks** — only "process exists" matters - **No dependencies** between processes Good for: short-lived dev sessions, monorepos where everything is JS/TS and Vercel-like ergonomics matter. Bad for: long-running services, dependency chains, anything you want supervised through a reboot. Use Pattern A instead. ## Pattern E — Portless + Tailscale (team sharing) Share local dev with teammates without a public deployment: ```bash # Start the proxy portless proxy start --tld test # Run with --tailscale to register a tailnet URL too portless myapp --tailscale next dev # → https://myapp.test (you, local) # → https://yourdevbox.your-team.ts.net (teammates, tailnet) ``` Requirements: - `tailscale` CLI installed and connected - HTTPS enabled on the tailnet (Tailscale admin console) - For public sharing: Funnel enabled (`--funnel` instead of `--tailscale`) See upstream docs (`references/upstream-portless.md`, section "Tailscale sharing") for the full setup. ## Pattern F — Subdomain Routing in a Monorepo ```bash portless myapp next dev # → https://myapp.test portless api.myapp pnpm start # → https://api.myapp.test portless docs.myapp next dev # → https://docs.myapp.test ``` Add `--wildcard` so any unregistered subdomain falls back to the parent: ```bash portless proxy start --wildcard --tld test # Now tenant1.myapp.test → routes to myapp (whatever's registered) ``` Useful for multi-tenant apps where you want to test tenant resolution locally. ## Pattern G — Git Worktrees with Per-Branch URLs Portless auto-detects git worktrees and prepends the branch name as a subdomain: ```bash # Main worktree cd D:/code/myapp portless run next dev # → https://myapp.test # Linked worktree on branch "fix-ui" cd D:/code/myapp/.worktrees/fix-ui portless run next dev # → https://fix-ui.myapp.test ``` No config — just works. Each worktree gets its own URL automatically, avoiding browser cookie/storage cross-contamination between branches. ## Common Anti-Patterns ``` BAD: use portless's spawn mode for production-equivalent local services GOOD: use Pattern A (Process Compose supervisor + portless routing) BAD: let two different stacks fight over the same TLD GOOD: pick TLD per machine, document it; or use different ports BAD: hardcode portless URLs in service config (e.g. CORS allowlists) GOOD: read PORTLESS_URL env var that portless injects into spawned processes (Pattern D only); or use SERVICE_URL env injection in your supervisor BAD: install portless globally without pinning a version GOOD: pin version: npm install -g portless@0.13.0; record in your repo ``` ## See Also - `process-compose-ops` skill for the supervisor side of Pattern A - `references/upstream-portless.md` for full CLI reference (auto-port assignment, etc.) - `references/tld-selection.md` for picking the right TLD up front -
tld-selection.md 4.2 KB
# TLD Selection for portless The TLD is a per-proxy setting — every alias resolves as `<name>.<tld>`. Choose with care: changing the TLD later means re-registering every alias. ## Quick Reference | TLD | Resolution | OAuth-safe | Best for | |---|---|---|---| | `.localhost` (default) | Native in Chrome/Firefox/Edge; needs `/etc/hosts` on Safari | ❌ Rejected by Google/Apple | Solo dev, no OAuth | | `.test` | IANA-reserved (RFC 6761) | ✅ Yes | Recommended default — safe everywhere | | `.lab` | Not reserved (no DNS collision in practice) | Provider-dependent | Personal/distinctive naming | | `.dev` | Google-owned, HSTS-preloaded (forces HTTPS) | ✅ Yes | OAuth-heavy projects | | `.app` | Google-owned, HSTS-preloaded | ✅ Yes | Similar to `.dev` | | `.local` | mDNS — **conflicts with Bonjour/Avahi** | Provider-dependent | LAN mode only (`--lan`) | | Anything you own (e.g. `.local.mycorp.dev`) | Whatever you configure | ✅ Yes | Teams, enterprise | ## Decision Flow ``` Do you need OAuth (Google/Apple/Facebook)? ├── Yes │ ├── Do you control a real domain? → use a subdomain of it (best) │ └── No → use .dev or .test (good) └── No ├── Solo dev, no special needs → .test (recommended) or .localhost └── Personal preference → .lab or any short distinctive TLD ``` ## Detailed Notes ### `.localhost` (default) - Auto-resolves to `127.0.0.1` in all modern browsers (RFC 6761) - **Safari** needs `/etc/hosts` entries — run `portless hosts sync` - Rejected by **Google OAuth** (not in their bundled Public Suffix List) - Rejected by **Apple** (no localhost or IP addresses at all) - Accepted by **Microsoft / GitHub** with caveats ### `.test` (recommended for general use) - IANA-reserved for testing per RFC 6761 - No real DNS will ever resolve `.test`, so no collision risk - Accepted by every OAuth provider that respects the Public Suffix List - Requires `/etc/hosts` entries (portless auto-syncs) ### `.dev` / `.app` (Google-owned) - Public Suffix List entries — provider-accepted - **HSTS-preloaded by Google** — browsers force HTTPS, so plain HTTP doesn't work - Portless defaults to HTTPS so this is fine - Slight cost: every browser hit issues an HSTS check (negligible in dev) ### `.local` (avoid for non-LAN use) - mDNS uses `.local` for Bonjour / Avahi auto-discovery - Using `--tld local` without `--lan` mode confuses macOS in particular - **Only use** when you intend LAN sharing — portless's `--lan` mode actually advertises `<name>.local` over mDNS ### Custom owned domain The most defensible option for OAuth and team setups: ```bash # You own example.com. Set up DNS: *.local.example.com A 127.0.0.1 # Then portless: portless proxy start --tld local.example.com portless myapp next dev # → https://myapp.local.example.com ``` - OAuth providers see a real, resolvable domain - Other devs on your team can resolve too (real DNS, no /etc/hosts edits) - Apple's strict server-side resolution check passes - Zero risk of accidentally hitting a real domain you don't own ### `.lab` (or any short distinctive TLD) - Not in the IANA root zone, not reserved - Won't ever resolve publicly, so no collision risk in practice - Short and memorable for personal use - **Won't work for OAuth** — Google/Apple require Public Suffix List domains - Good for personal dev when you don't need OAuth or external services ## Change Procedure If you need to change TLD (e.g. you started on `.localhost`, now need OAuth): ```bash # Stop proxy portless proxy stop # Wipe routes (because `portless alias --remove` appends the active TLD, # making it impossible to remove old-TLD aliases cleanly) rm ~/.portless/routes.json # Restart with new TLD portless proxy start --tld test --port 443 # Re-register aliases against new TLD portless alias myapp 8000 --force portless alias api 8001 --force ``` Update any bookmarks, OAuth provider configs, and `NEXTAUTH_URL` / `AUTH_URL` / `BASE_URL` environment variables. ## See Also - `references/upstream-oauth.md` — per-provider OAuth setup - `references/upstream-portless.md` — full CLI reference (search "tld" or "--tld") - `references/integration-patterns.md` — combining portless with process supervisors -
upstream-oauth.md 7.7 KB
# Upstream portless oauth SKILL.md (verbatim) **Source:** https://github.com/vercel-labs/portless/blob/main/skills/oauth/SKILL.md **Fetched:** 2026-05-12 **License:** Apache-2.0 **Note:** Verbatim copy of the upstream OAuth integration skill from portless. Refresh on portless version bumps. **Deviation:** the trailing `examples/google-oauth` link was absolutised to the upstream repo URL — it is repo-relative upstream and has no target here. Re-apply after any refresh. --- --- name: oauth description: Configure OAuth providers (Google, Apple, Microsoft, Facebook, GitHub, etc.) to work with portless local dev URLs. Use when setting up OAuth redirect URIs, fixing "redirect_uri_mismatch" or "invalid redirect" errors, configuring sign-in providers for local development, or when a provider rejects .localhost subdomains. Triggers include "OAuth not working with portless", "redirect URI mismatch", "Google/Apple/Microsoft sign-in fails locally", "configure OAuth for local dev", or any task involving OAuth callback URLs with portless domains. --- # OAuth with Portless OAuth providers validate redirect URIs against domain rules. `.localhost` subdomains fail on most providers because they are not in the Public Suffix List or are explicitly blocked. Portless fixes this with `--tld` to serve apps on real, valid domains. ## The Problem When portless uses the default `.localhost` TLD, OAuth providers reject redirect URIs like `http://myapp.localhost:1355/callback`: | Provider | `localhost` | `.localhost` subdomains | Reason | | --------- | ----------- | ----------------------- | ------------------------------ | | Google | Allowed | Rejected | Not in their bundled PSL | | Apple | Rejected | Rejected | No localhost at all | | Microsoft | Allowed | Allowed | Permissive localhost handling | | Facebook | Allowed | Varies | Must register each URI exactly | | GitHub | Allowed | Allowed | Permissive | Google and Apple are the strictest. Microsoft and GitHub are more lenient with localhost. ## The Fix Use a valid TLD so the redirect URI passes provider validation: ```bash portless proxy start --tld dev portless myapp next dev # -> https://myapp.dev ``` Any TLD in the Public Suffix List works: `.dev`, `.app`, `.com`, `.io`, etc. ### Use a domain you own Bare TLDs like `.dev` mean `myapp.dev` could collide with a real domain. Use a subdomain of a domain you control: ```bash portless proxy start --tld dev portless myapp.local.yourcompany next dev # -> https://myapp.local.yourcompany.dev ``` This ensures no outbound traffic reaches something you don't own. For teams, set a wildcard DNS record (`*.local.yourcompany.dev -> 127.0.0.1`) so every developer gets resolution without `/etc/hosts`. ## Provider Setup ### Google 1. Go to [Google Cloud Console > Credentials](https://console.cloud.google.com/apis/credentials) 2. Create or edit an OAuth 2.0 Client ID (Web application) 3. Add the portless domain to **Authorized JavaScript origins**: `https://myapp.dev` 4. Add the callback to **Authorized redirect URIs**: `https://myapp.dev/api/auth/callback/google` Google validates domains against the Public Suffix List. The domain must end with a recognized TLD. `.localhost` subdomains fail this check; `.dev`, `.app`, `.com`, etc. all pass. HTTPS is required for `.dev` and `.app` (HSTS-preloaded). Portless handles this automatically with `--https`. ### Apple Apple Sign In does not allow `localhost` or IP addresses at all. 1. Go to [Apple Developer > Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources) 2. Register a Services ID 3. Configure Sign In with Apple, adding the portless domain as a **Return URL**: `https://myapp.dev/api/auth/callback/apple` The domain must be a real, publicly-resolvable domain name. Since portless maps the domain to 127.0.0.1 locally, the browser resolves it but Apple's server-side validation may require the domain to resolve publicly too. If Apple rejects the domain, add a public DNS A record pointing to 127.0.0.1 for your dev subdomain. ### Microsoft (Entra / Azure AD) 1. Go to [Azure Portal > App registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps) 2. Create or edit an app registration 3. Under **Authentication**, add a **Web** redirect URI: `https://myapp.dev/api/auth/callback/azure-ad` Microsoft allows `http://localhost` with any port for development. It also accepts `.localhost` subdomains in most cases. Using a custom TLD with portless is still recommended for consistency across providers. ### Facebook (Meta) 1. Go to [Meta for Developers > App Dashboard](https://developers.facebook.com/apps/) 2. Under **Facebook Login > Settings**, add the portless URL to **Valid OAuth Redirect URIs**: `https://myapp.dev/api/auth/callback/facebook` Facebook requires each redirect URI to be registered exactly (no wildcards). Strict Mode (enabled by default) enforces exact matching. ### GitHub 1. Go to [GitHub Developer Settings > OAuth Apps](https://github.com/settings/developers) 2. Set **Authorization callback URL**: `https://myapp.dev/api/auth/callback/github` GitHub is permissive with localhost and subdomains. A custom TLD is not strictly required but keeps the setup consistent. ## Auth Library Configuration ### NextAuth / Auth.js Set `NEXTAUTH_URL` to match the portless domain: ```env NEXTAUTH_URL=https://myapp.dev ``` NextAuth uses this to construct callback URLs. Without it, callbacks may use `localhost` and cause a mismatch. ### Passport.js Set the `callbackURL` in each strategy to use the portless domain: ```js new GoogleStrategy({ clientID: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, callbackURL: process.env.BASE_URL + "/auth/google/callback", }); ``` Set `BASE_URL=https://myapp.dev` in your environment. ### Generic / Manual Read the `PORTLESS_URL` environment variable that portless injects into the child process: ```js const baseUrl = process.env.PORTLESS_URL || "http://localhost:3000"; const callbackUrl = `${baseUrl}/auth/callback`; ``` ## Troubleshooting ### "redirect_uri_mismatch" or "invalid redirect URI" The redirect URI sent during the OAuth flow doesn't match what's registered with the provider. Check: 1. The provider's registered redirect URI matches the portless domain exactly (protocol, host, path) 2. `NEXTAUTH_URL` or equivalent is set to the portless URL (not `localhost`) 3. The proxy is running with the correct TLD (`portless list` to verify) ### Provider requires HTTPS `.dev` and `.app` TLDs are HSTS-preloaded, so browsers force HTTPS. Start the proxy: ```bash portless proxy start --tld dev ``` Portless defaults to HTTPS on port 443 (auto-elevates with sudo). Run `portless trust` to add the local CA to your system trust store and eliminate browser warnings. ### Apple rejects the domain Apple may require the domain to resolve publicly. Add a DNS A record for your dev subdomain pointing to `127.0.0.1`: ``` myapp.local.yourcompany.dev A 127.0.0.1 ``` Or use a wildcard: `*.local.yourcompany.dev A 127.0.0.1`. ### Callback goes to wrong URL after sign-in The auth library is constructing the callback URL from `localhost` instead of the portless domain. Set the appropriate environment variable: - **NextAuth**: `NEXTAUTH_URL=https://myapp.dev` - **Auth.js v5**: `AUTH_URL=https://myapp.dev` - **Manual**: `PORTLESS_URL` is injected automatically; use it as the base URL ## Example See [`examples/google-oauth`](https://github.com/vercel-labs/portless/tree/main/examples/google-oauth) for a complete working example with Next.js + NextAuth + Google OAuth using `--tld dev`. -
upstream-portless.md 22.1 KB
# Upstream portless SKILL.md (verbatim) **Source:** https://github.com/vercel-labs/portless/blob/main/skills/portless/SKILL.md **Fetched:** 2026-05-12 **License:** Apache-2.0 **Note:** Verbatim copy of the upstream skill. Refresh on portless version bumps. Our `SKILL.md` (parent dir) adds operational patterns; this file is the canonical CLI reference. --- --- name: portless description: Set up and use portless for named local dev server URLs (e.g. https://myapp.localhost instead of http://localhost:3000). Use when integrating portless into a project, configuring dev server names, setting up the local proxy, working with .localhost domains, or troubleshooting port/proxy issues. --- # Portless Replace port numbers with stable, named .localhost URLs. For humans and agents. ## Why portless - **Port conflicts**: `EADDRINUSE` when two projects default to the same port - **Memorizing ports**: which app is on 3001 vs 8080? - **Refreshing shows the wrong app**: stop one server, start another on the same port, stale tab shows wrong content - **Monorepo multiplier**: every problem scales with each service in the repo - **Agents test the wrong port**: AI agents guess or hardcode the wrong port - **Cookie/storage clashes**: cookies on `localhost` bleed across apps; localStorage lost when ports shift - **Hardcoded ports in config**: CORS allowlists, OAuth redirects, `.env` files break when ports change - **Sharing URLs with teammates**: "what port is that on?" becomes a Slack question - **Browser history is useless**: `localhost:3000` history is a mix of unrelated projects ## Installation Install globally (recommended) or as a project dev dependency. Do NOT use `npx` or `pnpm dlx` for one-off execution. ```bash # Global (available everywhere) npm install -g portless # Or per-project dev dependency npm install -D portless ``` When installed per-project, invoke via package.json scripts or `npx portless` (since the package is local, npx will not download anything). ## Quick Start ```bash # Install globally (or add -D to a project) npm install -g portless # Run your app (auto-starts the HTTPS proxy on port 443) portless run next dev # -> https://<project>.localhost # Or with an explicit name portless myapp next dev # -> https://myapp.localhost ``` The proxy auto-starts when you run an app. You can also start it explicitly with `portless proxy start`. Auto-start reuses the configuration (port, TLS, TLD) from the most recent proxy run, so a restart or reboot does not silently revert to defaults. Explicit env vars always take priority. In non-interactive environments (no TTY, or `CI=1`), portless exits with a descriptive error instead of prompting. Task runners like turborepo should pre-start the proxy. ## Integration Patterns ### Zero-config (recommended) Bare `portless` works out of the box. It runs the `"dev"` script from `package.json` through the proxy, inferring the app name from the package name, git root, or directory: ```bash portless # -> runs "dev" script, https://<project>.localhost pnpm dev # -> works without portless, plain "next dev" ``` Use an optional `portless.json` to override defaults (name, script, port): ```json { "name": "myapp" } ``` ```bash portless # -> runs "dev" script, https://myapp.localhost ``` ### Monorepo One `portless.json` at the repo root. Portless discovers packages from `pnpm-workspace.yaml`, or the `"workspaces"` field in `package.json` (npm, yarn, bun): ```json { "apps": { "apps/web": { "name": "myapp" }, "apps/api": { "name": "api.myapp" } } } ``` ```bash portless # from repo root: start all packages with a "dev" script cd apps/web && portless # start just one package portless --script start # run "start" instead of "dev" ``` The `apps` map is optional and only provides name overrides. Unlisted packages auto-discover with inferred names. Without an `apps` map, hostnames follow `<package>.<project>.localhost`. The project name comes from the most common npm scope (e.g. `@myorg/web` and `@myorg/api` produce `myorg`), falling back to the workspace root directory name. If a package's short name matches the project name, it uses the bare `<project>.localhost`. ### Turborepo For turborepo projects, use portless as the `dev` script with the real command in a separate script: ```json { "scripts": { "dev": "portless", "dev:app": "next dev" }, "portless": { "name": "myapp", "script": "dev:app" } } ``` `pnpm dev` runs turbo, which runs `portless` in each package. Portless detects the package manager and runs `pnpm run dev:app` through the proxy. ### package.json scripts You can still use portless directly in scripts: ```json { "scripts": { "dev": "portless run next dev" } } ``` The proxy auto-starts when you run an app. Or start it explicitly: `portless proxy start`. ### Multi-app setups with subdomains ```bash portless myapp next dev # https://myapp.localhost portless api.myapp pnpm start # https://api.myapp.localhost portless docs.myapp next dev # https://docs.myapp.localhost ``` By default, only explicitly registered subdomains are routed (strict mode). Start the proxy with `--wildcard` to allow any subdomain of a registered route to fall back to that app (e.g. `tenant1.myapp.localhost` routes to the `myapp` app). Exact matches always take priority over wildcards. ### Git worktrees `portless run` automatically detects git worktrees. In a linked worktree, the branch name is prepended as a subdomain prefix so each worktree gets a unique URL: ```bash # Main worktree (no prefix) portless run next dev # -> https://myapp.localhost # Linked worktree on branch "fix-ui" portless run next dev # -> https://fix-ui.myapp.localhost ``` No config changes needed. Put `portless run` in `package.json` once and it works in all worktrees. ### Bypassing portless Set `PORTLESS=0` to run the command directly without the proxy: ```bash PORTLESS=0 pnpm dev # Bypasses proxy, uses default port ``` ## How It Works 1. `portless proxy start` starts an HTTPS reverse proxy on port 443 as a background daemon. Auto-elevates with sudo on macOS/Linux; falls back to port 1355 if sudo is unavailable. Use `--no-tls` for plain HTTP on port 80. Configurable with `-p` / `--port` or the `PORTLESS_PORT` env var. The proxy also auto-starts when you run an app. 2. `portless <name> <cmd>` assigns a random free port (4000-4999) via the `PORT` env var and registers the app with the proxy 3. The browser hits `https://<name>.localhost`; the proxy forwards to the app's assigned port `.localhost` domains resolve to `127.0.0.1` natively in Chrome, Firefox, and Edge. Safari relies on the system DNS resolver, which may not handle `.localhost` subdomains on all configurations. Run `portless hosts sync` to add entries to `/etc/hosts` if needed. Most frameworks (Next.js, Express, Nuxt, etc.) respect the `PORT` env var automatically. For frameworks that ignore `PORT` (Vite, VitePlus, Astro, React Router, Angular, Expo, React Native), portless auto-injects the correct `--port` flag and, when needed, a matching `--host` CLI flag. ### State directory Portless stores its state (routes, PID file, port file) in `~/.portless`. Override with the `PORTLESS_STATE_DIR` environment variable. ### Environment variables | Variable | Description | | --------------------- | --------------------------------------------------------------------------- | | `PORTLESS_PORT` | Override the default proxy port (default: 443 with HTTPS, 80 without) | | `PORTLESS_APP_PORT` | Use a fixed port for the app (skip auto-assignment) | | `PORTLESS_HTTPS` | HTTPS on by default; set to `0` to disable (same as `--no-tls`) | | `PORTLESS_LAN` | Set to `1` to always enable LAN mode (auto-detects LAN IP) | | `PORTLESS_TLD` | Use a custom TLD instead of localhost (e.g. test) | | `PORTLESS_WILDCARD` | Set to `1` to allow unregistered subdomains to fall back to parent | | `PORTLESS_SYNC_HOSTS` | Set to `0` to disable auto-sync of /etc/hosts (on by default) | | `PORTLESS_TAILSCALE` | Set to `1` to share apps on your Tailscale network (same as `--tailscale`) | | `PORTLESS_FUNNEL` | Set to `1` to share apps publicly via Tailscale Funnel (same as `--funnel`) | | `PORTLESS_STATE_DIR` | Override the state directory | | `PORTLESS=0` | Bypass the proxy, run the command directly | ### HTTP/2 + HTTPS HTTPS with HTTP/2 is enabled by default (faster page loads for dev servers with many files). First run generates a local CA and adds it to the system trust store. After that, no prompts and no browser warnings. ```bash portless proxy start --cert ./c.pem --key ./k.pem # Use custom certs portless proxy start --no-tls # Disable HTTPS (plain HTTP) portless trust # Add CA to trust store later ``` 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. ### LAN mode ```bash portless proxy start --lan portless proxy start --lan --https portless proxy start --lan --ip 192.168.1.42 ``` `--lan` advertises `<name>.local` hostnames over mDNS so any device on the same Wi-Fi can reach your apps. Portless auto-detects your LAN IP and follows network changes automatically, but you can pin a specific address with `--ip <address>` or the `PORTLESS_LAN_IP` environment variable. Set `PORTLESS_LAN=1` to default to LAN mode every time the proxy starts. Portless remembers LAN mode via `proxy.lan`, so if you stop a LAN proxy and start again, it stays in LAN mode. All proxy settings (port, TLS, TLD, LAN) are persisted and reused on auto-start unless overridden by explicit flags or env vars. Use `PORTLESS_LAN=0` for one start to switch back to `.localhost` mode. If a proxy is already running with different explicit LAN/TLS/TLD settings, portless warns and asks you to stop it first. LAN mode depends on the system mDNS helpers that portless launches: macOS includes `dns-sd`, while Linux uses `avahi-publish-address` from `avahi-utils` (install via `sudo apt install avahi-utils` or your distro's tooling). - **Next.js**: add your `.local` hostnames to `allowedDevOrigins`: ```js // next.config.js module.exports = { allowedDevOrigins: ["myapp.local", "*.myapp.local"], }; ``` - **Expo / React Native**: portless always injects `--port`. React Native also gets `--host 127.0.0.1`. Expo gets `--host localhost` outside LAN mode, but in LAN mode portless leaves Metro on its default LAN host behavior instead of forcing `--host` or `HOST`. ### Tailscale sharing Share dev servers with teammates on your Tailscale network using `--tailscale`, or expose to the public internet with `--funnel`: ```bash portless myapp --tailscale next dev # -> https://myapp.localhost (local) # -> https://devbox.yourteam.ts.net (tailnet) portless myapp --funnel next dev # -> https://myapp.localhost (local) # -> https://devbox.yourteam.ts.net (public internet) ``` Tailscale HTTPS certificates must be enabled before `--tailscale` or `--funnel` can register HTTPS URLs. Funnel must also be enabled for the tailnet and node before `--funnel` can register the public URL. If either setting is missing, portless exits before starting the child process. Each `--tailscale` app is root-mounted on its own Tailscale HTTPS port (443, then 8443, 8444, etc.) so no framework `basePath` configuration is needed. Set `PORTLESS_TAILSCALE=1` to share every app by default. `portless list` shows both local and tailnet URLs. Tailscale serve registrations are cleaned up when the app exits. Requires `tailscale` CLI installed and connected, with Tailscale HTTPS certificates enabled. ## OS startup service Use the service command when users want the proxy to start automatically after reboot: ```bash portless service install portless service status portless service uninstall ``` The service uses the default clean URL behavior: HTTPS on port 443 with `.localhost` names. macOS and Linux install a root-owned service so port 443 can bind at boot. Windows installs a Task Scheduler startup task that runs as SYSTEM. Installation and removal may require administrator privileges. `portless clean` automatically removes the service. ## CLI Reference | Command | Description | | -------------------------------------- | -------------------------------------------------------------- | | `portless` | Run dev script through proxy | | `portless` | From monorepo root: run all workspace packages | | `portless --script <name>` | Run a specific package.json script (default: dev) | | `portless run [cmd] [args...]` | Infer name from project, run through proxy (auto-starts) | | `portless run --name <name> <cmd>` | Override inferred base name (worktree prefix still applies) | | `portless <name> <cmd> [args...]` | Run app at `https://<name>.localhost` (auto-starts proxy) | | `portless get <name>` | Print URL for a service (for cross-service wiring) | | `portless get <name> --no-worktree` | Print URL without worktree prefix | | `portless list` | Show active routes | | `portless trust` | Add local CA to system trust store (for HTTPS) | | `portless clean` | Remove state, CA trust entry, and /etc/hosts block | | `portless prune` | Kill orphaned dev servers from crashed sessions | | `portless prune --force` | Kill orphans with SIGKILL instead of SIGTERM | | `portless proxy start` | Start HTTPS proxy as a daemon (port 443, auto-elevates) | | `portless proxy start --no-tls` | Start without HTTPS (plain HTTP on port 80) | | `portless proxy start --lan` | Start in LAN mode (mDNS `.local`, auto-follows LAN IP changes) | | `portless proxy start -p <number>` | Start the proxy on a custom port | | `portless proxy start --tld test` | Use .test instead of .localhost | | `portless proxy start --foreground` | Start the proxy in foreground (for debugging) | | `portless proxy start --wildcard` | Allow unregistered subdomains to fall back to parent route | | `portless proxy stop` | Stop the proxy | | `portless service install` | Start the HTTPS proxy when the OS starts | | `portless service status` | Show service and proxy status | | `portless service uninstall` | Remove the startup service | | `portless alias <name> <port>` | Register a static route (e.g. for Docker containers) | | `portless alias <name> <port> --force` | Overwrite an existing route | | `portless alias --remove <name>` | Remove a static route | | `portless hosts sync` | Add routes to /etc/hosts (fixes Safari) | | `portless hosts clean` | Remove portless entries from /etc/hosts | | `portless <name> --app-port <n> <cmd>` | Use a fixed port for the app instead of auto-assignment | | `portless <name> --tailscale <cmd>` | Share the app on your Tailscale network (tailnet) | | `portless <name> --funnel <cmd>` | Share the app publicly via Tailscale Funnel | | `portless <name> --force <cmd>` | Kill the existing process and take over its route | | `portless --name <name> <cmd>` | Force `<name>` as app name (bypasses subcommand dispatch) | | `portless <name> -- <cmd> [args...]` | Stop flag parsing; everything after `--` is passed to child | | `portless --help` / `-h` | Show help | | `portless run --help` | Show help for a subcommand (also: alias, hosts, clean) | | `portless --version` / `-v` | Show version | **Reserved names:** `run`, `get`, `alias`, `hosts`, `list`, `trust`, `clean`, `prune`, `proxy`, and `service` are subcommands and cannot be used as app names directly. Use `portless run <cmd>` to infer the name, or `portless --name <name> <cmd>` to force any name including reserved ones. ## portless.json Optional config file. Portless looks for it in the current directory. | Field | Type | Default | Description | | --------- | ------- | -------------------------- | -------------------------------------------------------- | | `name` | string | inferred from package.json | Base app name (worktree prefix still applies) | | `script` | string | `"dev"` | Name of a package.json script to run | | `appPort` | number | auto-assigned | Fixed port for the child process | | `proxy` | boolean | auto-detected | Whether to route through the proxy (`false` for tasks) | | `apps` | object | | Overrides for workspace packages, keyed by relative path | | `turbo` | boolean | `true` | Set `false` to use direct spawning instead of turborepo | Each `apps` entry has the same shape (`name`, `script`, `appPort`, `proxy`). When `apps` is present, top-level fields apply only in single-app mode. ### package.json "portless" key Instead of a separate `portless.json`, you can add a `"portless"` key to your `package.json`. A string value is shorthand for setting the name: ```json { "portless": "myapp" } ``` An object supports all per-app fields (`name`, `script`, `appPort`, `proxy`): ```json { "portless": { "name": "myapp", "script": "dev:app" } } ``` Precedence (closest wins): CLI flags > package.json `"portless"` key > portless.json app entry > defaults. ## Troubleshooting ### Proxy not running The proxy auto-starts when you run an app with `portless <name> <cmd>`. If it doesn't start (e.g. port conflict), start it manually: ```bash portless proxy start ``` ### Port already in use Another process is bound to the proxy port. Either stop it first, or use a different port: ```bash portless proxy start -p 8080 ``` ### Framework not respecting PORT Portless auto-injects the right `--port` flag and, when needed, a matching `--host` flag for frameworks that ignore the `PORT` env var: **Vite**, **VitePlus** (`vp`), **Astro**, **React Router**, **Angular**, **Expo**, and **React Native**. SvelteKit uses Vite internally and is handled automatically. For other frameworks that don't read `PORT`, pass the port manually: - **Webpack Dev Server**: use `--port $PORT` - **Custom servers**: read `process.env.PORT` and listen on it ### Permission errors The default ports (80 for HTTP, 443 for HTTPS) require `sudo` on macOS and Linux. Portless auto-elevates with sudo when needed. If sudo is unavailable, it falls back to port 1355 (no sudo needed). On Windows, no elevation is required. ```bash portless proxy start --https # Auto-elevates with sudo for port 443 portless proxy start -p 1355 --https # No sudo needed (URLs include :1355) portless proxy stop # Stop (use sudo if started with sudo) ``` ### Safari can't find .localhost URLs Safari relies on the system DNS resolver for `.localhost` subdomains, which may not resolve them on all macOS configurations. Chrome, Firefox, and Edge have built-in handling. Fix: ```bash portless hosts sync # Adds current routes to /etc/hosts portless hosts clean # Remove entries later ``` Auto-syncs `/etc/hosts` for route hostnames by default. Set `PORTLESS_SYNC_HOSTS=0` to disable. ### Browser shows certificate warning with --https The local CA may not be trusted yet. Run: ```bash portless trust ``` This adds the portless local CA to your system trust store. After that, restart the browser. ### Remove portless from the machine ```bash portless clean ``` Stops the proxy if needed, removes the portless CA from the trust store (when portless added it), deletes known files under state directories, and removes the portless `/etc/hosts` block. May require `sudo` on macOS/Linux. ### Proxy loop (508 Loop Detected) If your dev server proxies requests to another portless app (e.g. Vite proxying `/api` to `api.myapp.localhost`), the proxy must rewrite the `Host` header. Without this, portless routes the request back to the original app, creating an infinite loop. Fix: set `changeOrigin: true` in the proxy config (Vite, webpack-dev-server, etc.): ```ts // vite.config.ts proxy: { "/api": { target: "https://api.myapp.localhost", changeOrigin: true, ws: true, }, } ``` Portless automatically sets `NODE_EXTRA_CA_CERTS` in child processes so Node.js trusts the portless CA. If you run a separate Node.js process outside portless, point it at the CA manually: `NODE_EXTRA_CA_CERTS=~/.portless/ca.pem`. Alternatively, use `--no-tls` for plain HTTP. ### Tailscale not working If `--tailscale` or `--funnel` fails: ```bash tailscale status # Check if connected tailscale up # Connect to your tailnet ``` Requires the Tailscale CLI to be installed (https://tailscale.com/download) and on PATH. ### Requirements - Node.js 20+ - macOS, Linux, or Windows - `openssl` (for `--https` cert generation; ships with macOS and most Linux distributions; on Windows, install via `winget install -e --id ShiningLight.OpenSSL.Dev` or use the copy bundled with Git for Windows) - `tailscale` CLI (optional, for `--tailscale` and `--funnel`) -
windows-specifics.md 5.1 KB
# Windows Specifics for portless Things that bite on Windows but work transparently on macOS/Linux. ## OpenSSL Required for Cert Generation Portless uses OpenSSL to generate the local CA on first run. Without it: ``` Error: openssl failed: spawnSync openssl ENOENT ``` ### Fix — Add Git for Windows's bundled OpenSSL to PATH Git for Windows ships a usable OpenSSL at `C:\Program Files\Git\usr\bin\openssl.exe`. Add it to your user PATH permanently: ```powershell $gitBin = "C:\Program Files\Git\usr\bin" $current = [Environment]::GetEnvironmentVariable("PATH", "User") if ($current -notlike "*$gitBin*") { [Environment]::SetEnvironmentVariable("PATH", "$gitBin;$current", "User") } # Verify openssl version ``` For Task Scheduler / boot-time launches, the PATH must be set in the wrapper script (Task Scheduler runs with minimal PATH by default). ### Alternative — install standalone OpenSSL ```powershell winget install -e --id ShiningLight.OpenSSL.Light # or scoop install openssl ``` ## CA Trust via certutil `portless trust` calls Windows's `certutil.exe` to add the portless CA to the system trust store. Side effects: - **Affects browsers using the system store** (Chrome, Edge, Firefox-with-Windows-certs) — they will trust `*.<tld>` certs after `portless trust` - **Does NOT affect curl on Windows** — curl ships its own CA bundle and ignores the system store - **Does NOT affect Firefox by default** — Firefox uses its own NSS cert store unless you set `security.enterprise_roots.enabled=true` in `about:config` `portless trust` may prompt the UAC dialog; without admin elevation it may silently fail to add system-wide trust. Run from an elevated PowerShell for reliable installation. ## curl vs Browser Cert Handling Symptom: `curl https://myapp.test/` returns HTTP code 000, but the browser loads `https://myapp.test/` fine with a green padlock. Reason: curl uses its own CA bundle, browsers use the OS trust store. Three workarounds for curl on Windows: ```bash # 1. Skip verification (quickest, fine for local dev) curl -k https://myapp.test/ # 2. Point curl at portless's CA explicitly curl --cacert "$env:USERPROFILE/.portless/ca.pem" https://myapp.test/ # 3. Add the portless CA to curl's bundle (one-time setup) # Locate curl's CA bundle (varies by install): curl-config --ca # if curl-config is available # Or check $env:CURL_CA_BUNDLE or the bundle at the curl install dir # Then append portless's CA to it: type "$env:USERPROFILE\.portless\ca.pem" >> "C:\path\to\curl\bin\curl-ca-bundle.crt" ``` For most dev workflows just use `-k` — it's the fastest path. ## Boot Persistence — Task Scheduler `portless service install` registers a Task Scheduler entry that runs the proxy at system startup. Notes: - Runs as **SYSTEM** (not your user account) — fine because portless only needs to bind ports and read its own state dir - The state dir defaults to `%USERPROFILE%\.portless\` — Task Scheduler running as SYSTEM might not see it. Override with `PORTLESS_STATE_DIR` env if needed. - Uninstall: `portless service uninstall` or `portless clean` (also removes the task) For Process Compose's boot task, see `process-compose-ops` skill's `boot-persistence-windows.md`. ## /etc/hosts on Windows Windows uses `C:\Windows\System32\drivers\etc\hosts`. `portless hosts sync` writes to it — requires admin elevation. If portless isn't auto-syncing: ```powershell # Check what's in hosts notepad C:\Windows\System32\drivers\etc\hosts # Force a re-sync (run as admin) portless hosts sync ``` ## Port 443 Without sudo On macOS/Linux, binding port 443 requires `sudo` (portless auto-elevates). On Windows, no elevation is required to bind privileged ports for the current user — portless just binds them directly. Caveat: if **another service is already bound to 443** (IIS, Skype, Caddy from old setup), portless will fail to start with `EADDRINUSE`. Find the culprit: ```powershell netstat -ano | findstr ":443 " # Look at the PID, then: Get-Process -Id <pid> ``` Stop the conflicting service or pick a different port (`--port 1355`). ## PowerShell 5.1 vs 7+ PowerShell 5.1 (the default Windows PowerShell that ships with Windows) lacks some newer flags that PowerShell 7 has. Examples that bite: ```powershell # PS 7+: -SkipCertificateCheck Invoke-WebRequest -Uri https://x.lab -SkipCertificateCheck # PS 5.1: parameter not recognized → use curl.exe -k instead # PS 7+: ternary operator $x = $foo ? "yes" : "no" # PS 5.1: parse error → use if-else # PS 7+: pipeline parallel ... | ForEach-Object -Parallel { ... } # PS 5.1: -Parallel not available ``` If a script needs PS 7+ features, the shebang doesn't help on Windows — invoke explicitly with `pwsh` instead of `powershell`: ```powershell pwsh -File .\myscript.ps1 ``` ## Cleanup ```powershell # Stop proxy + uninstall boot task + clear state portless clean # Verify clean state Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue # Should return nothing if portless was the only thing on 443 ``` `portless clean` removes the portless CA from the trust store too, so browsers will see warnings again until you re-trust.
-
-
scripts
-
install-portless.ps1 5.6 KB · in bundle
-
reset-state.ps1 2.4 KB · in bundle
-
sync-aliases-from-yaml.ps1 1.6 KB · in bundle
-
-
tests
-
run.sh 6.8 KB
#!/usr/bin/env bash # Self-test for portless-ops — STATIC / STRUCTURAL ONLY. # # portless-ops ships PowerShell (.ps1) scripts that mutate real local state # (stop/restart a proxy, wipe routes.json, re-register aliases). On Linux CI you # cannot run pwsh reliably, and even on Windows you must not let a test suite # touch a developer's real .portless state. So this suite never executes the # scripts: it asserts their STATIC contract instead — # 1. each script carries a synopsis/usage (.SYNOPSIS + .EXAMPLE) block, # 2. the shipped portless.json asset templates parse as valid JSON, and # 3. reset-state.ps1 guards its destructive Remove-Item (Test-Path existence # check + the nuclear `portless clean` opt-in via -PreserveCa, which # DEFAULTS to $true = safe-by-default). # # NOTE (see final reply): reset-state.ps1 does not use -WhatIf / -Confirm / # ShouldProcess; its guard is the Test-Path existence check plus the safe-by- # default PreserveCa flag. That gap is surfaced below as INFO, not a failure — # it is documented rather than hidden. # # Usage: bash tests/run.sh # Exit: 0 all pass, 1 one or more failures set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL="$(dirname "$HERE")" SCRIPTS="$SKILL/scripts" ASSETS="$SKILL/assets" # JSON parser: prefer jq, fall back to a working python (skip if neither). JSON_TOOL="" if command -v jq >/dev/null 2>&1; then JSON_TOOL="jq" else for c in python python3; do if command -v "$c" >/dev/null 2>&1 && "$c" -c "" >/dev/null 2>&1; then JSON_TOOL="$c"; break; fi done fi json_ok() { # $1 = file -> 0 if parses case "$JSON_TOOL" in jq) jq empty "$1" >/dev/null 2>&1 ;; python|python3) "$JSON_TOOL" -c "import json,sys; json.load(open(sys.argv[1],encoding='utf-8'))" "$1" >/dev/null 2>&1 ;; *) return 2 ;; # no parser available esac } json_get() { # $1 = file, $2 = key -> prints value (jq: .key; python best-effort) case "$JSON_TOOL" in jq) jq -r "$2" "$1" 2>/dev/null ;; *) "$JSON_TOOL" -c "import json,sys; d=json.load(open(sys.argv[1],encoding='utf-8')); import builtins as b k=sys.argv[2].lstrip('.') print(d.get(k) if k in d else '')" "$1" "$2" 2>/dev/null ;; esac } PASS=0; FAIL=0 ok() { PASS=$((PASS+1)); printf ' PASS %s\n' "$1"; } no() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; } info() { printf ' INFO %s\n' "$1"; } echo "=== portless-ops self-test (static/structural) ===" # ── every script has a synopsis/usage block ────────────────────────────────── echo "-- synopsis/usage blocks --" for s in "$SCRIPTS"/*.ps1; do [[ -f "$s" ]] || continue b="$(basename "$s")" if grep -q '\.SYNOPSIS' "$s" && grep -q '\.EXAMPLE' "$s"; then ok "$b has .SYNOPSIS + .EXAMPLE" else no "$b missing .SYNOPSIS/.EXAMPLE" fi grep -q '\[CmdletBinding()\]' "$s" \ && ok "$b uses [CmdletBinding()] (param discipline)" \ || no "$b missing [CmdletBinding()]" done # ── reset-state.ps1: destructive ops are guarded ──────────────────────────── echo "-- reset-state.ps1 guards --" R="$SCRIPTS/reset-state.ps1" # 3a. Remove-Item is preceded by a Test-Path existence guard (no unconditional # wipe) — the real, present guard before the destructive call. if grep -q 'Test-Path' "$R" && grep -q 'Remove-Item' "$R"; then tp_line=$(grep -n 'Test-Path' "$R" | head -1 | cut -d: -f1) ri_line=$(grep -n 'Remove-Item' "$R" | head -1 | cut -d: -f1) if [[ "${tp_line:-0}" -gt 0 && "${ri_line:-0}" -gt "${tp_line:-0}" ]]; then ok "Remove-Item is guarded by a Test-Path check (line $tp_line < $ri_line)" else no "Remove-Item not preceded by Test-Path guard" fi else no "reset-state.ps1 missing Test-Path/Remove-Item" fi # 3b. The nuclear `portless clean` is opt-in and DEFAULTS to safe ($true). if grep -qE '\$PreserveCa\s*=\s*\$true' "$R"; then ok "nuclear 'portless clean' opt-in via PreserveCa (default \$true)" else no "PreserveCa does not default to \$true" fi # 3c. Transparently surface the absence of a strict per-op confirmation flag. if grep -qE -- '-WhatIf|-Confirm|ShouldProcess|ShouldContinue' "$R"; then ok "strict confirmation/dry-run guard (-WhatIf/-Confirm) present" else info "no -WhatIf/-Confirm/ShouldProcess — guard is Test-Path + PreserveCa default" fi # ── asset JSON templates parse ─────────────────────────────────────────────── echo "-- asset JSON parse --" if [[ -z "$JSON_TOOL" ]]; then info "no jq/python available — JSON parse skipped (still green)" else for a in "$ASSETS"/*.json; do [[ -f "$a" ]] || continue b="$(basename "$a")" if json_ok "$a"; then ok "asset parses: $b"; else no "asset parses: $b"; fi done # light semantic checks: each template is on-purpose (carries its key shape) [[ "$(json_get "$ASSETS/portless.json.simple.json" '.name')" == "myapp" ]] \ && ok "simple template has .name" || no "simple template .name" [[ -n "$(json_get "$ASSETS/portless.json.monorepo.json" '.apps')" ]] \ && ok "monorepo template has .apps" || no "monorepo template .apps" [[ -n "$(json_get "$ASSETS/portless.json.with-custom-tld.json" '._tld_choice')" ]] \ && ok "custom-tld template records _tld_choice" || no "custom-tld template _tld_choice" [[ -n "$(json_get "$ASSETS/package.json-portless-key.json" '.portless')" ]] \ && ok "package.json template has .portless key" || no "package.json template .portless" fi # ── install-portless.ps1: supply-chain audit posture (static) ──────────────── echo "-- install-portless.ps1 audit posture --" I="$SCRIPTS/install-portless.ps1" grep -qi 'SHA512\|SHA-512' "$I" && ok "verifies tarball SHA-512" || no "verifies tarball SHA-512" grep -qi 'IOC' "$I" && ok "scans package for IOC strings" || no "scans package for IOC strings" # integrity check must run BEFORE the install step ic_line=$(grep -ni 'integrity\|SHA-512\|SHA512' "$I" | head -1 | cut -d: -f1) in_line=$(grep -ni 'npm install -g' "$I" | head -1 | cut -d: -f1) if [[ "${ic_line:-0}" -gt 0 && "${in_line:-0}" -gt "${ic_line:-0}" ]]; then ok "integrity check precedes npm install" else no "integrity check must precede npm install" fi # ── sync-aliases-from-yaml.ps1: declares its yq dependency ─────────────────── echo "-- sync-aliases-from-yaml.ps1 --" S="$SCRIPTS/sync-aliases-from-yaml.ps1" grep -q 'yq' "$S" && ok "declares yq dependency" || no "declares yq dependency" grep -q -- '--force' "$S" && ok "registers aliases idempotently (--force)" || no "idempotent --force" echo "" echo "=== $PASS passed, $FAIL failed ===" [[ "$FAIL" -eq 0 ]] || exit 1 exit 0
-
-
SKILL.md 10.3 KB
--- name: portless-ops description: "Portless local-dev HTTPS proxy: replaces port numbers with named URLs (Caddy/nginx alternative for local dev). Triggers on: portless, local https proxy, named localhost URL, custom TLD, portless alias, portless.json, local CA trust, boot persistence, monorepo routing, Tailscale dev sharing." license: MIT allowed-tools: "Read Write Bash Edit" metadata: author: claude-mods related-skills: process-compose-ops, mcp-ops, cli-ops upstream: https://github.com/vercel-labs/portless --- # Portless Operations Portless (Vercel Labs) is a local-dev HTTPS proxy that replaces port numbers with named URLs. Replacement for Caddy/nginx in the local-dev role; not for production. **Upstream:** [vercel-labs/portless](https://github.com/vercel-labs/portless) (Apache-2.0). The portless repo ships canonical skills in its source tree (not in the npm package). Verbatim copies kept in `references/`: - **[`references/upstream-portless.md`](references/upstream-portless.md)** — full CLI reference, integration patterns (zero-config, monorepo, turborepo, worktrees, Tailscale), HTTPS/LAN setup, troubleshooting - **[`references/upstream-oauth.md`](references/upstream-oauth.md)** — OAuth provider compatibility (Google, Apple, Microsoft, Facebook, GitHub), TLD selection for OAuth, callback URI configuration This SKILL.md adds **operational patterns** we've validated in production (Windows specifics, the static-alias-with-supervisor pattern, TLD-reset procedure, supply-chain hygiene). For canonical CLI usage, prefer the upstream reference files. ## Mental Model | Layer | Portless owns | Portless does NOT own | |---|---|---| | Routing | hostname → port mapping, HTTPS termination, HTTP/2, CA trust | process supervision (use Process Compose or PM2) | | Naming | `<name>.<tld>` shape — one TLD per proxy | per-service distinct TLDs (not supported) | | Process spawning | when invoked as `portless myapp <cmd>` | crash recovery, restart policy, health checks | **Key shape constraint:** portless always renders `<alias-name>.<tld>`. You can't mix two TLDs in one proxy because TLD is per-instance — a dotted alias like `portless alias api.<app> 8108` gets the TLD appended → `api.<app>.<tld>`. ## Install ```bash # Pin a specific version (zero runtime deps, low supply-chain surface) npm install -g portless@0.13.0 # Verify portless --version ``` Record the pinned version in your repo. Upgrades are explicit PRs. ## CLI Quick Reference ```bash # example values — substitute your own (TLD, app name, ports) # Proxy lifecycle portless proxy start --tld lab --port 443 # HTTPS proxy on 443, *.lab routes portless proxy start --tld test --port 1355 # Non-privileged port for testing portless proxy stop portless trust # Add CA to system trust store # Aliases (for services portless didn't spawn — PM2, Process Compose, Docker, etc.) portless alias axiom 8108 # https://axiom.lab → :8108 portless alias axiom 8108 --force # Overwrite existing portless alias --remove axiom # Note: appends TLD! be careful # Spawn-mode (portless manages the process) portless myapp next dev # https://myapp.lab, auto port 4000-4999 portless run pnpm dev # Auto-infer name from package.json # Discovery (agent-friendly) portless list # Active routes portless get axiom # Returns: https://axiom.lab # Boot persistence portless service install # OS-native startup task portless service status portless service uninstall ``` ## The Static-Alias Pattern (portless + external process supervisor) The common pattern: a process supervisor (Process Compose, PM2, Docker) runs your dev servers on fixed ports. Portless just routes named URLs to those ports. ```bash # Started by Process Compose, listening on <your-port> # Now make it reachable at https://<your-app>.<your-tld> portless alias <your-app> <your-port> ``` Decoupling means: - Restart the dev server (`pm2 restart <your-app>`, `process-compose process restart <your-app>`) → portless keeps routing transparently - Swap one supervisor for another → portless layer is untouched **Source of truth pattern:** keep alias registration in your supervisor config. Example `scripts/install.ps1`: ```powershell $services = (yq '.processes | keys | .[]' process-compose.yaml) foreach ($svc in $services) { $port = (yq ".processes.$svc.readiness_probe.http_get.port" process-compose.yaml) if ($port -and $port -ne "null") { portless alias $svc $port --force } } ``` ## TLD Selection | TLD | When to use | Caveats | |---|---|---| | `.localhost` (default) | Quickest start | Auto-resolves to 127.0.0.1 on most systems | | `.lab` | Personal/distinctive | Not IANA-reserved (no DNS collision in practice for local) | | `.test` | OAuth-friendly | IANA-reserved; safe | | `.dev` | OAuth (Google, Apple) | Google-owned, forces HTTPS — portless handles this fine | | `.local` | Avoid | mDNS/Bonjour conflict | OAuth providers reject `.localhost` subdomains (not in Public Suffix List). Switch to `--tld test` or `--tld dev` for OAuth dev work. See [`references/upstream-oauth.md`](references/upstream-oauth.md) for full per-provider setup. ## Reset (clean slate) ```bash # Stop proxy portless proxy stop # Wipe all aliases (routes.json) rm ~/.portless/routes.json # Linux/macOS Remove-Item "$env:USERPROFILE\.portless\routes.json" # PowerShell # Start fresh with desired TLD portless proxy start --tld <tld> --port 443 # Re-register aliases from your supervisor config ``` This is the right pattern when you change TLD — `portless alias --remove` appends the active TLD which makes it fight you. ## Windows-Specific Notes ### `openssl` required on PATH Portless uses OpenSSL to generate the local CA. Git for Windows ships it: ```powershell # Persistent: add to user PATH $gitBin = "C:\Program Files\Git\usr\bin" $current = [Environment]::GetEnvironmentVariable("PATH", "User") if ($current -notlike "*$gitBin*") { [Environment]::SetEnvironmentVariable("PATH", "$gitBin;$current", "User") } ``` Without it: `Error: openssl failed: spawnSync openssl ENOENT` ### Boot persistence `portless service install` registers a Task Scheduler entry. Pair it with your supervisor's own boot task (e.g., for Process Compose, register a separate task via `scripts/boot-task-install.ps1`). Verify both registered: ```powershell Get-ScheduledTask | Where-Object { $_.TaskName -like "*ortless*" -or $_.TaskName -like "*ompose*" } ``` ### curl vs browser cert handling curl on Windows uses its own bundled CA store, not the system one. So `curl https://<your-app>.<your-tld>/` returns code 000 (cert untrusted) even after `portless trust`. Browsers work fine because they use the system store. Test from curl with `-k` (skip verify), or `--cacert ~/.portless/ca.pem`: ```bash curl -k https://<your-app>.<your-tld>/ # quick test curl --cacert ~/.portless/ca.pem https://<your-app>.<your-tld>/ # proper ``` ## Common Errors | Error | Cause | Fix | |---|---|---| | `openssl failed: spawnSync openssl ENOENT` | OpenSSL not on PATH | Add Git's `usr/bin` to PATH | | `Error: No alias found for "foo.lab"` (you asked for `foo`) | `--remove` appends TLD; sometimes adds an extra | Wipe `routes.json` and re-register | | Browser shows cert warning | CA not in system trust store | Re-run `portless trust` (may need admin) | | `https://name.lab` shows "No app registered" | Alias not set or proxy stopped | `portless list` to confirm; re-register if needed | | Safari can't resolve `*.lab` | Safari uses system DNS, not Node's resolver | `portless hosts sync` to write /etc/hosts | | Port 443 conflict on `portless proxy start` | Another service bound (Caddy, IIS) | Stop the other service, or use `--port 1355` for testing | ## Worked Example: Replacing Caddy with portless A PM2+Caddy to Process Compose+portless migration is worth keeping in its own small repo (e.g. `~/infra/local-stack/`), with these key files: - `process-compose.yaml` — supervisor config with health-checked services - `scripts/cutover.ps1` — stops PM2/Caddy, starts portless+PC, registers aliases - `docs/MIGRATION-LOG.md` — every issue hit during cutover and how it was solved - `docs/SUPPLY-CHAIN.md` — pinning + verification procedures ## Anti-Patterns ``` BAD: portless alias name 8000; portless alias name 8001 # second silently fails without --force GOOD: portless alias name 8001 --force BAD: use portless as production reverse proxy GOOD: keep portless as dev-only; production = nginx/Caddy/cloud LB BAD: rely on portless for crash recovery (it has none for spawned processes) GOOD: pair portless with Process Compose / PM2 / supervisord for supervision BAD: change TLD by stopping/starting with different --tld and hoping aliases update GOOD: stop proxy, wipe routes.json, start with new TLD, re-register from supervisor config ``` ## Resources in this skill ### `references/` - `upstream-portless.md` — canonical portless SKILL.md verbatim (CLI ref, monorepo, turborepo, worktrees, LAN, Tailscale, HTTPS, troubleshooting) - `upstream-oauth.md` — canonical OAuth setup for Google/Apple/Microsoft/Facebook/GitHub - `tld-selection.md` — decision tree for picking the right TLD; trade-offs of `.test`/`.dev`/`.localhost`/custom-owned - `windows-specifics.md` — openssl PATH, certutil quirks, curl-vs-browser cert handling, PS 5.1 gotchas - `integration-patterns.md` — combos with Process Compose / Docker / PM2 / Tailscale / git worktrees ### `scripts/` - `install-portless.ps1` — verified install: inspect tarball, scan for IOCs from recent attacks, install only if clean - `reset-state.ps1` — clean state reset (used when changing TLD; `--remove` can't clear old-TLD aliases) - `sync-aliases-from-yaml.ps1` — derive portless aliases from a process-compose.yaml ### `assets/` - `portless.json.simple.json` — single-app config template - `portless.json.monorepo.json` — workspace monorepo with name overrides - `portless.json.with-custom-tld.json` — documents TLD choice in repo - `package.json-portless-key.json` — alternative: portless config inside package.json ## Related Skills - `process-compose-ops` — the supervisor we pair with portless - `mcp-ops` — agent-friendly tooling; portless `get <name>` provides URL discovery for agents - `cli-ops` — general CLI tool patterns
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.