Claude Skill

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

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 0xdarkmatter-claude-mods-skills_portless-ops-3dfaf0b.zip · 31 KB
Part of 0xdarkmatter/claude-mods — 94 skills

Install

skills CLI npx skills add https://github.com/0xDarkMatter/claude-mods/tree/main/skills/portless-ops
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 0xdarkmatter-claude-mods@llmmart
Git 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, troubleshooting
  • 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

# 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 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
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.

No comments yet.

Reviews (0)

No reviews yet.

Related