rb-portless 0.3.1 → 0.4.0.dev.20260630.0d72797

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: 0df8dbb4c256aa152a22a8dff868be693a0cb35290f1576098ad6bb68b248f7f
4
- data.tar.gz: 2a96b1f4877a39d1df935fba7d467066acf08970ce508c44dbc5873c1190c645
3
+ metadata.gz: 8938cad2e70c50e9e39f2078fe9a8590b6f0324a136cd1da9f5a9258a0604c4f
4
+ data.tar.gz: 5fe88139d39e877e53b0adf46a87bd20ee361b23f3f858ca74d2039df3ee0562
5
5
  SHA512:
6
- metadata.gz: f2cf340182dfe22b8ee7cadd2d250a7c4f3c5949a8ff7d0767651e64b706c6927b66e32d9d3c30218f94580879037fa5c4aeb0792c423447e7fd621ac5f6329f
7
- data.tar.gz: 71b881128b1a4250ae1d7b65823ca2d582a9fff5597b4cc982293d4e7e22135d6cb90352076159d8c3579f1c2be96efb7e70d130670a26c86a1f8006cedfcd30
6
+ metadata.gz: e49cc73d6937721e37c41c4489755252c1cc815f1b687b2187a62c6e1a12c3c496d9f3eaf24a948957488ef0b8928f21862c4920225b707988938d53fb0f51bb
7
+ data.tar.gz: b4ef3864d39837cb27896997d22c24e4ee496c004bfa0a296a534ead61ed1a555936457e55600bcccc942ca41d776c9456ab4bd3dacfd81c7809719b1768bdd1
data/README.md CHANGED
@@ -112,17 +112,36 @@ anything, same as portless), and each degrades gracefully if the tool is absent:
112
112
  | Command | Does |
113
113
  | --- | --- |
114
114
  | `run <cmd>` | run a dev server through the proxy |
115
+ | `<name> <cmd>` | shorthand for `run --name <name> <cmd>` |
115
116
  | `proxy start \| stop` | manage the proxy daemon |
116
117
  | `trust` | install the local CA into the OS trust store |
117
118
  | `service install \| uninstall \| status` | bind the privileged port at boot (launchd/systemd) |
118
- | `alias <name> <port>` | a static route (Docker, Postgres, …) |
119
- | `get <name>` | print a name's URL (for `$(rb-portless get api)`) |
120
- | `list` | show active routes |
119
+ | `alias <name> <port> [--force]` | a static route (Docker, Postgres, …); `--remove` to drop it |
120
+ | `get <name> [--no-worktree]` | print a name's URL (for `$(rb-portless get api)`) |
121
+ | `list` | show active routes (with any tailscale/ngrok URLs) |
121
122
  | `hosts sync \| clean` | manage `/etc/hosts` (Safari / non-`.localhost` TLDs) |
122
123
  | `doctor` | diagnose setup |
123
- | `prune` | reap stale routes |
124
+ | `prune [--force]` | reap stale routes and kill the orphaned dev server (`--force` = SIGKILL) |
124
125
  | `clean` | stop the proxy, untrust the CA, remove all state |
125
126
 
127
+ Every subcommand takes `--help`. `rb-portless <cmd> --help` prints command-specific
128
+ help.
129
+
130
+ ### Run flags
131
+
132
+ | Flag | Does |
133
+ | --- | --- |
134
+ | `--name <name>` | override the inferred hostname |
135
+ | `--app-port <n>` | pin the backend port (else a random 4000–4999) |
136
+ | `--force` | take over a route already held by another `run` |
137
+ | `--lan [--ip <addr>]` | also serve on the LAN as `<name>.local` (mDNS) |
138
+ | `--ngrok` / `--tailscale` / `--funnel` | share publicly |
139
+
140
+ In a **git worktree** linked off a non-default branch, the branch name is prepended
141
+ as a subdomain — `feature/auth` → `https://auth.<name>.localhost` — so every worktree
142
+ gets a distinct URL. Pass `--no-worktree` (on `get`) to skip it. Set **`PORTLESS=0`**
143
+ (`false`/`skip`) to run the command directly without the proxy.
144
+
126
145
  ## Rails
127
146
 
128
147
  Rails is first-class: it respects `PORT` and trusts the loopback proxy, so
@@ -32,12 +32,10 @@ module Portless
32
32
 
33
33
  def row(label, value) = " #{green('➜')} #{label.to_s.ljust(8)}#{value}"
34
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?
35
+ # ── colours (target stderr, where the banner is printed) ──
36
+ def bold(str) = Colors.bold(str, io: $stderr)
37
+ def dim(str) = Colors.dim(str, io: $stderr)
38
+ def cyan(str) = Colors.cyan(str, io: $stderr)
39
+ def green(str) = Colors.green(str, io: $stderr)
42
40
  end
43
41
  end
data/lib/portless/cli.rb CHANGED
@@ -16,17 +16,24 @@ module Portless
16
16
  end
17
17
 
18
18
  def run
