ask-local 0.1.1 → 0.2.1

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.
@@ -3,109 +3,143 @@
3
3
  module Ask
4
4
  module Local
5
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.
6
+ # Boot commands: `ask-local`, `ask-local start`, `ask-local run`.
7
+ # The config file is mandatory missing file prints the fix and exits.
8
+ # `boot_all` fans out over every process declared in config/local.yml.
9
+ # No inference, no Procfile at boot, no single-process default.
9
10
  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
11
  PORT_IGNORING = %w[jekyll middleman bridgetown].freeze
14
12
 
15
13
  module_function
16
14
 
15
+ # Bare `ask-local` / `ask-local start` / `ask-local run`.
16
+ # Reads config/local.yml via the resolver, ensures the proxy,
17
+ # boots every process, supervises the tree, cleans up on exit.
17
18
  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)
19
+ variant = ENV["ASK_LOCAL_VARIANT"]
20
+ opts = ctx.parse_flags(args, %i[variant tld force])
21
+ resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
23
22
  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
23
+ boot_all(ctx, resolved, opts)
32
24
  end
33
25
 
34
26
  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)
27
+ run_inferred(ctx, args)
44
28
  end
45
29
 
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}."
30
+ def run_named(ctx, name, _args)
31
+ $stderr.puts "Error: `ask-local #{name}` is no longer supported."
32
+ $stderr.puts " All processes come from config/local.yml. Run `ask-local init` to create one."
33
+ exit 1
34
+ end
35
+
36
+ # Core boot loop: iterate processes from config/local.yml,
37
+ # classify HTTP vs background, spawn each, register routes for
38
+ # HTTP processes, then supervise the tree.
39
+ def boot_all(ctx, resolved, opts)
40
+ service = resolved.app
41
+ tld = resolved.tld
42
+ host = resolved.host
43
+ processes = resolved.processes
44
+
45
+ if processes.empty?
46
+ $stderr.puts "Error: no processes in config/local.yml. Add at least one."
53
47
  exit 1
54
48
  end
55
- boot_run(ctx, Runner.new(store: ctx.store), resolved, hostnames, opts[:rest], opts)
56
- end
57
49
 
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
50
+ primary = Resolver.primary_proc(resolved)
51
+ if primary.nil?
52
+ $stderr.puts "Error: no HTTP process (proxy: true) found in config/local.yml."
53
+ exit 1
54
+ end
71
55
 
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)
56
+ runner = Runner.new(store: ctx.store)
57
+ children = []
58
+ routes_registered = []
59
+
60
+ puts "ask-local (#{service})"
61
+ puts "--"
62
+
63
+ processes.each do |proc_name, entry|
64
+ next if entry["proxy"] == false
65
+
66
+ hostname = Resolver.hostname_for(resolved, proc_name)
67
+ next unless hostname
68
+
69
+ url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
70
+ hostnames = [hostname]
71
+ puts " [#{proc_name}] #{url}"
72
+
73
+ # Each process cmd runs through the shell so $PORT (and other
74
+ # env refs) expand — same trust boundary as a Procfile line
75
+ # (repo code, not user input). Compound lines are refused.
76
+ cmd = entry["cmd"].to_s
77
+ if cmd.match?(Ask::Local::Procfile::COMPOUND)
78
+ $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: ask-local run -- #{cmd}"
79
+ next
80
+ end
81
+
82
+ port = opts[:app_port] || Ports.find_free
83
+ shell_cmd = ["sh", "-c", cmd]
84
+ # Always allow the proxied hostname in Rails dev (Rails ignores
85
+ # this env var when not a Rails app — safe for every framework).
86
+ app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
87
+ dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
88
+ rails_dev_host: hostname)
89
+ register_all(ctx, hostnames, app, force: opts[:force],
90
+ spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
91
+ routes_registered << { hostnames: hostnames, app: app }
92
+
93
+ puts " -> #{url}"
94
+ end
95
+
96
+ background = processes.select { |_, v| v["proxy"] == false }
97
+ unless background.empty?
98
+ puts " [background] #{background.keys.join(', ')}"
99
+ end
100
+
101
+ puts
102
+ ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
103
+
104
+ # Supervisor: exit when ANY child dies (loud cleanup).
105
+ all_pids = routes_registered.map { |r| r[:app].pid }
106
+ trap_cleanup(ctx, routes_registered.flat_map { |r| r[:hostnames] }, all_pids)
107
+ supervise_tree(ctx, routes_registered.flat_map { |r| r[:hostnames] }, all_pids)
108
+ end
109
+
110
+ def build_env(ctx, resolved, entry)
111
+ env = {}
112
+ # Merge config env.clear
113
+ config_env = resolved.secrets || {}
114
+ entry_env = entry["env"] || {}
115
+ (entry_env["clear"] || {}).each { |k, v| env[k] = v }
116
+ # Merge secrets from config/local.secrets
117
+ secret_keys = entry_env["secret"] || []
118
+ secret_keys.each do |k|
119
+ env[k] = config_env[k] if config_env.key?(k)
120
+ end
121
+ # Host env (dotenv from .env) already in ENV
122
+ env
87
123
  end
88
124
 
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)
125
+ def supervise_tree(ctx, hostnames, pids)
126
+ loop do
97
127
  sleep 0.5
