rb-portless 0.1.0 → 0.3.0.dev.20260630.8a76e8f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fe5a5a76f71f89027e083e3ba964520c22aef5f1a42ecefc3850e16a0ac16bb2
4
- data.tar.gz: 13081e8f73114575b5a96ab27f9dec30c4949f333fa3c95ba0659f104cf16b19
3
+ metadata.gz: dda2037a7c7e36ac84158c7c47c2a49de2006452255f552d87547e513a17f8f9
4
+ data.tar.gz: cf0b838ff151ed00b72ec48579719de79b0e6f37e46368cd3358595dff9482c2
5
5
  SHA512:
6
- metadata.gz: 06a5c203d83b18de91ca2248744854e71933cdb4771626d8ae7af4e9bdbc2840c98edcfcc9cc51a9b88f9b6d6d9431c733118910bfcb380c372b9ad2151916e0
7
- data.tar.gz: 44412d9c53a6c2990af830497f55bfd30cc5a3f4e9cd2032a9b5347cfc6853931e0754191ecb634607cfbaffc448e3ba4b333f259ae037d759ff6b15f31ccd3c
6
+ metadata.gz: 382f71a9be4a4a9b7b71b36e4d23d98d69831e3f17174c534cab283371357cdcb7586ee55435b5c723a6a74b6382a7a79a68fe7e56b28a26a5f2e64d5ffba8e2
7
+ data.tar.gz: 425b0f7286a54d778b0cfedae39e74fb4bc07415f1c93ba1243153fa4ecf55614fd4e5d09e22cd99419185db7c70f034036554df871655cf611608e2e8e236d1
data/AGENTS.md CHANGED
@@ -46,6 +46,10 @@ injected as `PORT`; the proxy routes the named host to it.
46
46
  | `daemon.rb` | proxy start/stop, sudo bind, 1355 fallback | cli.ts handleProxy |
47
47
  | `service.rb` | launchd / systemd boot service | service.ts |
48
48
  | `frameworks.rb` | --port/--host injection (vite/astro/…) | cli-utils.ts |
49
+ | `banner.rb` | the "where's my app" startup banner | cli.ts (run output) |
50
+ | `multi.rb` | monorepo: run many apps under one proxy | workspace.ts/turbo.ts |
51
+ | `lan_ip.rb` / `mdns.rb` | LAN IP detect + `<name>.local` mDNS publish | lan-ip.ts/mdns.ts |
52
+ | `share/ngrok.rb` / `share/tailscale.rb` | public/tailnet sharing | ngrok.ts/tailscale.ts |
49
53
  | `runner.rb` | run cmd: port→env→spawn→register→supervise | cli.ts runApp |
50
54
  | `rails.rb` | opt-in railtie (whitelist *.localhost in dev) | — |
51
55
  | `cli.rb` | command dispatch | cli.ts main |
@@ -62,13 +66,15 @@ injected as `PORT`; the proxy routes the named host to it.
62
66
  - **Phase 3 ✅ (partial)** framework `--port`/`--host` injection, Linux CA trust,
63
67
  optional `portless/rails` railtie.
64
68
 
69
+ - **Phase 4 ✅** startup banner; monorepo multi-app (`apps` map); LAN mode
70
+ (`--lan`: LanIp + mDNS `<name>.local`); public sharing (`--ngrok`/`--tailscale`/
71
+ `--funnel`, experimental).
72
+
65
73
  ### Roadmap (not yet built)
66
74
 
67
- - LAN mode (mDNS `.local` publishing) for phones/tablets.
68
- - Public sharing via `tailscale serve|funnel` and `ngrok`.
69
- - Monorepo multi-app (one proxy, many named apps).
70
75
  - Windows CA trust + Task Scheduler service.
71
76
  - WebSocket upgrade relay hardening + HTTP/2 to the backend.
77
+ - mDNS LAN-IP change monitoring (re-publish on network switch).
72
78
 
73
79
  ## Conventions
74
80
 
data/CHANGELOG.md CHANGED
@@ -4,6 +4,34 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.3.0]
8
+
9
+ ### Added
10
+
11
+ - **Startup banner.** Running a dev server through rb-portless now prints a clear
12
+ banner with the named URL(s) it's reachable at — not just `127.0.0.1:port`.
13
+ - **Monorepo / multi-app.** A `portless.json` `apps` map runs several apps under
14
+ one proxy, each at its own `<name>.<tld>` (`rb-portless run` with no command).
15
+ - **LAN mode (`--lan`).** Reach the app from phones/tablets on the same Wi-Fi:
16
+ detects the LAN IP, registers `<name>.local`, and publishes it over mDNS
17
+ (`dns-sd`/`avahi-publish`). `--ip` overrides the detected address.
18
+ - **Public sharing (experimental).** `--ngrok`, `--tailscale`, `--funnel` expose
19
+ the app via ngrok / your tailnet (their CLIs, installed separately). When a
20
+ tool is missing or unconfigured, print an **actionable** message (install link,
21
+ `ngrok config add-authtoken`, "enable HTTPS in your tailnet DNS settings",
22
+ "run `tailscale up`") rather than failing silently — mirroring portless. Tailscale is **non-destructive**: it reads
23
+ `tailscale serve status`, picks a free HTTPS port (never clobbering your
24
+ existing serve/funnel config), registers with `--yes`, and removes only the
25
+ port it created on exit — mirroring portless's port-conflict handling.
26
+
27
+ ## [0.2.0]
28
+
29
+ ### Added
30
+
31
+ - **Auto-trust on first run.** `run` now trusts the local CA automatically the
32
+ first time (interactive only; skipped with a hint in CI), matching portless —
33
+ HTTPS works with no browser warnings without a separate `trust` step.
34
+
7
35
  ## [0.1.0] — first release
