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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +119 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/bin/ask-local +6 -0
- data/lib/ask/local/certs.rb +179 -0
- data/lib/ask/local/cli/boot.rb +255 -0
- data/lib/ask/local/cli/context.rb +106 -0
- data/lib/ask/local/cli/routes.rb +253 -0
- data/lib/ask/local/cli/system.rb +294 -0
- data/lib/ask/local/cli.rb +104 -0
- data/lib/ask/local/config.rb +113 -0
- data/lib/ask/local/doctor.rb +135 -0
- data/lib/ask/local/errors.rb +20 -0
- data/lib/ask/local/framework.rb +77 -0
- data/lib/ask/local/hostname.rb +46 -0
- data/lib/ask/local/hosts.rb +80 -0
- data/lib/ask/local/inference.rb +144 -0
- data/lib/ask/local/log.rb +61 -0
- data/lib/ask/local/ownership.rb +62 -0
- data/lib/ask/local/ports.rb +47 -0
- data/lib/ask/local/proxy.rb +480 -0
- data/lib/ask/local/proxy_control.rb +199 -0
- data/lib/ask/local/resolver.rb +126 -0
- data/lib/ask/local/route_store.rb +150 -0
- data/lib/ask/local/runner.rb +240 -0
- data/lib/ask/local/sanitize.rb +43 -0
- data/lib/ask/local/supervisor.rb +244 -0
- data/lib/ask/local/trust.rb +123 -0
- data/lib/ask/local/variant.rb +136 -0
- data/lib/ask/local/version.rb +7 -0
- data/lib/ask/skills/ask-local/SKILL.md +89 -0
- data/lib/ask-local.rb +37 -0
- metadata +135 -0
|
@@ -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
|