128
+ if pids.any? { |pid| !ProxyControl.pid_alive?(pid) }
129
+ puts "\nA process exited — cleaning up all routes."
130
+ cleanup_routes(ctx, hostnames)
131
+ # Kill remaining children
132
+ pids.each do |pid|
133
+ Process.kill("TERM", pid) rescue nil
134
+ end
135
+ exit 0
136
+ end
98
137
  end
99
- puts "\nBackend exited — cleaning up."
100
- cleanup_routes(ctx, hostnames)
101
- exit 0
102
138
  end
103
139
 
104
140
  def inject_port_flags(command, port)
105
141
  return command if command.empty?
106
142
  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
143
  return command if command.any? { |a| a.include?("$PORT") }
110
144
 
111
145
  bin = File.basename(command.first.to_s)
@@ -113,85 +147,33 @@ module Ask
113
147
  (command.length > 2 && PORT_IGNORING.include?(File.basename(command[2].to_s)))
114
148
  return command unless needs_flags
115
149
 
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
150
+ command + ["--port", port.to_s, "--host", "127.0.0.1"]
157
151
  end
158
152
 
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.
153
+ # Legacy procfile parsing for backwards compat.
164
154
  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)
155
+ path = ::Ask::Local::Procfile.find_file(Dir.pwd)
156
+ return nil unless path
157
+ lines = ::Ask::Local::Procfile.parse_file(path)
158
+ line = if process
159
+ lines.find { |l| l.name == process }
160
+ else
161
+ lines.first
162
+ end
163
+ return nil unless line
164
+ line.compound ? nil : ["sh", "-c", line.command]
165
+ end
166
+
167
+ def trap_cleanup(ctx, hostnames, pids = [])
183
168
  %w[INT TERM].each do |sig|
184
169
  trap(sig) do
185
170
  cleanup_routes(ctx, hostnames)
171
+ pids.each { |pid| Process.kill("TERM", pid) rescue nil }
186
172
  exit 0
187
173
  end
188
174
  end
189
175
  end
190
176
 
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
177
  def register_all(ctx, hostnames, app, force:, spec: nil)
196
178
  hostnames[1..].each do |h|
197
179
  ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
@@ -207,8 +189,6 @@ module Ask
207
189
  nil
208
190
  end
209
191
 
210
- # Foreground boot semantics (portless model): leaving = routes gone
211
- # AND backend stopped. No orphans on Ctrl+C, TERM, or clean exit.
212
192
  def cleanup_routes(ctx, hostnames)
213
193
  hostnames.each do |h|
214
194
  begin
@@ -225,51 +205,42 @@ module Ask
225
205
  end
226
206
  end
227
207
 
228
- # Proxy auto-start. The URL promise is absolute: clean
229
- # https://<app>.localhost with no :port suffix, which requires the
230
- # proxy on port 443 (or 80 for --no-tls). There is deliberately NO
231
- # silent fallback to a high port here — a fallback would boot fine
232
- # and hand you https://app.localhost:1355, silently corrupting every
233
- # downstream consumer of ASK_LOCAL_URL (OAuth callbacks, mailers,
234
- # webhooks). If 443 cannot be bound, this is a hard error pointing
235
- # at `ask-local setup`.
236
- #
237
- # Explicit opt-in is different: `ask-local proxy start -p 1355`
238
- # means you asked for a port in the URL, and ASK_LOCAL_URL carries
239
- # it faithfully. That path never flows through here.
208
+ def resolve!(ctx, variant: nil, tld: nil)
209
+ # The resolver calls Config.load, which raises ConfigError if
210
+ # config/local.yml is missing exactly what we want.
211
+ Ask::Local::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
212
+ end
213
+
240
214
  def ensure_proxy!(ctx)
241
215
  port = ctx.proxy_port
242
216
  tls = ctx.proxy_tls
243
217
  if ProxyControl.listening?(port)
244
218
  return if ProxyControl.ours?(port, tls: tls)
245
-
246
219
  $stderr.puts "Error: port #{port} is in use by another process."