8
36
 
9
37
  The full portless workflow for Ruby, validated end-to-end against a real Rails
data/README.md CHANGED
@@ -41,12 +41,12 @@ rb-portless run -- npm run dev # Vite/Astro/etc. get --port injected
41
41
 
42
42
  A random port (4000–4999) is injected as `PORT` (and `HOST=127.0.0.1`);
43
43
  Rails/puma respect it natively. The proxy **auto-starts** on first run: it
44
- generates a local CA, and binds 443 — a one-time `sudo` on macOS/Linux, exactly
45
- like portless (falls back to `:1355` if you decline). Run `rb-portless trust`
46
- once so your browser accepts the certificates.
44
+ generates a local CA, **trusts it** (one keychain/sudo prompt, like portless),
45
+ and binds 443 — another one-time `sudo` on macOS/Linux (falls back to `:1355` if
46
+ you decline). After that, HTTPS just works with no browser warnings.
47
47
 
48
48
  ```bash
49
- rb-portless trust # trust the local CA (HTTPS, no warnings)
49
+ rb-portless trust # re-trust manually if ever needed
50
50
  rb-portless service install # bind 443 at boot — never prompt for sudo again
51
51
  ```
52
52
 
@@ -66,6 +66,47 @@ rb-portless service install # bind 443 at boot — never prompt for sudo
66
66
  With a custom `tld`, every `*.shirabe.org.localhost` subdomain wildcard-routes to
67
67
  the one app — ideal for subdomain-per-tenant apps.
68
68
 
