ask-local 0.1.0

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.
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ class CLI
6
+ # Boot commands: bare `ask-local`, `run`, and `<name> <cmd>`.
7
+ # Owns managed/run boot orchestration, Procfile resolution, and the
8
+ # foreground supervision loop.
9
+ module BootCommand
10
+ # Frameworks that ignore $PORT get explicit flags (portless lesson:
11
+ # Vite/Astro/Expo need --port; Jekyll/Middleman/Bridgetown do too).
12
+ # Only injects when the user hasn't already set a port.
13
+ PORT_IGNORING = %w[jekyll middleman bridgetown].freeze
14
+
15
+ module_function
16
+
17
+ def run_inferred(ctx, args)
18
+ opts = ctx.parse_flags(args, %i[name service variant tld branch proc force])
19
+ resolved = Resolver.resolve(Dir.pwd, name: opts[:name],
20
+ service: opts[:service], variant: opts[:variant],
21
+ tlds: opts[:tld], use_branch: opts[:branch])
22
+ hostnames = Resolver.hostnames(resolved)
23
+ ensure_proxy!(ctx)
24
+ framework = Framework.detect(Dir.pwd)
25
+ runner = Runner.new(store: ctx.store)
26
+ if Framework.managed?(framework) && opts[:rest].empty?
27
+ boot_managed(ctx, runner, resolved, hostnames, opts)
28
+ else
29
+ command = opts[:rest].empty? ? default_command(framework, opts[:proc]) : opts[:rest]
30
+ boot_run(ctx, runner, resolved, hostnames, command, opts)
31
+ end
32
+ end
33
+
34
+ def run_explicit(ctx, args)
35
+ opts = ctx.parse_flags(args, %i[name service variant tld branch proc force])
36
+ resolved = Resolver.resolve(Dir.pwd, name: opts[:name],
37
+ service: opts[:service], variant: opts[:variant],
38
+ tlds: opts[:tld], use_branch: opts[:branch])
39
+ hostnames = Resolver.hostnames(resolved)
40
+ ensure_proxy!(ctx)
41
+ framework = Framework.detect(Dir.pwd)
42
+ command = opts[:rest].empty? ? default_command(framework, opts[:proc]) : opts[:rest]
43
+ boot_run(ctx, Runner.new(store: ctx.store), resolved, hostnames, command, opts)
44
+ end
45
+
46
+ def run_named(ctx, name, args)
47
+ opts = ctx.parse_flags(args, %i[force app_port])
48
+ resolved = Resolver.resolve(Dir.pwd, name: name)
49
+ hostnames = Resolver.hostnames(resolved)
50
+ ensure_proxy!(ctx)
51
+ if opts[:rest].empty?
52
+ $stderr.puts "Error: no command given for #{name}."
53
+ exit 1
54
+ end
55
+ boot_run(ctx, Runner.new(store: ctx.store), resolved, hostnames, opts[:rest], opts)
56
+ end
57
+
58
+ def boot_managed(ctx, runner, resolved, hostnames, opts)
59
+ primary = hostnames.first
60
+ url = Hostname.url(primary, port: ctx.proxy_port, tls: ctx.proxy_tls)
61
+ puts "ask-local"
62
+ puts "-- #{hostnames.join(", ")}"
63
+ app = runner.boot_managed(name: resolved.app, hostname: primary,
64
+ url: url, dir: Dir.pwd, force: opts[:force])
65
+ register_all(ctx, hostnames, app, force: opts[:force], spec: { "dir" => File.expand_path(Dir.pwd) })
66
+ puts "\n -> #{url}\n"
67
+ ctx.report_unresolved(hostnames)
68
+ trap_cleanup(ctx, hostnames)
69
+ supervise_backend(ctx, hostnames, app)
70
+ end
71
+
72
+ def boot_run(ctx, runner, resolved, hostnames, command, opts)
73
+ primary = hostnames.first
74
+ url = Hostname.url(primary, port: ctx.proxy_port, tls: ctx.proxy_tls)
75
+ puts "ask-local"
76
+ puts "-- #{hostnames.join(", ")}"
77
+ port = opts[:app_port] || Ports.find_free
78
+ command = inject_port_flags(command, port)
79
+ app = runner.boot_run(name: resolved.app, hostname: primary, url: url,
80
+ dir: Dir.pwd, command: command, port: port, force: opts[:force])
81
+ register_all(ctx, hostnames, app, force: opts[:force])
82
+ puts "\n -> #{url}\n"
83
+ puts "Running: PORT=#{app.target.split(":").last} ASK_LOCAL_URL=#{url} #{command.join(" ")}"
84
+ ctx.report_unresolved(hostnames)
85
+ trap_cleanup(ctx, hostnames)
86
+ supervise_backend(ctx, hostnames, app)
87
+ end
88
+
89
+ # Foreground loop: exit (cleaning up) when the backend dies, so a
90
+ # crashed app never leaves a stale route behind. Backends are
91
+ # spawned detached (so Ctrl+C in the CLI never SIGINTs the app),
92
+ # which rules out Process.wait — detached children are already
93
+ # reaped. Poll liveness at 2Hz: prompt enough for crash cleanup
94
+ # without spinning.
95
+ def supervise_backend(ctx, hostnames, app)
96
+ until !ProxyControl.pid_alive?(app.pid)
97
+ sleep 0.5
98
+ end
99
+ puts "\nBackend exited — cleaning up."
100
+ cleanup_routes(ctx, hostnames)
101
+ exit 0
102
+ end
103
+
104
+ def inject_port_flags(command, port)
105
+ return command if command.empty?
106
+ return command if command.any? { |a| a.match?(/\A(-p|--port)(=|\z)/) }
107
+ # A literal $PORT already present (e.g. Procfile `web: x --port $PORT`,
108
+ # foreman --port passthrough): injecting again would double-set it.
109
+ return command if command.any? { |a| a.include?("$PORT") }
110
+
111
+ bin = File.basename(command.first.to_s)
112
+ needs_flags = PORT_IGNORING.include?(bin) ||
113
+ (command.length > 2 && PORT_IGNORING.include?(File.basename(command[2].to_s)))
114
+ return command unless needs_flags
115
+
116
+ flags = ["--port", port.to_s, "--host", "127.0.0.1"]
117
+ flags.concat(jekyll_livereload_flags(port)) if jekyll_command?(command)
118
+ command + flags
119
+ end
120
+
121
+ # Jekyll's livereload runs its own server on a second port
122
+ # (default 35729) serving the livereload.js WebSocket. It cannot go
123
+ # through the proxy (one route = one backend), so pin it next to the
124
+ # main port and document the direct URL. Only when the app enables
125
+ # livereload in _config.yml; explicit user flags always win.
126
+ JEKYLL_LIVERELOAD_DEFAULT_PORT = 35_729
127
+
128
+ def jekyll_command?(command)
129
+ command.any? { |a| File.basename(a.to_s) == "jekyll" }
130
+ end
131
+
132
+ def jekyll_livereload_flags(port)
133
+ return [] unless File.file?("_config.yml")
134
+ return [] unless File.read("_config.yml").match?(/^\s*livereload:\s*true/i)
135
+ return [] if port + 1 > Ports::MAX_PORT
136
+
137
+ ["--livereload-port", (port + 1).to_s]
138
+ rescue SystemCallError
139
+ []
140
+ end
141
+
142
+ def default_command(framework, proc_name = nil)
143
+ case framework
144
+ when :procfile
145
+ procfile_command(proc_name) || raise(Error,
146
+ proc_name ? "Procfile has no '#{proc_name}' process, or its line is " \
147
+ "compound (&&, ||, |, ;) — ask-local cannot inject PORT safely. " \
148
+ "Run explicitly instead: ask-local run -- <command>" :
149
+ "Procfile.dev first line is compound (&&, ||, |, ;) or unreadable — " \
150
+ "ask-local cannot inject PORT safely. Run explicitly instead: " \
151
+ "ask-local run -- <command>")
152
+ when :jekyll then %w[bundle exec jekyll serve]
153
+ when :bridgetown then %w[bin/bridgetown start]
154
+ when :middleman then %w[bundle exec middleman server]
155
+ else raise(Error, "No command given and no bootable app detected. Usage: ask-local run -- <command>")
156
+ end
157
+ end
158
+
159
+ # First process by default; --proc <name> picks a specific line
160
+ # (the overmind `-P web` convention teams already use).
161
+ #
162
+ # Trust boundary: the Procfile line is repo code, so `sh -c` is
163
+ # safe here the way it would not be for user-supplied input.
164
+ def procfile_command(process = nil)
165
+ path = File.file?("Procfile.dev") ? "Procfile.dev" : "Procfile"
166
+ lines = File.readlines(path).map(&:strip).reject { |l| l.empty? || l.start_with?("#") }
167
+ chosen =
168
+ if process
169
+ lines.find { |l| l.start_with?("#{process}:") }
170
+ else
171
+ lines.first
172
+ end
173
+ return nil unless chosen
174
+
175
+ cmd = chosen.split(":", 2).last.to_s.strip
176
+ # Refuse compound lines we cannot safely inject PORT into.
177
+ return nil if cmd.match?(/&&|\|\||[|;]/)
178
+
179
+ ["sh", "-c", cmd]
180
+ end
181
+
182
+ def trap_cleanup(ctx, hostnames)
183
+ %w[INT TERM].each do |sig|
184
+ trap(sig) do
185
+ cleanup_routes(ctx, hostnames)
186
+ exit 0
187
+ end
188
+ end
189
+ end
190
+
191
+ # Primary hostname is registered by the runner; secondaries (extra
192
+ # TLDs) share the same backend. Every hostname gets a backend sidecar
193
+ # so `ask-local stop` finds the process from any of them, and the
194
+ # daemon supervisor needs the spec on every hostname.
195
+ def register_all(ctx, hostnames, app, force:, spec: nil)
196
+ hostnames[1..].each do |h|
197
+ ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
198
+ force: force, spec: spec)
199
+ write_backend_sidecar(ctx, h, app.pid)
200
+ end
201
+ end
202
+
203
+ def write_backend_sidecar(ctx, hostname, pid)
204
+ ctx.store.ensure_dir
205
+ File.write(File.join(ctx.store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
206
+ rescue SystemCallError
207
+ nil
208
+ end
209
+
210
+ # Foreground boot semantics (portless model): leaving = routes gone
211
+ # AND backend stopped. No orphans on Ctrl+C, TERM, or clean exit.
212
+ def cleanup_routes(ctx, hostnames)
213
+ hostnames.each do |h|
214
+ begin
215
+ entry = ctx.store.find(h)
216
+ pid = ctx.backend_pid_for(entry) if entry
217
+ if pid && ProxyControl.pid_alive?(pid)
218
+ Process.kill("TERM", pid) rescue nil
219
+ end
220
+ ctx.store.remove_route(h, owner_pid: Process.pid) rescue nil
221
+ FileUtils.rm_f(File.join(ctx.store.dir, "backend-#{h}.pid"))
222
+ rescue StandardError
223
+ nil
224
+ end
225
+ end
226
+ end
227
+
228
+ def ensure_proxy!(ctx)
229
+ port = ctx.proxy_port
230
+ tls = ctx.proxy_tls
231
+ if ProxyControl.listening?(port)
232
+ if ProxyControl.ours?(port, tls: tls)
233
+ return
234
+ end
235
+
236
+ $stderr.puts "Error: port #{port} is in use by another process."
237
+ end
238
+
239
+ privileged = port < 1024 && !ProxyControl.root?
240
+ if privileged && !ctx.interactive?
241
+ $stderr.puts "Proxy is not running and no TTY is available for sudo."
242
+ $stderr.puts "Start it in a terminal: ask-local proxy start"
243
+ $stderr.puts "Or use an unprivileged port: ask-local proxy start -p 1355"
244
+ exit 1
245
+ end
246
+ puts "Starting proxy#{privileged ? " (will prompt for sudo to bind port #{port})" : ""}..."
247
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
248
+ rescue Errno::EACCES
249
+ $stderr.puts "Error: could not bind port #{port}. Try: ask-local proxy start -p 1355"
250
+ exit 1
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ class CLI
6
+ # Shared state and helpers for command objects. Every command gets a
7
+ # Context instead of reaching into CLI privates, so cli.rb stays a
8
+ # thin dispatcher.
9
+ class Context
10
+ def store
11
+ @store ||= RouteStore.new(Certs.state_dir,
12
+ on_warning: ->(m) { warn m })
13
+ end
14
+
15
+ def interactive?
16
+ $stdin.tty? && ENV["CI"].nil?
17
+ end
18
+
19
+ def proxy_port
20
+ ProxyControl.proxy_port(store) || ProxyControl.default_port(proxy_tls)
21
+ end
22
+
23
+ def proxy_tls
24
+ ProxyControl.proxy_tls(store)
25
+ end
26
+
27
+ def report_unresolved(hostnames)
28
+ missing = Hosts.unresolved(hostnames)
29
+ return if missing.empty?
30
+
31
+ warn "Warning: #{missing.join(", ")} will not resolve. Run: ask-local hosts sync"
32
+ end
33
+
34
+ def wait_for_exit(pid, timeout:)
35
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
36
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
37
+ return true unless ProxyControl.pid_alive?(pid)
38
+
39
+ sleep 0.25
40
+ end
41
+ !ProxyControl.pid_alive?(pid)
42
+ end
43
+
44
+ def backend_pid_for(entry)
45
+ return nil unless entry
46
+
47
+ sidecar = File.join(store.dir, "backend-#{entry["hostname"]}.pid")
48
+ return nil unless File.file?(sidecar)
49
+
50
+ pid = File.read(sidecar).strip.to_i
51
+ pid.positive? ? pid : nil
52
+ rescue SystemCallError, ArgumentError
53
+ nil
54
+ end
55
+
56
+ def backend_alive?(entry)
57
+ case entry["kind"]
58
+ when "socket"
59
+ File.socket?(entry["target"].to_s)
60
+ when "tcp"
61
+ host, port = entry["target"].to_s.split(":", 2)
62
+ begin
63
+ TCPSocket.new(host, port.to_i).close
64
+ true
65
+ rescue SystemCallError
66
+ false
67
+ end
68
+ else
69
+ false
70
+ end
71
+ end
72
+
73
+ def parse_flags(args, known)
74
+ opts = { rest: [] }
75
+ i = 0
76
+ rest_start = nil
77
+ while i < args.length
78
+ arg = args[i]
79
+ if arg == "--"
80
+ rest_start = i + 1
81
+ break
82
+ elsif arg.start_with?("--")
83
+ key = arg.sub(/\A--/, "").tr("-", "_").to_sym
84
+ if known.include?(key)
85
+ if %i[branch force].include?(key)
86
+ opts[key] = true
87
+ i += 1
88
+ else
89
+ opts[key] = args.fetch(i + 1)
90
+ i += 2
91
+ end
92
+ else
93
+ raise Error, "Unknown flag #{arg}"
94
+ end
95
+ else
96
+ rest_start = i
97
+ break
98
+ end
99
+ end
100
+ opts[:rest] = rest_start ? args[rest_start..] : []
101
+ opts
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ class CLI
6
+ # Route and app-lifecycle commands: get/alias/list/prune,
7
+ # stop/restart/log/status/open.
8
+ module RoutesCommand
9
+ module_function
10
+
11
+ # ask-local get <name> [--service x] [--variant y] [--tld z]
12
+ #
13
+ # Variant and TLDs are inherited from the CURRENT directory's
14
+ # context (worktree branch, ASK_LOCAL_* env, config) so cross-service
15
+ # wiring works inside a variant: from a fix-ui worktree,
16
+ # `get backend` -> https://fix-ui.backend.localhost.
17
+ def get(ctx, args)
18
+ name = args.first
19
+ raise Error, "Usage: ask-local get <name> [--service s] [--variant v] [--tld t]" unless name
20
+
21
+ opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
22
+ context = Resolver.resolve(Dir.pwd, service: opts[:service],
23
+ variant: opts[:variant], tlds: opts[:tld], use_branch: false)
24
+ hostnames = Hostname.build(
25
+ app: Sanitize.hostname_label(name),
26
+ service: context.service,
27
+ variant: context.variant,
28
+ tlds: context.tlds
29
+ )
30
+ puts Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
31
+ end
32
+
33
+ def list(ctx, args)
34
+ json = args.delete("--json")
35
+ routes = ctx.store.load_routes
36
+ port = ctx.proxy_port
37
+ tls = ctx.proxy_tls
38
+ entries = routes.map do |r|
39
+ { hostname: r["hostname"],
40
+ url: Hostname.url(r["hostname"], port: port, tls: tls),
41
+ target: r["target"], kind: r["kind"],
42
+ pid: r["pid"], supervised: !r["spec"].nil?,
43
+ alive: alive_state(ctx, r) }
44
+ end
45
+ if json
46
+ require "json"
47
+ puts JSON.generate({ routes: entries, proxy_port: port, tls: tls })
48
+ return
49
+ end
50
+ if entries.empty?
51
+ puts "No active routes."
52
+ puts "Start an app with: ask-local"
53
+ return
54
+ end
55
+ puts "\nActive routes:\n"
56
+ entries.each do |e|
57
+ puts " #{e[:url]} -> #{e[:target]} #{label_for(e)}"
58
+ end
59
+ puts
60
+ end
61
+
62
+ def alive_state(ctx, route)
63
+ if route["pid"] == 0
64
+ ctx.backend_alive?(route) ? "reachable" : "unreachable"
65
+ elsif ProxyControl.pid_alive?(route["pid"])
66
+ "running"
67
+ else
68
+ "owner-gone"
69
+ end
70
+ rescue StandardError
71
+ "unknown"
72
+ end
73
+
74
+ def label_for(entry)
75
+ if entry[:pid] == 0
76
+ "(alias, #{entry[:alive]})"
77
+ else
78
+ "(pid #{entry[:pid]}, #{entry[:alive]})"
79
+ end
80
+ end
81
+
82
+ # Backend liveness per route: agents can see at a glance whether
83
+ # the route points at something alive. Static aliases (pid 0)
84
+ # report the probe, not a process.
85
+ def route_label(ctx, route)
86
+ entry = { pid: route["pid"], alive: alive_state(ctx, route) }
87
+ label_for(entry)
88
+ end
89
+
90
+ def prune(ctx, _args)
91
+ stale = ctx.store.prune_stale
92
+ if stale.empty?
93
+ puts "No stale routes."
94
+ else
95
+ stale.each { |r| puts "Removed stale route #{r["hostname"]}" }
96
+ end
97
+ end
98
+
99
+ def alias_add(ctx, args)
100
+ if args.first == "--remove"
101
+ name = args[1] or raise Error, "Usage: ask-local alias --remove <name>"
102
+ hostname = alias_hostname(name)
103
+ ctx.store.remove_route(hostname)
104
+ puts "Removed alias #{hostname}."
105
+ return
106
+ end
107
+ name, port_or_url = args
108
+ raise Error, "Usage: ask-local alias <name> <port|url>" unless name && port_or_url
109
+
110
+ hostname = alias_hostname(name)
111
+ target = port_or_url.match?(/\A\d+\z/) ? "127.0.0.1:#{port_or_url}" : port_or_url
112
+ force = args.include?("--force")
113
+ ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force)
114
+ puts "#{hostname} -> #{target}"
115
+ end
116
+
117
+ # A name containing dots is treated as a full hostname (any TLD);
118
+ # otherwise it is a label under the current TLD context
119
+ # (ASK_LOCAL_TLD first entry, else localhost).
120
+ def alias_hostname(name)
121
+ return Hostname.strip_port(name.downcase) if name.include?(".")
122
+
123
+ tld = ENV["ASK_LOCAL_TLD"]&.split(",")&.map(&:strip)&.reject(&:empty?)&.first
124
+ "#{Sanitize.hostname_label(name)}.#{tld || Hostname::DEFAULT_TLD}"
125
+ end
126
+
127
+ # Stop the app in the current directory (route + backend).
128
+ # Exit codes are machine-readable for agents: 0 stopped something,
129
+ # 2 no route here, 3 route existed but the backend was already gone.
130
+ def stop(ctx, _args, out: $stdout)
131
+ resolved = Resolver.resolve(Dir.pwd)
132
+ hostnames = Resolver.hostnames(resolved)
133
+ stopped = []
134
+ gone = []
135
+ hostnames.each do |hostname|
136
+ entry = ctx.store.find(hostname)
137
+ next unless entry
138
+
139
+ backend_pid = ctx.backend_pid_for(entry)
140
+ if backend_pid && ProxyControl.pid_alive?(backend_pid)
141
+ begin
142
+ Process.kill("TERM", backend_pid)
143
+ if ctx.wait_for_exit(backend_pid, timeout: 10)
144
+ stopped << "#{hostname} (backend #{backend_pid})"
145
+ else
146
+ stopped << "#{hostname} (backend #{backend_pid} still draining)"
147
+ end
148
+ rescue SystemCallError
149
+ gone << hostname
150
+ end
151
+ else
152
+ gone << hostname
153
+ end
154
+ ctx.store.remove_route(hostname)
155
+ FileUtils.rm_f(File.join(ctx.store.dir, "backend-#{hostname}.pid"))
156
+ end
157
+ if stopped.any?
158
+ stopped.each { |s| out.puts "Stopped #{s}." }
159
+ return 0
160
+ end
161
+ if gone.any?
162
+ out.puts "Route existed but the backend was already gone: #{gone.join(", ")}."
163
+ return 3
164
+ end
165
+
166
+ out.puts "No ask-local app running here."
167
+ 2
168
+ end
169
+
170
+ # Touch tmp/restart.txt so a supervised managed app reboots.
171
+ def restart(_ctx, _args)
172
+ path = File.join(Dir.pwd, "tmp", "restart.txt")
173
+ require "fileutils"
174
+ FileUtils.mkdir_p(File.dirname(path))
175
+ FileUtils.touch(path)
176
+ puts "Touched #{path} — managed app restarts on next request."
177
+ end
178
+
179
+ # Tail the app log (default 50 lines); --follow streams.
180
+ def log(ctx, args)
181
+ follow = args.delete("--follow") || args.delete("-f")
182
+ lines = (args.first || 50).to_i
183
+ resolved = Resolver.resolve(Dir.pwd)
184
+ path = File.expand_path(File.join(Dir.pwd, "log", "ask-local-#{resolved.app}.log"))
185
+ unless File.file?(path)
186
+ puts "No log at #{path} yet."
187
+ return
188
+ end
189
+ if follow
190
+ exec("tail", "-F", "-n", lines.to_s, path)
191
+ else
192
+ puts File.readlines(path).last(lines).join
193
+ end
194
+ end
195
+
196
+ # Print the effective naming context for this directory: what
197
+ # `ask-local` would boot here and why. Answers "why did I get
198
+ # this URL" without booting anything. --json emits stable keys
199
+ # for agents instead of prose.
200
+ def status(_ctx, args)
201
+ json = args.delete("--json")
202
+ resolved = Resolver.resolve(Dir.pwd)
203
+ hostnames = Resolver.hostnames(resolved)
204
+ urls = hostnames.map do |h|
205
+ Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
206
+ end
207
+ payload = {
208
+ app: resolved.app, app_source: resolved.sources[:app],
209
+ service: resolved.service, service_source: resolved.sources[:service],
210
+ variant: resolved.variant, variant_source: resolved.sources[:variant],
211
+ tlds: resolved.tlds, urls: urls,
212
+ framework: Framework.detect(Dir.pwd).to_s
213
+ }
214
+ if json
215
+ require "json"
216
+ puts JSON.generate(payload)
217
+ return
218
+ end
219
+ puts "app: #{payload[:app]} (from #{payload[:app_source]})"
220
+ puts "service: #{payload[:service] || "web (default, bare)"} (from #{payload[:service_source] || "default"})"
221
+ puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no worktree, branch, flag, or env"})"
222
+ puts "tlds: #{payload[:tlds].join(", ")}"
223
+ puts "urls:"
224
+ urls.each { |u| puts " #{u}" }
225
+ puts "framework: #{payload[:framework]}"
226
+ end
227
+
228
+ # Open the app URL in the default browser (macOS `open`).
229
+ def open(ctx, args)
230
+ name = args.first
231
+ url =
232
+ if name
233
+ opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
234
+ context = Resolver.resolve(Dir.pwd, service: opts[:service],
235
+ variant: opts[:variant], tlds: opts[:tld], use_branch: false)
236
+ hostnames = Hostname.build(app: Sanitize.hostname_label(name),
237
+ service: context.service, variant: context.variant, tlds: context.tlds)
238
+ Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
239
+ else
240
+ resolved = Resolver.resolve(Dir.pwd)
241
+ Hostname.url(Resolver.hostnames(resolved).first,
242
+ port: ctx.proxy_port, tls: ctx.proxy_tls)
243
+ end
244
+ case RUBY_PLATFORM
245
+ when /darwin/ then exec("open", url)
246
+ when /linux/ then exec("xdg-open", url)
247
+ else puts url
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end