247
- $stderr.puts " Stop it, or point ask-local elsewhere: ASK_LOCAL_PORT=<free-port> ask-local"
220
+ $stderr.puts " Stop it, or ask-local proxy start -p <port>"
248
221
  exit 1
249
222
  end
250
-
251
223
  privileged = port < 1024 && !ProxyControl.root?
252
224
  if privileged && !ctx.interactive?
253
- $stderr.puts "Error: proxy is not running and port #{port} needs root to bind."
254
- $stderr.puts " Run this once in a terminal (it handles sudo + service + trust):"
255
- $stderr.puts " ask-local setup"
256
- $stderr.puts " Or start the proxy by hand first:"
257
- $stderr.puts " sudo ask-local proxy start"
225
+ $stderr.puts "Error: proxy is not running and port #{port} needs root."
226
+ $stderr.puts " Human: run this once ask-local setup"
227
+ $stderr.puts " Agent/CI: pre-provision passwordless sudo once —"
228
+ $stderr.puts " ask-local sudoers > /tmp/ask-local.sudoers"
229
+ $stderr.puts " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
230
+ $stderr.puts " Or start the proxy by hand: sudo ask-local proxy start"
258
231
  exit 1
259
232
  end
260
- puts "Starting proxy#{privileged ? " (will prompt for sudo to bind port #{port})" : ""}..."
233
+ puts "Starting proxy#{privileged ? " (sudo)" : ""}..."
261
234
  begin
262
- ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
235
+ Ask::Local::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
263
236
  rescue Ask::Local::ProxyNotRunningError => e
264
237
  $stderr.puts "Error: #{e.message.lines.first&.strip}"
265
- $stderr.puts " The proxy could not bind port #{port}. To fix once and for all:"
266
- $stderr.puts " ask-local setup"
238
+ $stderr.puts " Fix once: ask-local setup"
267
239
  exit 1
268
240
  end
269
241
  rescue Errno::EACCES
270
242
  $stderr.puts "Error: permission denied binding port #{port}."
271
- $stderr.puts " Run this once (it handles sudo + service + trust):"
272
- $stderr.puts " ask-local setup"
243
+ $stderr.puts " Fix once: ask-local setup"
273
244
  exit 1
274
245
  end
275
246
  end
@@ -19,13 +19,11 @@ module Ask
19
19
  raise Error, "Usage: ask-local get <name> [--service s] [--variant v] [--tld t]" unless name
20
20
 
21
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)
22
+ context = Resolver.resolve(Dir.pwd, variant: opts[:variant])
24
23
  hostnames = Hostname.build(
25
24
  app: Sanitize.hostname_label(name),
26
- service: context.service,
27
- variant: context.variant,
28
- tlds: context.tlds
25
+ tlds: Array(context.tld || Ask::Local::Hostname::DEFAULT_TLD),
26
+ variant: context.variant
29
27
  )
30
28
  puts Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
31
29
  end
@@ -206,9 +204,11 @@ module Ask
206
204
  end
207
205
  payload = {
208
206
  app: resolved.app, app_source: resolved.sources[:app],
209
- service: resolved.service, service_source: resolved.sources[:service],
207
+ tld: resolved.tld, tld_source: resolved.sources[:tld],
208
+ host: resolved.host, host_source: resolved.sources[:host],
210
209
  variant: resolved.variant, variant_source: resolved.sources[:variant],
211
- tlds: resolved.tlds, urls: urls,
210
+ urls: urls,
211
+ processes: resolved.processes.keys,
212
212
  framework: Framework.detect(Dir.pwd).to_s
213
213
  }
214
214
  if json
@@ -217,9 +217,13 @@ module Ask
217
217
  return
218
218
  end
219
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(", ")}"
220
+ if payload[:host]
221
+ puts "host: #{payload[:host]} (from #{payload[:host_source]})"
222
+ else
223
+ puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
224
+ end
225
+ puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no overlay file, flag, or env"})"
226
+ puts "processes: #{payload[:processes].join(", ")}"
223
227
  puts "urls:"
224
228
  urls.each { |u| puts " #{u}" }
225
229
  puts "framework: #{payload[:framework]}"
@@ -231,10 +235,10 @@ module Ask
231
235
  url =
232
236
  if name
233
237
  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)
238
+ context = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld])
236
239
  hostnames = Hostname.build(app: Sanitize.hostname_label(name),
237
- service: context.service, variant: context.variant, tlds: context.tlds)
240
+ tlds: Array(context.tld || Ask::Local::Hostname::DEFAULT_TLD),
241
+ variant: context.variant)
238
242
  Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
239
243
  else
240
244
  resolved = Resolver.resolve(Dir.pwd)