69
+ ## Multiple apps, LAN & sharing
70
+
71
+ **Monorepo / multi-app** — define an `apps` map and `rb-portless run` (no command)
72
+ starts them all, each at its own name:
73
+
74
+ ```jsonc
75
+ // portless.json
76
+ { "apps": { "web": "bin/rails server", "api": "node api/server.js" } }
77
+ // → https://web.localhost, https://api.localhost
78
+ ```
79
+
80
+ **LAN mode** — reach the app from your phone on the same Wi-Fi. It detects the
81
+ LAN IP, registers `<name>.local`, and publishes it over mDNS:
82
+
83
+ ```bash
84
+ rb-portless run --lan bin/dev # also → https://<name>.local
85
+ rb-portless run --lan --ip 10.0.0.5 bin/dev # override the detected IP
86
+ ```
87
+ > Devices won't trust your local CA without installing it — use `--lan` with
88
+ > `--no-tls` (set `"tls": false`) for plain HTTP, or install `~/.rb-portless/ca.pem`
89
+ > on the device.
90
+
91
+ **Public sharing** (experimental) — expose the app via ngrok or your tailnet:
92
+
93
+ ```bash
94
+ rb-portless run --ngrok bin/dev # https://xxxx.ngrok.app
95
+ rb-portless run --tailscale bin/dev # your-machine.tailnet.ts.net
96
+ rb-portless run --funnel bin/dev # tailscale Funnel (public)
97
+ ```
98
+
99
+ These use the tools' own CLIs — **install them separately** (we don't bundle
100
+ anything, same as portless), and each degrades gracefully if the tool is absent:
101
+ - **ngrok** — install the `ngrok` CLI and configure an authtoken (free account).
102
+ - **tailscale** — install `tailscale`, be logged into a tailnet, and enable HTTPS
103
+ certificates (and Funnel, for `--funnel`) in your tailnet settings.
104
+
105
+ > **Your tailnet config is safe.** rb-portless reads `tailscale serve status`,
106
+ > picks a **free** HTTPS port (never one you're already serving on), registers
107
+ > with `--yes`, and on exit turns off **only the port it created** — it never
108
+ > touches your existing `serve`/`funnel` setup.
109
+
69
110
  ## Commands
70
111
 
71
112
  | Command | Does |
@@ -104,8 +145,7 @@ end
104
145
  ```
105
146
 
106
147
  ```bash
107
- rb-portless trust # one-time: trust the local CA
108
- rb-portless run bin/dev # → https://myapp.localhost
148
+ rb-portless run bin/dev # → https://myapp.localhost (CA auto-trusted on first run)
109
149
  ```
110
150
 
111
151
  That's it. The railtie **auto-detects when you're running under `rb-portless`**
@@ -164,6 +204,29 @@ boundary). For ports < 1024 it re-execs under `sudo` so the elevated process can
164
204
  bind the socket, then hands ownership of state files back to you. See
165
205
  [`AGENTS.md`](AGENTS.md) for the full architecture.
166
206
 
207
+ ## Compared to portless (Node)
208
+
209
+ The mental model is identical — `run` wraps your dev command, the proxy
210
+ auto-starts, named `.localhost` URLs replace ports. The only Ruby-world addition
211
+ is the one-line `require "portless/rails"` to satisfy Rails' host authorization.
212
+
213
+ | | **portless (Node)** | **rb-portless (Ruby/Rails)** |
214
+ |---|---|---|
215
+ | Install | `npm i -g portless` | `gem install rb-portless` (or Gemfile dev group) |
216
+ | Run a server | `portless run next dev` | `rb-portless run bin/rails server` |
217
+ | Run the dev orchestrator | `portless` (runs `"dev"` script) | `rb-portless run bin/dev` (wraps Foreman) |
218
+ | Bake into the project | `"dev": "portless run next dev"` → `npm run dev` | put `rb-portless run` in `bin/dev`, or use the binstub |
219
+ | Name the URL | `portless myapp …` / `portless.json` | `portless.json` `{ "name": "myapp" }` (else dir/git root) |
220
+ | Wildcard tenant subdomains | `tld` config | `portless.json` `{ "tld": "myapp.localhost" }` → `*.myapp.localhost` |
221
+ | Pin the backend port | `--app-port` / `appPort` | `appPort` in `portless.json` |
222
+ | Framework port injection | vite/astro/etc. auto | same (Rails/puma respect `PORT` natively) |
223
+ | HTTPS trust | auto on first run (+ `portless trust`) | auto on first run (+ `rb-portless trust`) |
224
+ | **Host allowlist** | not needed | **`gem "rb-portless", require: "portless/rails"`** (Rails-only) |
225
+ | Privileged 443 bind | sudo re-exec (auto) | sudo re-exec (auto), `:1355` fallback |
226
+ | Bind at boot (no sudo) | `portless service install` | `rb-portless service install` |
227
+ | Inspect / manage | `portless list / doctor / clean` | `rb-portless list / doctor / clean` |
228
+ | Static route (DB, etc.) | `portless alias pg 5432` | `rb-portless alias pg 5432` |
229
+
167
230
  ## Contributing
168
231
 
169
232
  ```bash
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # The "where is my app" banner printed to stderr when a dev server starts
5
+ # through rb-portless — so you see the named URL, not just 127.0.0.1:port.
6
+ # Vite-ish layout; colours are stripped when stderr isn't a TTY. Mirrors the
7
+ # spirit of portless's run output.
8
+ module Banner
9
+ module_function
10
+
11
+ # rows: ordered [label, value, color] for the reachable URLs (Local,
12
+ # Network, Public, …); backend is the real 127.0.0.1:port behind the proxy.
13
+ def app(rows:, backend_port:)
14
+ out = [ "", " #{bold('rb-portless')} #{dim("v#{VERSION}")}", "" ]
15
+ rows.each { |label, value, paint| out << row(label, send(paint || :cyan, value)) }
16
+ out << row("Backend", dim("127.0.0.1:#{backend_port}"))
17
+ out << ""
18
+ out << " #{dim('ready — press Ctrl-C to stop')}"
19
+ out << ""
20
+ warn out.join("\n")
21
+ end
22
+
23
+ # Multi-app: one row per app (name → URL).
24
+ def multi(apps:)
25
+ out = [ "", " #{bold('rb-portless')} #{dim("v#{VERSION}")}", "" ]
26
+ apps.each { |app| out << row(app.name, cyan(app.url)) }
27
+ out << ""
28
+ out << " #{dim('ready — press Ctrl-C to stop')}"
29
+ out << ""
30
+ warn out.join("\n")
31
+ end
32
+
33
+ def row(label, value) = " #{green('➜')} #{label.to_s.ljust(8)}#{value}"
34
+
35
+ # ── colours (no-op unless stderr is a TTY) ──
36
+ def paint(code, str) = tty? ? "\e[#{code}m#{str}\e[0m" : str
37
+ def bold(str) = paint("1", str)
38
+ def dim(str) = paint("90", str)
39
+ def cyan(str) = paint("36", str)
40
+ def green(str) = paint("32", str)
41
+ def tty? = $stderr.tty?
42
+ end
43
+ end
data/lib/portless/cli.rb CHANGED
@@ -34,7 +34,32 @@ module Portless
34
34
 
35
35
  # ── Commands ────────────────────────────────────────────────────────────
36
36
  def cmd_run(args)
37
- Runner.new(command: strip_flags(args)).run
37
+ options, command = parse_run(args)
38
+ if command.empty? && Config.load.apps.any?
39
+ Multi.new.run # monorepo: portless.json `apps` map
40
+ else
41
+ Runner.new(command: command, options: options).run
42
+ end
43
+ end
44
+
45
+ # Consume leading rb-portless flags (--lan/--ip/--ngrok/--tailscale/--funnel),
46
+ # stopping at the first non-flag, an unknown flag, or `--`; the rest is the
47
+ # command to run.
48
+ def parse_run(args)
49
+ options = {}
50
+ rest = args.dup
51
+ while (flag = rest.first)&.start_with?("--")
52
+ case flag
53
+ when "--lan" then options[:lan] = true; rest.shift
54
+ when "--ip" then rest.shift; options[:ip] = rest.shift
55
+ when "--ngrok" then options[:ngrok] = true; rest.shift
56
+ when "--tailscale" then options[:tailscale] = true; rest.shift
57
+ when "--funnel" then options[:funnel] = true; rest.shift
58
+ when "--" then rest.shift; break
59
+ else break
60
+ end
61
+ end
62
+ [ options, rest ]
38
63
  end
39
64
 
40
65
  def cmd_proxy(args)
@@ -180,11 +205,6 @@ module Portless
180
205
  i ? Integer(args[i + 1], exception: false) : nil
181
206
  end
182
207
 
183
- def strip_flags(args)
184
- # everything after `run`, minus our own flags
185
- args.reject { |a| a.start_with?("--portless") }
186
- end
187
-
188
208
  def todo(name, desc, _args = nil)
189
209
  warn "rb-portless #{name}: #{desc} — not yet implemented (#{Portless::VERSION})"
190
210
  exit 1
@@ -206,7 +226,7 @@ module Portless
206
226
 
207
227
  Usage:
208
228
  rb-portless run <command> run a dev server through the proxy
209
- rb-portless [<command>] (bare) run the project's dev script
229
+ rb-portless run run the `apps` map, or the dev script
210
230
  rb-portless proxy start|stop manage the proxy daemon
211
231
  rb-portless trust trust the local CA (HTTPS)
212
232
  rb-portless hosts sync|clean manage /etc/hosts (Safari fallback)
@@ -215,6 +235,11 @@ module Portless
215
235
  rb-portless clean | prune tear down / reap orphans
216
236
  rb-portless service install bind the privileged port at boot
217
237
 
238
+ run flags:
239
+ --lan [--ip <addr>] also serve on the LAN (<name>.local)
240
+ --ngrok share publicly via ngrok
241
+ --tailscale | --funnel share via your tailnet / Funnel
242
+
218
243
  HTTPS is the default (https://<name>.localhost). Config: portless.json.
219
244
  HELP
220
245
  end
@@ -9,7 +9,7 @@ module Portless
9
9
  class Config
10
10
  DEFAULT_SCRIPT = "dev"
11
11
 
12
- attr_reader :name, :tld, :script, :app_port, :tls
12
+ attr_reader :name, :tld, :script, :app_port, :tls, :apps
13
13
 
14
14
  def self.load(dir = Dir.pwd)
15
15
  new(read_file(dir), dir)
@@ -22,6 +22,8 @@ module Portless
22
22
  @script = data["script"] || DEFAULT_SCRIPT
23
23
  @app_port = data["appPort"] || data["app_port"]
24
24
  @tls = data.fetch("tls", true)
25
+ # Monorepo: { "apps": { "web": "bin/rails server", "api": "node api.js" } }.
26
+ @apps = (data["apps"] || {}).transform_keys { |k| sanitize_label(k) }
25
27
  end
26
28
 
27
29
  # The full hostname an app registers (e.g. "shirabe.org.localhost" when tld is
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Portless
6
+ # The machine's primary private LAN IPv4 — so phones/tablets on the same Wi-Fi
7
+ # can reach the dev app. Pure stdlib. Mirrors portless's lan-ip.ts.
8
+ module LanIp
9
+ module_function
10
+
11
+ def detect(override = nil)
12
+ return override if override.to_s.strip != ""
13
+
14
+ Socket.ip_address_list
15
+ .find { |addr| addr.ipv4? && addr.ipv4_private? && !addr.ipv4_loopback? }
16
+ &.ip_address
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # Publish `<name>.local → LAN IP` over mDNS so phones/tablets resolve it on the
5
+ # local network (`.localhost` only works on the dev machine). Shells out to the
6
+ # OS responder — `dns-sd` on macOS, `avahi-publish` on Linux — and returns the
7
+ # publisher pid (kill it to unpublish). A no-op (with a hint) if neither tool
8
+ # is present. Mirrors portless's mdns.ts.
9
+ module Mdns
10
+ module_function
11
+
12
+ def publish(hostname, ip)
13
+ return nil unless ip
14
+
15
+ cmd = command_for(hostname, ip)
16
+ unless cmd
17
+ warn "rb-portless: no mDNS responder (dns-sd / avahi-publish) — `#{hostname}` won't resolve on the LAN"
18
+ return nil
19
+ end
20
+
21
+ pid = Process.spawn(*cmd, out: File::NULL, err: File::NULL)
22
+ Process.detach(pid)
23
+ pid
24
+ rescue StandardError
25
+ nil
26
+ end
27
+
28
+ def unpublish(pid)
29
+ Process.kill("TERM", pid) if pid
30
+ rescue StandardError
31
+ nil
32
+ end
33
+
34
+ def command_for(hostname, ip)
35
+ if Portless.which("dns-sd")
36
+ # Proxy-register an A record: name type domain port host ip.
37
+ [ "dns-sd", "-P", hostname.sub(/\.local\z/, ""), "_http._tcp", "local", "80", hostname, ip ]
38
+ elsif Portless.which("avahi-publish")
39
+ [ "avahi-publish", "-a", "-R", hostname, ip ]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # Run several apps under one proxy, each at its own `<name>.<tld>`, from the
5
+ # `apps` map in portless.json. Every app gets a free backend port, a route, and
6
+ # injected PORT/HOST/PORTLESS_URL; all run in their own process groups and are
7
+ # supervised + torn down together. Ruby sets env per-spawn, so there's no
8
+ # NODE_OPTIONS loader hack (cf. portless's turbo.ts).
9
+ class Multi
10
+ App = Struct.new(:name, :hostname, :port, :url, :pid, keyword_init: true)
11
+
12
+ def initialize(config: Config.load, route_store: RouteStore.new)
13
+ @config = config
14
+ @route_store = route_store
15
+ @apps = []
16
+ end
17
+
18
+ def run
19
+ raise Error, "no apps defined — add an \"apps\" map to portless.json" if @config.apps.empty?
20
+
21
+ ensure_trusted if @config.tls
22
+ proxy_port = Daemon.ensure_running(tls: @config.tls)
23
+ @apps = @config.apps.map { |name, command| start_app(name, command, proxy_port) }
24
+
25
+ Banner.multi(apps: @apps)
26
+ install_signal_handlers
27
+ Process.waitall
28
+ ensure
29
+ teardown
30
+ end
31
+
32
+ private
33
+
34
+ def start_app(name, command, proxy_port)
35
+ port = FreePort.find
36
+ hostname = "#{name}.#{@config.tld}"
37
+ url = display_url(hostname, proxy_port)
38
+ @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
39
+ # A bare command string runs through the shell (handles "bin/rails server").
40
+ pid = Process.spawn(child_env(port, url), command, pgroup: true)
41
+ App.new(name: name, hostname: hostname, port: port, url: url, pid: pid)
42
+ end
43
+
44
+ def install_signal_handlers
45
+ %w[INT TERM].each { |sig| trap(sig) { kill_all(sig) } }
46
+ end
47
+
48
+ def kill_all(sig)
49
+ @apps.each do |app|
50
+ Process.kill(sig, -Process.getpgid(app.pid))
51
+ rescue StandardError
52
+ nil
53
+ end
54
+ end
55
+
56
+ def teardown
57
+ @apps.each { |app| @route_store.remove(app.hostname, owner_pid: Process.pid) }
58
+ kill_all("TERM")
59
+ end
60
+
61
+ def child_env(port, url)
62
+ {
63
+ "PORT" => port.to_s,
64
+ "HOST" => "127.0.0.1",
65
+ "PORTLESS_URL" => url,
66
+ "SSL_CERT_FILE" => (File.exist?(State.ca_cert) ? State.ca_cert : nil)
67
+ }.compact
68
+ end
69
+
70
+ def display_url(hostname, proxy_port)
71
+ scheme = @config.tls ? "https" : "http"
72
+ default = @config.tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
73
+ suffix = proxy_port && proxy_port != default ? ":#{proxy_port}" : ""
74
+ "#{scheme}://#{hostname}#{suffix}"
75
+ end
76
+
77
+ def ensure_trusted
78
+ Trust.install! if Privilege.interactive? && !Trust.trusted?
79
+ rescue Portless::Error
80
+ nil
81
+ end
82
+ end
83
+ end
@@ -6,10 +6,11 @@ module Portless
6
6
  # group (forwarding signals), and deregister on exit. Rails/puma respect PORT
7
7
  # natively. Mirrors portless's runApp/spawnCommand.
8
8
  class Runner
9
- def initialize(command:, config: Config.load, route_store: RouteStore.new)
9
+ def initialize(command:, config: Config.load, route_store: RouteStore.new, options: {})
10
10
  @command = Array(command)
11
11
  @config = config
12
12
  @route_store = route_store
13
+ @options = options # :lan, :ip, :ngrok, :tailscale, :funnel
13
14
  end
14
15
 
15
16
  def run
@@ -19,20 +20,87 @@ module Portless
19
20
  port = @config.app_port&.to_i || FreePort.find
20
21
  command = Frameworks.inject(command, port) # --port/--host for vite/astro/etc.
21
22
  hostname = @config.hostname
22
- url = "#{@config.tls ? 'https' : 'http'}://#{hostname}"
23
23
 
24
- Daemon.ensure_running(tls: @config.tls)
24
+ ensure_trusted if @config.tls
25
+ proxy_port = Daemon.ensure_running(tls: @config.tls)
25
26
  @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
26
27
 
27
- announce(url, port)
28
+ url = display_url(hostname, proxy_port)
29
+ rows = [ [ "Local", url, :cyan ] ]
30
+ rows.concat(lan_rows(port, proxy_port))
31
+ rows.concat(share_rows(hostname, port))
32
+ Banner.app(rows: rows, backend_port: port)
33
+
28
34
  status = supervise(command, port, url)
29
35
  exit(status)
30
36
  ensure
37
+ teardown
31
38
  @route_store.remove(hostname, owner_pid: Process.pid) if hostname
32
39
  end
33
40
 
34
41
  private
35
42
 
43
+ def display_url(hostname, proxy_port)
44
+ scheme = @config.tls ? "https" : "http"
45
+ default = @config.tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
46
+ suffix = proxy_port && proxy_port != default ? ":#{proxy_port}" : ""
47
+ "#{scheme}://#{hostname}#{suffix}"
48
+ end
49
+
50
+ # ── LAN mode (--lan) ──────────────────────────────────────────────────
51
+ # Register a `<name>.local` route, publish it over mDNS, and surface the URL
52
+ # so phones/tablets on the Wi-Fi can reach the app.
53
+ def lan_rows(backend_port, proxy_port)
54
+ return [] unless @options[:lan]
55
+
56
+ ip = LanIp.detect(@options[:ip])
57
+ return [ [ "Network", "no LAN IPv4 found", :dim ] ] unless ip
58
+
59
+ @lan_host = "#{@config.name}.local"
60
+ @route_store.add(hostname: @lan_host, port: backend_port, pid: Process.pid, force: true)
61
+ @mdns_pid = Mdns.publish(@lan_host, ip)
62
+ warn "rb-portless: trust #{State.ca_cert} on the device for HTTPS over the LAN" if @config.tls
63
+ [ [ "Network", display_url(@lan_host, proxy_port), :green ] ]
64
+ end
65
+
66
+ # ── Public sharing (--ngrok / --tailscale / --funnel) ─────────────────
67
+ def share_rows(hostname, backend_port)
68
+ rows = []
69
+ if @options[:ngrok] && (@ngrok = Share::Ngrok.start(hostname: hostname, backend_port: backend_port))
70
+ rows << [ "Public", @ngrok[:url], :green ]
71
+ end
72
+ if (@options[:tailscale] || @options[:funnel]) &&
73
+ (@tailscale = Share::Tailscale.start(backend_port: backend_port, funnel: @options[:funnel]))
74
+ rows << [ @options[:funnel] ? "Funnel" : "Tailnet", @tailscale[:url], :green ]
75
+ end
76
+ rows
77
+ end
78
+
79
+ def teardown
80
+ Mdns.unpublish(@mdns_pid)
81
+ @route_store.remove(@lan_host, owner_pid: Process.pid) if @lan_host
82
+ Share::Ngrok.stop(@ngrok) if @ngrok
83
+ Share::Tailscale.stop(@tailscale) if @tailscale
84
+ end
85
+
86
+ # Trust the local CA on first run (like portless), so HTTPS works without
87
+ # browser warnings out of the box. Interactive only — macOS prompts for
88
+ # keychain auth; in CI/no-TTY we skip with a hint rather than hang. Never
89
+ # blocks the run if trusting fails.
90
+ def ensure_trusted
91
+ return if Trust.trusted?
92
+
93
+ unless Privilege.interactive?
94
+ warn "rb-portless: CA not trusted — run `rb-portless trust` (HTTPS shows warnings until then)"
95
+ return
96
+ end
97
+
98
+ warn "rb-portless: trusting the local CA (first run)…"
99
+ Trust.install!
100
+ rescue Portless::Error => e
101
+ warn "rb-portless: couldn't auto-trust the CA (#{e.message}) — run `rb-portless trust`"
102
+ end
103
+
36
104
  # Run the child in its own process group so we can signal the whole tree,
37
105
  # forwarding INT/TERM and propagating its exit status.
38
106
  def supervise(command, port, url)
@@ -73,9 +141,5 @@ module Portless
73
141
 
74
142
  []
75
143
  end
76
-
77
- def announce(url, port)
78
- warn "rb-portless → #{url} (backend :#{port})"
79
- end
80
144
  end
81
145
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Portless
8
+ module Share
9
+ # Expose the local app publicly via ngrok. We point ngrok at the *backend*
10
+ # port directly (with the app's Host header) rather than tunnelling through
11
+ # our self-signed TLS proxy — simpler and avoids cert-trust issues. Returns
12
+ # { pid:, url: } or nil. EXPERIMENTAL. Mirrors portless's ngrok.ts.
13
+ module Ngrok
14
+ module_function
15
+
16
+ API = "http://127.0.0.1:4040/api/tunnels"
17
+
18
+ def start(hostname:, backend_port:)
19
+ unless Portless.which("ngrok")
20
+ warn "rb-portless: ngrok not found — install it (https://ngrok.com/download) to use --ngrok"
21
+ return nil
22
+ end
23
+
24
+ pid = Process.spawn("ngrok", "http", backend_port.to_s, "--host-header=#{hostname}",
25
+ out: File::NULL, err: File::NULL)
26
+ Process.detach(pid)
27
+
28
+ if (url = poll_public_url)
29
+ { pid: pid, url: url }
30
+ else
31
+ stop(pid: pid)
32
+ warn "rb-portless: ngrok didn't produce a public URL — is your authtoken set? (`ngrok config add-authtoken <token>`)"
33
+ nil
34
+ end
35
+ rescue StandardError => e
36
+ warn "rb-portless: ngrok failed (#{e.message})"
37
+ nil
38
+ end
39
+
40
+ def stop(handle)
41
+ pid = handle.is_a?(Hash) ? handle[:pid] : handle
42
+ Process.kill("TERM", pid) if pid
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def poll_public_url(tries: 25)
48
+ tries.times do
49
+ sleep 0.3
50
+ body = fetch(API)
51
+ next unless body
52
+
53
+ tunnels = JSON.parse(body)["tunnels"] || []
54
+ url = tunnels.map { |t| t["public_url"] }.compact.find { |u| u.start_with?("https") }
55
+ return url if url
56
+ end
57
+ nil
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def fetch(url)
63
+ Net::HTTP.get(URI(url))
64
+ rescue StandardError
65
+ nil
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Portless
6
+ module Share
7
+ # Expose the local app on your tailnet (`serve`) or publicly (`funnel`) via
8
+ # the tailscale CLI. Returns { mode:, port:, url: } or nil. EXPERIMENTAL.
9
+ #
10
+ # SAFETY (mirrors portless's tailscale.ts): we never clobber your existing
11
+ # serve config — we read `tailscale serve status` for ports already in use
12
+ # and pick the first FREE one from the preferred list, register with `--yes`
13
+ # (no prompt), and on teardown turn off ONLY the port we registered.
14
+ module Tailscale
15
+ module_function
16
+
17
+ PREFERRED_SERVE_PORTS = [ 443, 8443, 8444, 8445, 8446, 8447, 8448, 8449, 8450 ].freeze
18
+ FUNNEL_PORTS = [ 443, 8443, 10_000 ].freeze # Funnel supports only these
19
+
20
+ def start(backend_port:, funnel: false)
21
+ unless Portless.which("tailscale")
22
+ warn "rb-portless: tailscale not found — install it (https://tailscale.com/download) to use --tailscale"
23
+ return nil
24
+ end
25
+
26
+ status = status_json
27
+ unless status
28
+ warn "rb-portless: tailscale isn't connected — run `tailscale up`, then retry"
29
+ return nil
30
+ end
31
+ unless capability?(status, "https")
32
+ warn "rb-portless: tailscale HTTPS certs aren't enabled — turn on HTTPS in your tailnet DNS settings"
33
+ return nil
34
+ end
35
+ if funnel && !capability?(status, "funnel")
36
+ warn "rb-portless: tailscale Funnel isn't enabled for this node — enable it, then retry --funnel"
37
+ return nil
38
+ end
39
+
40
+ mode = funnel ? "funnel" : "serve"
41
+ port = available_port(funnel: funnel)
42
+ unless port
43
+ warn "rb-portless: all tailscale Funnel ports are in use (443/8443/10000)"
44
+ return nil
45
+ end
46
+
47
+ unless system("tailscale", mode, "--bg", "--yes", "--https=#{port}",
48
+ "http://127.0.0.1:#{backend_port}", out: File::NULL, err: File::NULL)
49
+ warn "rb-portless: `tailscale #{mode}` failed to register"
50
+ return nil
51
+ end
52
+
53
+ base = dns_name(status)
54
+ return (off(mode, port) and nil) unless base
55
+
56
+ { mode: mode, port: port, url: format_url(base, port) }
57
+ rescue StandardError => e
58
+ warn "rb-portless: tailscale sharing failed (#{e.message})"
59
+ nil
60
+ end
61
+
62
+ def stop(handle)
63
+ return unless handle && Portless.which("tailscale")
64
+
65
+ off(handle[:mode], handle[:port])
66
+ end
67
+
68
+ # Turn off ONLY the registration we created (scoped to our port).
69
+ def off(mode, port)
70
+ system("tailscale", mode, "--yes", "--https=#{port}", "off", out: File::NULL, err: File::NULL)
71
+ rescue StandardError
72
+ nil
73
+ end
74
+
75
+ # First free HTTPS port from the preferred pool, never one already in use by
76
+ # the user's existing serve/funnel config. nil if the funnel pool is full.
77
+ def available_port(funnel:)
78
+ used = used_serve_ports
79
+ pool = funnel ? FUNNEL_PORTS : PREFERRED_SERVE_PORTS
80
+ free = pool.find { |port| !used.include?(port) }
81
+ return free if free
82
+ return nil if funnel
83
+
84
+ port = PREFERRED_SERVE_PORTS.last + 1
85
+ port += 1 while used.include?(port)
86
+ port
87
+ end
88
+
89
+ # HTTPS ports the user's current serve config already occupies.
90
+ def used_serve_ports
91
+ config = JSON.parse(`tailscale serve status --json 2>/dev/null`)
92
+ ports = []
93
+ (config["Web"] || {}).each_key { |host_port| ports << Regexp.last_match(1).to_i if host_port =~ /:(\d+)\z/ }
94
+ (config["TCP"] || {}).each_key { |port| ports << port.to_i }
95
+ ports
96
+ rescue StandardError
97
+ []
98
+ end
99
+
100
+ def status_json
101
+ json = JSON.parse(`tailscale status --json 2>/dev/null`)
102
+ json.is_a?(Hash) ? json : nil
103
+ rescue StandardError
104
+ nil
105
+ end
106
+
107
+ def dns_name(status)
108
+ dns = status.dig("Self", "DNSName").to_s.chomp(".")
109
+ dns.empty? ? nil : "https://#{dns}"
110
+ end
111
+
112
+ # Does this node have the HTTPS / Funnel capability? (mirrors portless)
113
+ def capability?(status, name)
114
+ node = status["Self"] || {}
115
+ names = Array(node["Capabilities"]) + (node["CapMap"] || {}).keys
116
+ names.any? { |cap| down = cap.to_s.downcase; down == name || down.end_with?("/#{name}") }
117
+ end
118
+
119
+ def format_url(base, port)
120
+ base = base.chomp("/")
121
+ port == 443 ? base : "#{base}:#{port}"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0.dev.20260630.8a76e8f"
5
5
  end
data/lib/rb-portless.rb CHANGED
@@ -17,7 +17,13 @@ require_relative "portless/proxy"
17
17
  require_relative "portless/daemon"
18
18
  require_relative "portless/service"
19
19
  require_relative "portless/frameworks"
20
+ require_relative "portless/banner"
21
+ require_relative "portless/lan_ip"
22
+ require_relative "portless/mdns"
23
+ require_relative "portless/share/ngrok"
24
+ require_relative "portless/share/tailscale"
20
25
  require_relative "portless/runner"
26
+ require_relative "portless/multi"
21
27
  require_relative "portless/cli"
22
28
 
23
29
  module Portless
@@ -25,4 +31,9 @@ module Portless
25
31
 
26
32
  # Raised when a privileged action can't run non-interactively (no TTY / CI).
27
33
  class NonInteractiveError < Error; end
34
+
35
+ # Is an executable on PATH? (For optional external tools: dns-sd, ngrok, …)
36
+ def self.which(bin)
37
+ ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, bin)) }
38
+ end
28
39
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rb-portless
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0.dev.20260630.8a76e8f
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso
@@ -56,6 +56,7 @@ files:
56
56
  - LICENSE
57
57
  - README.md
58
58
  - exe/rb-portless
59
+ - lib/portless/banner.rb
59
60
  - lib/portless/certs.rb
60
61
  - lib/portless/cli.rb
61
62
  - lib/portless/config.rb
@@ -65,6 +66,9 @@ files:
65
66
  - lib/portless/free_port.rb
66
67
  - lib/portless/health.rb
67
68
  - lib/portless/hosts.rb
69
+ - lib/portless/lan_ip.rb
70
+ - lib/portless/mdns.rb
71
+ - lib/portless/multi.rb
68
72
  - lib/portless/privilege.rb
69
73
  - lib/portless/proxy.rb
70
74
  - lib/portless/rails.rb
@@ -72,6 +76,8 @@ files:
72
76
  - lib/portless/route_store.rb
73
77
  - lib/portless/runner.rb
74
78
  - lib/portless/service.rb
79
+ - lib/portless/share/ngrok.rb
80
+ - lib/portless/share/tailscale.rb
75
81
  - lib/portless/state.rb
76
82
  - lib/portless/trust.rb
77
83
  - lib/portless/version.rb