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 +4 -4
- data/README.md +23 -4
- data/lib/portless/banner.rb +5 -7
- data/lib/portless/cli.rb +181 -65
- data/lib/portless/colors.rb +26 -0
- data/lib/portless/config.rb +16 -3
- data/lib/portless/multi.rb +2 -1
- data/lib/portless/port_owner.rb +34 -0
- data/lib/portless/route_store.rb +30 -11
- data/lib/portless/runner.rb +18 -5
- data/lib/portless/version.rb +1 -1
- data/lib/portless/worktree.rb +77 -0
- data/lib/rb-portless.rb +11 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8938cad2e70c50e9e39f2078fe9a8590b6f0324a136cd1da9f5a9258a0604c4f
|
|
4
|
+
data.tar.gz: 5fe88139d39e877e53b0adf46a87bd20ee361b23f3f858ca74d2039df3ee0562
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
119
|
-
| `get <name
|
|
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
|
data/lib/portless/banner.rb
CHANGED
|
@@ -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 (
|
|
36
|
-
def
|
|
37
|
-
def
|
|
38
|
-
def
|
|
39
|
-
def
|
|
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
|
-
|
|
20
|
-
return
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
33
|
+
warn error_line(e.message)
|
|
27
34
|
exit 2
|
|
28
35
|
rescue Portless::Error => e
|
|
29
|
-
warn
|
|
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
|
-
#
|
|
46
|
-
#
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
when "--
|
|
55
|
-
when "--
|
|
56
|
-
when "--
|
|
57
|
-
when "--
|
|
58
|
-
when "--"
|
|
59
|
-
|
|
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,
|
|
88
|
+
[ options, command ]
|
|
63
89
|
end
|
|
64
90
|
|
|
65
91
|
def cmd_proxy(args)
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
+
info "CA already trusted"
|
|
81
104
|
else
|
|
82
105
|
Trust.install!
|
|
83
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
127
|
+
ok "synced #{hostnames.size} host(s) to #{Hosts.file}"
|
|
102
128
|
when "clean"
|
|
103
129
|
with_hosts_write([ "hosts", "clean" ]) { Hosts.clean }
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
175
|
+
ok "removed all state"
|
|
144
176
|
end
|
|
145
177
|
|
|
146
|
-
def cmd_doctor(
|
|
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? },
|
|
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,
|
|
158
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
data/lib/portless/config.rb
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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.
|
data/lib/portless/multi.rb
CHANGED
|
@@ -35,7 +35,8 @@ module Portless
|
|
|
35
35
|
|
|
36
36
|
def start_app(name, command, proxy_port)
|
|
37
37
|
port = FreePort.find
|
|
38
|
-
|
|
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
|
data/lib/portless/route_store.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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(
|
|
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
|
|
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
|
data/lib/portless/runner.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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
|
data/lib/portless/version.rb
CHANGED
|
@@ -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.
|
|
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:
|