19
- return print_version if flag?("--version", "-v")
20
- return print_help if @argv.empty? || flag?("--help", "-h")
19
+ first = @argv.first
20
+ return print_version if %w[--version -v].include?(first)
21
+ return print_help if @argv.empty? || %w[--help -h].include?(first)
21
22
 
22
- command = @argv.first
23
- command = "run" unless COMMANDS.include?(command)
24
- send("cmd_#{command}", rest(command))
23
+ if COMMANDS.include?(first)
24
+ return command_help(first) if %w[--help -h].include?(@argv[1])
25
+
26
+ send("cmd_#{first}", rest(first))
27
+ elsif first.start_with?("--")
28
+ cmd_run(@argv) # leading flags (incl. --name) → run mode
29
+ else
30
+ cmd_named(@argv) # `rb-portless <name> <command…>` shorthand
31
+ end
25
32
  rescue Portless::NonInteractiveError => e
26
- warn "rb-portless: #{e.message}"
33
+ warn error_line(e.message)
27
34
  exit 2
28
35
  rescue Portless::Error => e
29
- warn "rb-portless: #{e.message}"
36
+ warn error_line(e.message)
30
37
  exit 1
31
38
  end
32
39
 
@@ -42,54 +49,73 @@ module Portless
42
49
  end
43
50
  end
44
51
 
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.
52
+ # `rb-portless <name> <command…>`: run the command under the hostname <name>
53
+ # (portless's named-app shorthand). The first non-flag token is the name.
54
+ def cmd_named(args)
55
+ options, rest = parse_run(args)
56
+ name = rest.shift
57
+ if rest.empty?
58
+ raise Error, "no command given — try `rb-portless run #{name}` or `rb-portless #{name} <command>`"
59
+ end
60
+
61
+ options[:name] = name
62
+ Runner.new(command: rest, options: options).run
63
+ end
64
+
65
+ # Pull known flags out of the run args from anywhere before `--` (mirrors
66
+ # portless's global-flag stripping), leaving the command to execute. Flags:
67
+ # --lan/--ip, sharing (--ngrok/--tailscale/--funnel), --name, --force,
68
+ # --app-port. Everything after `--` is the command verbatim.
48
69
  def parse_run(args)
49
70
  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
71
+ command = []
72
+ i = 0
73
+ while i < args.length
74
+ case args[i]
75
+ when "--" then command.concat(args[i + 1..]); break
76
+ when "--lan" then options[:lan] = true
77
+ when "--ip" then options[:ip] = args[i += 1]
78
+ when "--ngrok" then options[:ngrok] = true
79
+ when "--tailscale" then options[:tailscale] = true
80
+ when "--funnel" then options[:funnel] = true
81
+ when "--force" then options[:force] = true
82
+ when "--name" then options[:name] = args[i += 1]
83
+ when "--app-port" then options[:app_port] = parse_port!(args[i += 1], "--app-port")
84
+ else command << args[i]
60
85
  end
86
+ i += 1
61
87
  end
62
- [ options, rest ]
88
+ [ options, command ]
63
89
  end
64
90
 
65
91
  def cmd_proxy(args)
66
- action = args.first
67
- case action
92
+ case args.first
68
93
  when "start"
69
94
  Daemon.start(tls: tls_flag(args), port: int_flag(args, "--port"), foreground: flag?("--foreground"))
70
- when "stop"
71
- Daemon.stop
72
- else
73
- warn "usage: rb-portless proxy start|stop"
74
- exit 1
95
+ when "stop" then Daemon.stop
96
+ when nil then command_help("proxy")
97
+ else invalid_action!("proxy start|stop")
75
98
  end
76
99
  end
77
100
 
78
101
  def cmd_trust(_args)
79
102
  if Trust.trusted?
80
- puts "rb-portless: CA already trusted"
103
+ info "CA already trusted"
81
104
  else
82
105
  Trust.install!
83
- puts "rb-portless: local CA trusted"
106
+ ok "local CA trusted"
84
107
  end
85
108
  end
86
109
 
87
110
  def cmd_list(_args)
88
111
  routes = RouteStore.new.routes
89
- if routes.empty?
90
- puts "rb-portless: no active routes"
91
- else
92
- routes.each { |r| puts format("%-40s :%d", r.hostname, r.port) }
112
+ return puts(Colors.dim("rb-portless: no active routes")) if routes.empty?
113
+
114
+ routes.each do |r|
115
+ tag = r.alias? ? "alias" : "pid #{r.pid}"
116
+ puts "#{Colors.cyan(r.hostname.ljust(40))} → :#{r.port} #{Colors.dim("(#{tag})")}"
117
+ puts " #{Colors.dim('↳ tailnet')} #{Colors.green(r.tailscale)}" if r.tailscale
118
+ puts " #{Colors.dim('↳ ngrok ')} #{Colors.green(r.ngrok)}" if r.ngrok
93
119
  end
94
120
  end
95
121
 
@@ -98,40 +124,46 @@ module Portless
98
124
  when "sync"
99
125
  hostnames = RouteStore.new.routes.map(&:hostname).uniq
100
126
  with_hosts_write([ "hosts", "sync" ]) { Hosts.sync(hostnames) }
101
- puts "rb-portless: synced #{hostnames.size} host(s) to #{Hosts.file}"
127
+ ok "synced #{hostnames.size} host(s) to #{Hosts.file}"
102
128
  when "clean"
103
129
  with_hosts_write([ "hosts", "clean" ]) { Hosts.clean }
104
- puts "rb-portless: cleaned #{Hosts.file}"
105
- else
106
- warn "usage: rb-portless hosts sync|clean"
107
- exit 1
130
+ ok "cleaned #{Hosts.file}"
131
+ when nil then command_help("hosts")
132
+ else invalid_action!("hosts sync|clean")
108
133
  end
109
134
  end
110
135
 
111
136
  def cmd_alias(args)
112
137
  if args.first == "--remove"
113
138
  name = args[1] or abort_usage("alias --remove <name>")
114
- RouteStore.new.remove(hostname_for(name))
115
- puts "rb-portless: removed alias #{hostname_for(name)}"
139
+ host = hostname_for(name)
140
+ raise Error, "no alias found for #{host}" unless RouteStore.new.remove(host, owner_pid: 0)
141
+ ok "removed alias #{host}"
116
142
  else
117
- name, port = args
118
- abort_usage("alias <name> <port>") unless name && port
119
- RouteStore.new.add(hostname: hostname_for(name), port: Integer(port), pid: 0, force: true)
120
- puts "rb-portless: #{hostname_for(name)} → :#{port}"
143
+ name, port = args.reject { |a| a.start_with?("--") }
144
+ abort_usage("alias <name> <port> [--force]") unless name && port
145
+ host = hostname_for(name)
146
+ port = parse_port!(port, "port")
147
+ RouteStore.new.add(hostname: host, port: port, pid: 0, force: args.include?("--force"))
148
+ ok "#{host} → :#{port}"
121
149
  end
122
150
  end
123
151
 
124
152
  def cmd_get(args)
125
- name = args.first or abort_usage("get <name>")
153
+ worktree = !args.include?("--no-worktree")
154
+ name = args.find { |a| !a.start_with?("--") } or abort_usage("get <name> [--no-worktree]")
126
155
  config = Config.load
127
- puts "#{config.tls ? 'https' : 'http'}://#{hostname_for(name)}"
156
+ host = hostname_for(name)
157
+ host = "#{config.worktree_prefix}.#{host}" if worktree && config.worktree_prefix
158
+ puts "#{config.tls ? 'https' : 'http'}://#{host}"
128
159
  end
129
160
 
130
- def cmd_prune(_args)
131
- store = RouteStore.new
132
- before = store.routes.size
133
- store.prune
134
- puts "rb-portless: pruned #{before - store.routes.size} stale route(s)"
161
+ def cmd_prune(args)
162
+ force = args.include?("--force")
163
+ pruned = RouteStore.new.prune
164
+ killed = pruned.sum { |r| PortOwner.kill(r.port, force: force) }
165
+ note = killed.positive? ? ", killed #{killed} orphan process(es)" : ""
166
+ ok "pruned #{pruned.size} stale route(s)#{note}"
135
167
  end
136
168
 
137
169
  def cmd_clean(_args)
@@ -140,10 +172,13 @@ module Portless
140
172
  begin; with_hosts_write([ "hosts", "clean" ]) { Hosts.clean }; rescue StandardError; end
141
173
  require "fileutils"
142
174
  FileUtils.rm_rf(State.dir)
143
- puts "rb-portless: removed all state"
175
+ ok "removed all state"
144
176
  end
145
177
 
146
- def cmd_doctor(_args)
178
+ def cmd_doctor(args)
179
+ extra = args.reject { |a| %w[--help -h].include?(a) }
180
+ raise Error, "unknown argument #{extra.first.inspect}" unless extra.empty?
181
+
147
182
  port = Health.discover_port
148
183
  routes = RouteStore.new.routes
149
184
  report = [
@@ -151,12 +186,14 @@ module Portless
151
186
  [ "state dir", File.directory?(State.dir), State.dir ],
152
187
  [ "proxy running", !port.nil?, port ? "on :#{port}" : "not running" ],
153
188
  [ "CA generated", File.exist?(State.ca_cert), nil ],
154
- [ "CA trusted", safe { Trust.trusted? }, Constants::MACOS ? nil : "macOS only for now" ],
189
+ [ "CA trusted", safe { Trust.trusted? }, safe { Trust.trusted? } ? nil : "run `rb-portless trust`" ],
155
190
  [ "routes", true, "#{routes.size} active" ]
156
191
  ]
157
- report.each do |label, ok, note|
158
- puts " #{ok ? '✓' : '✗'} #{label}#{note ? " — #{note}" : ''}"
192
+ report.each do |label, pass, note|
193
+ mark = pass ? Colors.green("") : Colors.red("✗")
194
+ puts " #{mark} #{label}#{note ? " — #{Colors.dim(note)}" : ''}"
159
195
  end
196
+ exit 1 if report.any? { |_label, pass, _note| !pass }
160
197
  end
161
198
 
162
199
  def cmd_service(args)
@@ -164,9 +201,8 @@ module Portless
164
201
  when "install" then Service.install(tls: tls_flag(args), port: int_flag(args, "--port"))
165
202
  when "uninstall" then Service.uninstall
166
203
  when "status" then Service.status
167
- else
168
- warn "usage: rb-portless service install|uninstall|status"
169
- exit 1
204
+ when nil then command_help("service")
205
+ else invalid_action!("service install|uninstall|status")
170
206
  end
171
207
  end
172
208
 
@@ -189,10 +225,23 @@ module Portless
189
225
  end
190
226
 
191
227
  def abort_usage(usage)
192
- warn "usage: rb-portless #{usage}"
228
+ warn error_line("usage: rb-portless #{usage}")
229
+ exit 1
230
+ end
231
+
232
+ # An *unknown* sub-action is an error (exit 1); a bare subcommand prints its
233
+ # help and exits 0 (handled by the `when nil` arms). Mirrors portless's
234
+ # `exit(help || !args[1] ? 0 : 1)`.
235
+ def invalid_action!(usage)
236
+ warn error_line("usage: rb-portless #{usage}")
193
237
  exit 1
194
238
  end
195
239
 
240
+ # ── Output (colour no-ops when not a TTY / NO_COLOR) ──────────────────
241
+ def ok(msg) = puts Colors.green("rb-portless: #{msg}")
242
+ def info(msg) = puts "rb-portless: #{msg}"
243
+ def error_line(msg) = Colors.red("rb-portless: #{msg}", io: $stderr)
244
+
196
245
  # ── Flag helpers ──────────────────────────────────────────────────────
197
246
  def tls_flag(args)
198
247
  return false if args.include?("--no-tls")
@@ -205,6 +254,14 @@ module Portless
205
254
  i ? Integer(args[i + 1], exception: false) : nil
206
255
  end
207
256
 
257
+ # Parse + validate a port in 1–65535, with a clean error (never a backtrace).
258
+ def parse_port!(value, label)
259
+ port = Integer(value.to_s, exception: false)
260
+ raise Error, "invalid #{label} #{value.inspect} — must be 1-65535" unless port&.between?(1, 65_535)
261
+
262
+ port
263
+ end
264
+
208
265
  def rest(command)
209
266
  @argv.first == command ? @argv[1..] : @argv
210
267
  end
@@ -217,26 +274,85 @@ module Portless
217
274
 
218
275
  def print_help
219
276
  puts <<~HELP
220
- rb-portless #{Portless::VERSION} — named .localhost URLs for local dev
277
+ #{Colors.bold("rb-portless #{Portless::VERSION}")} — named .localhost URLs for local dev
221
278
 
222
- Usage:
279
+ #{Colors.blue('Usage:')}
223
280
  rb-portless run <command> run a dev server through the proxy
224
281
  rb-portless run run the `apps` map, or the dev script
282
+ rb-portless <name> <command> run <command> at https://<name>.localhost
283
+ rb-portless get <name> print a service's URL (--no-worktree)
284
+ rb-portless alias <name> <port> static route for an unmanaged service
225
285
  rb-portless proxy start|stop manage the proxy daemon
226
286
  rb-portless trust trust the local CA (HTTPS)
227
287
  rb-portless hosts sync|clean manage /etc/hosts (Safari fallback)
228
288
  rb-portless list show active routes
229
289
  rb-portless doctor diagnose setup
230
- rb-portless clean | prune tear down / reap orphans
290
+ rb-portless clean | prune tear down / reap orphans (--force kills)
231
291
  rb-portless service install bind the privileged port at boot
232
292
 
233
- run flags:
293
+ #{Colors.blue('run flags:')}
294
+ --name <name> override the inferred hostname
295
+ --app-port <n> fix the backend port (else auto)
296
+ --force take over a route owned by another run
234
297
  --lan [--ip <addr>] also serve on the LAN (<name>.local)
235
298
  --ngrok share publicly via ngrok
236
299
  --tailscale | --funnel share via your tailnet / Funnel
237
300
 
301
+ In a linked git worktree on a non-default branch, the branch name is
302
+ prepended as a subdomain (auth.<name>.localhost). PORTLESS=0 runs the
303
+ command directly without the proxy.
304
+
238
305
  HTTPS is the default (https://<name>.localhost). Config: portless.json.
239
306
  HELP
240
307
  end
308
+
309
+ # Per-command help (`rb-portless <cmd> --help`, or a bare subcommand). Kept
310
+ # as plain data so the renderer can colourise it for the terminal.
311
+ HELP = {
312
+ "run" => { summary: "Run a dev server through the proxy.",
313
+ usage: [ "run <command>", "run (the apps map, or bin/dev / bin/rails server)" ],
314
+ flags: [ [ "--name <name>", "override the inferred hostname" ],
315
+ [ "--app-port <n>", "fix the backend port (else auto)" ],
316
+ [ "--force", "take over a route held by another run" ],
317
+ [ "--lan [--ip <addr>]", "also serve on the LAN" ],
318
+ [ "--ngrok | --tailscale | --funnel", "share publicly" ] ],
319
+ example: "rb-portless run bin/dev" },
320
+ "get" => { summary: "Print a service's URL (for scripts / env vars).",
321
+ usage: [ "get <name> [--no-worktree]" ],
322
+ flags: [ [ "--no-worktree", "skip the git-worktree subdomain prefix" ] ],
323
+ example: "DB_URL=$(rb-portless get db)" },
324
+ "alias" => { summary: "Static route for a service portless doesn't manage.",
325
+ usage: [ "alias <name> <port> [--force]", "alias --remove <name>" ],
326
+ flags: [ [ "--force", "overwrite an existing route" ] ],
327
+ example: "rb-portless alias postgres 5432 # -> https://postgres.localhost" },
328
+ "proxy" => { summary: "Manage the proxy daemon.",
329
+ usage: [ "proxy start [--no-tls] [--port <n>]", "proxy stop" ] },
330
+ "trust" => { summary: "Trust the local CA so HTTPS works without warnings.",
331
+ usage: [ "trust" ] },
332
+ "hosts" => { summary: "Manage the /etc/hosts block (Safari / non-.localhost TLDs).",
333
+ usage: [ "hosts sync", "hosts clean" ] },
334
+ "list" => { summary: "Show active routes.", usage: [ "list" ] },
335
+ "doctor" => { summary: "Diagnose the setup (read-only).", usage: [ "doctor" ] },
336
+ "clean" => { summary: "Remove all state: stop proxy, untrust CA, clear routes & hosts.",
337
+ usage: [ "clean" ] },
338
+ "prune" => { summary: "Reap routes whose owner died; kill the orphaned dev server.",
339
+ usage: [ "prune [--force]" ],
340
+ flags: [ [ "--force", "SIGKILL the orphan instead of SIGTERM" ] ] },
341
+ "service" => { summary: "Install the proxy as an OS startup service (binds 443 at boot).",
342
+ usage: [ "service install [--no-tls] [--port <n>]", "service uninstall", "service status" ] }
343
+ }.freeze
344
+
345
+ def command_help(name)
346
+ h = HELP.fetch(name)
347
+ out = [ "", " #{Colors.bold("rb-portless #{name}")} — #{h[:summary]}", "", " #{Colors.blue('Usage:')}" ]
348
+ h[:usage].each { |u| out << " #{Colors.cyan("rb-portless #{u}")}" }
349
+ if h[:flags]
350
+ out << "" << " #{Colors.blue('Flags:')}"
351
+ h[:flags].each { |flag, desc| out << " #{Colors.cyan(flag.ljust(34))} #{desc}" }
352
+ end
353
+ out << "" << " #{Colors.dim("Example: #{h[:example]}")}" if h[:example]
354
+ out << ""
355
+ puts out.join("\n")
356
+ end
241
357
  end
242
358
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # ANSI colours for CLI output. A no-op when the target stream isn't a TTY (so
5
+ # piped/redirected output stays clean) or when NO_COLOR is set. Mirrors
6
+ # portless's `colors` helper.
7
+ module Colors
8
+ extend self
9
+
10
+ CODES = { bold: 1, dim: 90, gray: 90, red: 31, green: 32, yellow: 33, blue: 34, cyan: 36 }.freeze
11
+
12
+ CODES.each_key do |name|
13
+ define_method(name) { |str, io: $stdout| paint(name, str, io: io) }
14
+ end
15
+
16
+ def paint(name, str, io: $stdout)
17
+ return str.to_s unless enabled?(io)
18
+
19
+ "\e[#{CODES.fetch(name)}m#{str}\e[0m"
20
+ end
21
+
22
+ def enabled?(io)
23
+ ENV["NO_COLOR"].to_s.empty? && io.respond_to?(:tty?) && io.tty?
24
+ end
25
+ end
26
+ end
@@ -24,9 +24,22 @@ module Portless
24
24
  end
25
25
 
26
26
  # The full hostname an app registers (e.g. "shirabe.org.localhost" when tld is
27
- # set to that, or "<name>.localhost" by default).
28
- def hostname
29
- tld.split(".").include?(name) ? tld : "#{name}.#{tld}"
27
+ # set to that, or "<name>.localhost" by default). `name` overrides the base
28
+ # (used by --name); `worktree:` prepends the git-worktree branch prefix so a
29
+ # linked worktree gets its own URL (`auth.<name>.localhost`).
30
+ def hostname(name = nil, worktree: true)
31
+ base = name ? sanitize_label(name) : @name
32
+ host = tld.split(".").include?(base) ? tld : "#{base}.#{tld}"
33
+ prefix = worktree ? worktree_prefix : nil
34
+ prefix ? "#{prefix}.#{host}" : host
35
+ end
36
+
37
+ # The git-worktree subdomain prefix for this project dir (nil if none).
38
+ # Memoized — it shells out to git.
39
+ def worktree_prefix
40
+ return @worktree_prefix if defined?(@worktree_prefix)
41
+
42
+ @worktree_prefix = Worktree.prefix(@dir)
30
43
  end
31
44
 
32
45
  # Real/reserved TLDs that can intercept live traffic or clash with mDNS.
@@ -35,7 +35,8 @@ module Portless
35
35
 
36
36
  def start_app(name, command, proxy_port)
37
37
  port = FreePort.find
38
- hostname = "#{name}.#{@config.tld}"
38
+ prefix = @config.worktree_prefix
39
+ hostname = "#{prefix ? "#{prefix}.#{name}" : name}.#{@config.tld}"
39
40
  url = display_url(hostname, proxy_port)
40
41
  @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
41
42
  # A bare command string runs through the shell (handles "bin/rails server").
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # Best-effort "who is listening on this TCP port" — used by `prune` to reap an
5
+ # orphaned dev server whose owning CLI process already died but whose backend
6
+ # port is still held. Shells out to lsof (present on macOS + most Linux);
7
+ # silently no-ops when it's unavailable. Mirrors portless's port-kill in prune.
8
+ module PortOwner
9
+ module_function
10
+
11
+ def listeners(port)
12
+ return [] unless Portless.which("lsof")
13
+
14
+ out = IO.popen([ "lsof", "-ti", "tcp:#{Integer(port)}", "-sTCP:LISTEN" ], err: File::NULL, &:read)
15
+ out.split.filter_map { |p| Integer(p, exception: false) }
16
+ rescue StandardError
17
+ []
18
+ end
19
+
20
+ # Signal every listener on `port` (TERM, or KILL with force). Never signals
21
+ # ourselves. Returns how many processes were signalled.
22
+ def kill(port, force: false)
23
+ sig = force ? "KILL" : "TERM"
24
+ listeners(port).count do |pid|
25
+ next false if pid == Process.pid
26
+
27
+ Process.kill(sig, pid)
28
+ true
29
+ rescue StandardError
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
@@ -9,7 +9,7 @@ module Portless
9
9
  # directory mutex (atomic mkdir), and the proxy watches it. Dead-pid entries
10
10
  # are reaped on every load. Mirrors portless's RouteStore.
11
11
  class RouteStore
12
- Route = Struct.new(:hostname, :port, :pid, keyword_init: true) do
12
+ Route = Struct.new(:hostname, :port, :pid, :tailscale, :ngrok, keyword_init: true) do
13
13
  def alias? = pid.to_i.zero? # pid 0 = static alias (never reaped)
14
14
  end
15
15
 
@@ -22,39 +22,58 @@ module Portless
22
22
  end
23
23
 
24
24
  def routes
25
- load.map { |h| Route.new(hostname: h["hostname"], port: h["port"], pid: h["pid"]) }
25
+ load.map do |h|
26
+ Route.new(hostname: h["hostname"], port: h["port"], pid: h["pid"],
27
+ tailscale: h["tailscale"], ngrok: h["ngrok"])
28
+ end
26
29
  end
27
30
 
28
31
  # Register (or replace) a route. Conflicts with a *live* different owner raise
29
- # unless force, which SIGTERMs the incumbent. Alias routes use pid 0.
30
- def add(hostname:, port:, pid:, force: false)
32
+ # unless force, which SIGTERMs the incumbent. Alias routes use pid 0. Public
33
+ # share URLs (tailscale/ngrok), when present, are recorded so `list` can show
34
+ # them while the run is active.
35
+ def add(hostname:, port:, pid:, force: false, tailscale: nil, ngrok: nil)
31
36
  with_lock do
32
37
  all = load.reject { |r| dead?(r["pid"]) }
33
38
  existing = all.find { |r| r["hostname"] == hostname }
34
39
  if existing && existing["pid"].to_i != pid.to_i && !dead?(existing["pid"])
35
- raise Error, "#{hostname} is already served by pid #{existing['pid']}" unless force
40
+ unless force
41
+ raise RouteConflictError,
42
+ "#{hostname} is already served by pid #{existing['pid']} — pass --force to take it over"
43
+ end
36
44
  terminate(existing["pid"])
37
45
  end
38
46
  all.reject! { |r| r["hostname"] == hostname }
39
- all << { "hostname" => hostname, "port" => port, "pid" => pid }
47
+ entry = { "hostname" => hostname, "port" => port, "pid" => pid }
48
+ entry["tailscale"] = tailscale if tailscale
49
+ entry["ngrok"] = ngrok if ngrok
50
+ all << entry
40
51
  write(all)
41
52
  end
42
53
  end
43
54
 
44
55
  # Remove a route only if still owned by `owner_pid` (so a force-replaced
45
- # predecessor doesn't delete the successor's route on its way out).
56
+ # predecessor doesn't delete the successor's route on its way out). Returns
57
+ # whether anything was actually removed.
46
58
  def remove(hostname, owner_pid: nil)
47
59
  with_lock do
48
- all = load
49
- all.reject! do |r|
60
+ before = load
61
+ after = before.reject do |r|
50
62
  r["hostname"] == hostname && (owner_pid.nil? || r["pid"].to_i == owner_pid.to_i)
51
63
  end
52
- write(all)
64
+ write(after)
65
+ after.size < before.size
53
66
  end
54
67
  end
55
68
 
69
+ # Drop dead-pid routes and return them (so callers can reap the orphaned
70
+ # process still holding the backend port). Alias routes (pid 0) are kept.
56
71
  def prune
57
- with_lock { write(load.reject { |r| dead?(r["pid"]) }) }
72
+ with_lock do
73
+ dead, alive = load.partition { |r| dead?(r["pid"]) }
74
+ write(alive)
75
+ dead.map { |r| Route.new(hostname: r["hostname"], port: r["port"], pid: r["pid"]) }
76
+ end
58
77
  end
59
78
 
60
79
  private
@@ -12,26 +12,30 @@ module Portless
12
12
  @command = Array(command)
13
13
  @config = config
14
14
  @route_store = route_store
15
- @options = options # :lan, :ip, :ngrok, :tailscale, :funnel
15
+ @options = options # :lan, :ip, :ngrok, :tailscale, :funnel, :name, :force, :app_port
16
16
  end
17
17
 
18
18
  def run
19
19
  command = resolved_command
20
20
  raise Error, "nothing to run — pass a command, e.g. rb-portless run bin/dev" if command.empty?
21
21
 
22
- port = @config.app_port&.to_i || FreePort.find
22
+ # PORTLESS=0|false|skip run the command straight through, no proxy/route.
23
+ return exec(*command) if Portless.skip_proxy?
24
+
25
+ port = @options[:app_port] || @config.app_port&.to_i || FreePort.find
23
26
  command = Frameworks.inject(command, port) # --port/--host for vite/astro/etc.
24
- hostname = @config.hostname
27
+ hostname = @config.hostname(@options[:name])
25
28
 
26
29
  warn "rb-portless: #{@config.tld_warning}" if @config.tld_warning
27
30
  ensure_trusted
28
31
  proxy_port = Daemon.ensure_running(tls: @config.tls)
29
- @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: true)
32
+ @route_store.add(hostname: hostname, port: port, pid: Process.pid, force: @options[:force])
30
33
 
31
34
  url = display_url(hostname, proxy_port)
32
35
  rows = [ [ "Local", url, :cyan ] ]
33
36
  rows.concat(lan_rows(port, proxy_port))
34
37
  rows.concat(share_rows(hostname, port))
38
+ record_share_urls(hostname, port) # so `rb-portless list` shows the public URLs
35
39
  Banner.app(rows: rows, backend_port: port)
36
40
 
37
41
  status = supervise(command, port, url)
@@ -53,7 +57,7 @@ module Portless
53
57
  return [ [ "Network", "no LAN IPv4 found", :dim ] ] unless ip
54
58
 
55
59
  @lan_host = "#{@config.name}.local"
56
- @route_store.add(hostname: @lan_host, port: backend_port, pid: Process.pid, force: true)
60
+ @route_store.add(hostname: @lan_host, port: backend_port, pid: Process.pid, force: @options[:force])
57
61
  @mdns_pid = Mdns.publish(@lan_host, ip)
58
62
  warn "rb-portless: trust #{State.ca_cert} on the device for HTTPS over the LAN" if @config.tls
59
63
  [ [ "Network", display_url(@lan_host, proxy_port), :green ] ]
@@ -72,6 +76,15 @@ module Portless
72
76
  rows
73
77
  end
74
78
 
79
+ # Re-register the route with the public share URLs once tunnels are up, so a
80
+ # `list` from another terminal surfaces them while this run is active.
81
+ def record_share_urls(hostname, port)
82
+ return unless @ngrok || @tailscale
83
+
84
+ @route_store.add(hostname: hostname, port: port, pid: Process.pid,
85
+ tailscale: @tailscale&.dig(:url), ngrok: @ngrok&.dig(:url))
86
+ end
87
+
75
88
  def teardown
76
89
  Mdns.unpublish(@mdns_pid)
77
90
  @route_store.remove(@lan_host, owner_pid: Process.pid) if @lan_host
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.3.1"
4
+ VERSION = "0.4.0.dev.20260630.0d72797"
5
5
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Portless
4
+ # Git-worktree-aware hostname prefix. In a *linked* worktree (one created via
5
+ # `git worktree add`, not the root checkout) sitting on a non-default branch,
6
+ # the branch's last path segment becomes a subdomain prefix so each worktree
7
+ # gets its own URL — `feature/auth` → `auth.<name>.localhost`. Returns nil in
8
+ # the root worktree, on main/master, on detached HEAD, or outside git.
9
+ # Mirrors portless's detectWorktreePrefix (git-CLI path).
10
+ module Worktree
11
+ module_function
12
+
13
+ DEFAULT_BRANCHES = %w[main master].freeze
14
+
15
+ def prefix(dir = Dir.pwd)
16
+ Portless.which("git") ? via_cli(dir) : via_filesystem(dir)
17
+ rescue StandardError
18
+ nil
19
+ end
20
+
21
+ # Authoritative path: ask git directly.
22
+ def via_cli(dir)
23
+ list = git(dir, "worktree", "list", "--porcelain")
24
+ return nil if list.nil?
25
+ return nil if list.lines.count { |l| l.start_with?("worktree ") } <= 1
26
+
27
+ # Only a *linked* worktree gets a prefix: there --git-dir differs from
28
+ # --git-common-dir; in the root worktree they're the same path.
29
+ git_dir = git(dir, "rev-parse", "--git-dir")
30
+ common = git(dir, "rev-parse", "--git-common-dir")
31
+ return nil if git_dir.nil? || common.nil?
32
+ return nil if File.expand_path(git_dir, dir) == File.expand_path(common, dir)
33
+
34
+ branch_to_prefix(git(dir, "rev-parse", "--abbrev-ref", "HEAD"))
35
+ end
36
+
37
+ # Fallback when the git binary isn't available: walk up for a `.git` *file*
38
+ # (worktrees use a file, not a dir) whose gitdir points into /worktrees/, and
39
+ # read the branch from that gitdir's HEAD. Submodules (/modules/) are ignored.
40
+ def via_filesystem(dir)
41
+ current = File.expand_path(dir)
42
+ loop do
43
+ git_path = File.join(current, ".git")
44
+ return nil if File.directory?(git_path) # root checkout, not a worktree
45
+
46
+ if File.file?(git_path)
47
+ gitdir = File.read(git_path)[/^gitdir:\s*(.+)$/, 1]
48
+ return nil unless gitdir&.match?(%r{[/\\]worktrees[/\\][^/\\]+$})
49
+
50
+ head = File.read(File.join(File.expand_path(gitdir, current), "HEAD"))
51
+ return branch_to_prefix(head[%r{^ref: refs/heads/(.+)$}, 1])
52
+ end
53
+
54
+ parent = File.dirname(current)
55
+ return nil if parent == current
56
+
57
+ current = parent
58
+ end
59
+ end
60
+
61
+ # Last `/`-segment of the branch, sanitized; nil for default/detached HEAD.
62
+ def branch_to_prefix(branch)
63
+ return nil if branch.nil? || branch.empty? || branch == "HEAD"
64
+ return nil if DEFAULT_BRANCHES.include?(branch)
65
+
66
+ label = branch.split("/").last.to_s.downcase.gsub(/[^a-z0-9-]+/, "-").gsub(/\A-+|-+\z/, "")
67
+ label.empty? ? nil : label
68
+ end
69
+
70
+ def git(dir, *args)
71
+ out = IO.popen([ "git", "-C", dir, *args ], err: File::NULL, &:read)
72
+ $?.success? ? out.strip : nil
73
+ rescue StandardError
74
+ nil
75
+ end
76
+ end
77
+ end
data/lib/rb-portless.rb CHANGED
@@ -4,9 +4,12 @@
4
4
  # of Vercel's portless). See AGENTS.md for the architecture map.
5
5
  require_relative "portless/version"
6
6
  require_relative "portless/constants"
7
+ require_relative "portless/colors"
7
8
  require_relative "portless/state"
9
+ require_relative "portless/worktree"
8
10
  require_relative "portless/config"
9
11
  require_relative "portless/free_port"
12
+ require_relative "portless/port_owner"
10
13
  require_relative "portless/route_store"
11
14
  require_relative "portless/health"
12
15
  require_relative "portless/privilege"
@@ -33,6 +36,14 @@ module Portless
33
36
  # Raised when a privileged action can't run non-interactively (no TTY / CI).
34
37
  class NonInteractiveError < Error; end
35
38
 
39
+ # Raised when a hostname is already served by a different live owner and the
40
+ # caller didn't pass --force. Mirrors portless's RouteConflictError.
41
+ class RouteConflictError < Error; end
42
+
43
+ # `PORTLESS=0|false|skip` runs the command straight through, no proxy/route —
44
+ # the bypass portless documents for CI or one-off plain runs.
45
+ def self.skip_proxy? = %w[0 false skip].include?(ENV["PORTLESS"].to_s.downcase)
46
+
36
47
  # Is an executable on PATH? (For optional external tools: dns-sd, ngrok, …)
37
48
  def self.which(bin)
38
49
  ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, bin)) }
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.3.1
4
+ version: 0.4.0.dev.20260630.0d72797
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso
@@ -59,6 +59,7 @@ files:
59
59
  - lib/portless/banner.rb
60
60
  - lib/portless/certs.rb
61
61
  - lib/portless/cli.rb
62
+ - lib/portless/colors.rb
62
63
  - lib/portless/config.rb
63
64
  - lib/portless/constants.rb
64
65
  - lib/portless/daemon.rb
@@ -69,6 +70,7 @@ files:
69
70
  - lib/portless/lan_ip.rb
70
71
  - lib/portless/mdns.rb
71
72
  - lib/portless/multi.rb
73
+ - lib/portless/port_owner.rb
72
74
  - lib/portless/privilege.rb
73
75
  - lib/portless/proxy.rb
74
76
  - lib/portless/rails.rb
@@ -82,6 +84,7 @@ files:
82
84
  - lib/portless/state.rb
83
85
  - lib/portless/trust.rb
84
86
  - lib/portless/version.rb
87
+ - lib/portless/worktree.rb
85
88
  - lib/rb-portless.rb
86
89
  homepage: https://github.com/davafons/rb-portless
87
90
  licenses: