ask-local 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b2c1d943d3bbfb315651a8bd50deb3519f325822e894f1c4040d30b7523cb46
4
- data.tar.gz: 9ad93192b70b62cae5292b5a1784bd9086e4103dba7d4f2a07aa83b7327bf795
3
+ metadata.gz: 7cab052002c8e0c03a4ed39fd2ff9b4d43474b6e2b969cd5ba0a5e5122c0229b
4
+ data.tar.gz: 600b5127b34523e88d1fe8a08434309fd7690ab33217f7eb33fbf18b4d3f851b
5
5
  SHA512:
6
- metadata.gz: 74920fa209896d3a66f67245c6757f45f9c23793147dabfdf7e1f088f0c6af9fcb41442f44dcd1657816617aa88eaa318a5c57fde6035dc767704812bef7f0ff
7
- data.tar.gz: c65585ddefac14be0b4898f3e5af9b96f370826bb11b43bc2f2ace9ce3dd350b1f8ba6b0a6b6bd45f2f43eecf1a34f4831bb1fff439087b01ce6ef1da379d169
6
+ metadata.gz: 8451f072fa059698ec390057df2cc0945da03cb5bab03ef4e29cd0f93f437eba827df542814538d84464b1cb805e297d8ee104b36063c5a34ca761003a760922
7
+ data.tar.gz: 9442146768560787bea3d9e1b024bdcd292db6af7a0611e029e6398c9863fb46589e58c7ca0e05950a71f0787e90df075f2c1c18984e9671638cd0348239e7c1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.1] — 2026-09-06
4
+
5
+ Patch release focused on workstation setup, URL correctness, and proxy reliability.
6
+
7
+ ### Added — `ask-local setup` & `ask-local start`
8
+
9
+ - `ask-local setup` — one-shot workstation setup for clean
10
+ `https://<app>.localhost` URLs: trust the local CA, serve port 443
11
+ (root launchd/systemd service when possible, sudo daemon otherwise),
12
+ sync `/etc/hosts`, and verify with `doctor`. Each step reports
13
+ `==>` / `ok` and the first failure aborts with the specific fix.
14
+ - `ask-local start` — one-setup-and-go entry point: an idempotent
15
+ workstation-check-then-boot (`ask-local setup` if needed, then the
16
+ app). `ask-local` bare is an alias for it; `ask-local setup` stays for
17
+ explicit re-setup.
18
+ - `askl` — shell-friendly alias binary (`bin/askl`, same entry point as
19
+ `bin/ask-local`). Keep `ask-local` in logs and docs so `grep` stays
20
+ useful.
21
+
22
+ ### Added — DNS-rebinding & log hygiene
23
+
24
+ - DNS-rebinding boundary: foreign `Host` headers get a bare 404 naming
25
+ nothing; only hosts under our own configured TLDs see the route-listing
26
+ 404. The proxy takes `--tld` (persisted to `proxy.tlds`) so the boundary
27
+ follows custom domains. The `X-Ask-Local: 1` health header marks our
28
+ proxy responses (including 404s) for the `ours?` probe.
29
+ - Log rotation — `proxy.log` and per-app backend logs rotate at 5MB
30
+ (`ASK_LOCAL_LOG_MAX_BYTES`, one generation) before each write. A new
31
+ `doctor` disk-usage check warns past 100MB of state.
32
+
33
+ ### Fixed
34
+
35
+ - **Silent `:1355` URL fallback removed.** Privileged-port (443) bind
36
+ failure is now a hard error pointing at `ask-local setup`, never a
37
+ booted app on `https://app.localhost:1355` that silently corrupts
38
+ downstream consumers of `ASK_LOCAL_URL`. The only port-suffixed URLs
39
+ are the ones you explicitly ask for (`proxy start -p 1355`).
40
+ - **Health probe `130+?` hang fixed.** The TLS probe's `connect` sat
41
+ outside the timeout: a TLS handshake against a foreign plain-HTTP
42
+ server blocked in `connect` for 60s+. Connect is now inside the
43
+ timeout, plain HTTP is tried first (our proxy answers plain HTTP via
44
+ byte-peeking even on the TLS port), and any HTTP response without our
45
+ header short-circuits as foreign — only silent servers wait for the
46
+ timeout.
47
+ - `start` dispatch was missing from the dispatcher despite being in
48
+ `SUBCOMMANDS`, so `ask-local start` fell through to `run_named` with
49
+ "start" as an app name. The kamal-help append drifted to a 6-space
50
+ indent. Both are fixed and pinned by tests.
51
+ - `base64` declared as a runtime dependency (it left the default gems in
52
+ Ruby 3.4).
53
+ - Missing `require "optparse"` lost in the CLI split.
54
+
55
+ ### Tests — new coverage for this patch
56
+
57
+ - `start_test.rb` — help, fast-path vs. needs-setup branching, and the
58
+ non-interactive hard-error message.
59
+ - `setup_test.rb` — four-step orchestration (all-steps-stubbed), first-failure
60
+ abort with fix text, `--no-service` flag, and the three `ensure_proxy!`
61
+ hard-error paths (non-interactive, foreign port, spawn failure) plus
62
+ explicit-port URL honesty and responding-foreign-server fast classification.
63
+ - Pinned under `bundle exec rake test` (fast unit suite); no `test:e2e`
64
+ needed for these.
65
+
3
66
  ## [0.2.0] — Unreleased
4
67
 
5
68
  ### Changed
@@ -22,10 +85,11 @@
22
85
  - Root-owned `service install` (launchd/systemd) binding 80/443 at boot
23
86
  with the invoking user's state dir; sudo re-exec when needed.
24
87
  - All root write paths chown state back to the invoking user; `doctor`
25
- reports an unwritable state dir plainly.
88
+ reports an unwritable state dir plainly. The privileged auto-start
89
+ re-execs under `sudo` with the correct state dir.
26
90
  - Bounded proxy concurrency (`ASK_LOCAL_MAX_CONNECTIONS`, 503 past the
27
- cap), mtime-TTL route cache, IPv4+IPv6 loopback listeners, dual-stack
28
- `ours?` health check.
91
+ cap), mtime-based route cache (no TTL race for boot-then-curl), IPv4+IPv6
92
+ loopback listeners, dual-stack `ours?` health check.
29
93
  - `get` inherits variant/TLD context from the current directory
30
94
  (`get backend` in a fix-ui worktree -> fix-ui.backend.localhost);
31
95
  `--service/--variant/--tld` overrides.
@@ -37,32 +101,12 @@
37
101
  - Ships the `ask-local` agent skill (ask/skills/ask-local/SKILL.md).
38
102
  - `test:e2e` / `test:all` rake tasks; CI matrix (3.2/3.3/3.4/4.0),
39
103
  macOS e2e leg, fixture-sweep job.
40
- - Ownership module: root write paths chown state back to the invoking
41
- user; `doctor` reports an unwritable state dir with the fix.
42
- - Proxy hardening: bounded concurrency (503 past the cap), mtime-TTL
43
- route cache, IPv4+IPv6 loopback listeners, dual-stack health check,
44
- oversize-hostname refusal.
45
- - `status` (effective naming context), `open [name]`, `log -f`;
46
- `stop` exit codes 0/2/3; `list` shows backend liveness.
47
- - Sinatra-modular + foreman-`$PORT` fixtures; no-double-injection guard.
48
- - SKILL.md "when NOT to use" section (CI, prod, Docker networks).
49
- - Framework coverage: hanami2 slice layout, jekyll livereload second
50
- port (`--livereload-port` pinned next to the main port when
51
- `livereload: true`), roda-plugins managed boot; no-double-injection
52
- guard for explicit `$PORT`.
53
- - Chunked request uploads pinned by test (streamed intact,
54
- close-delimited).
55
- - README non-goals section (HTTP/2, tunnels, production) with rationale.
56
- - DNS-rebinding boundary: foreign Hosts get a bare 404; only our own
57
- TLDs see the route-listing 404. Proxy takes `--tld` (persisted) so the
58
- boundary follows custom domains.
59
- - Log rotation (5MB, one generation) on proxy + backend logs; `doctor`
60
- disk-usage check.
61
- - `--json` on list/status/doctor with stable keys; `status` sources made
62
- explicit (no more `(from -)`).
63
- - Route cache keyed on file mtime (no TTL race for boot-then-curl);
64
- foreground supervision polls at 2Hz with the detached-children
65
- rationale documented.
104
+ - Ownership module, framework fixtures (sinatra-modular,
105
+ foreman-`$PORT`, hanami2 slice layout, jekyll livereload), SKILL.md
106
+ "when NOT to use" section, README non-goals, vite/Shakapacker recipe.
107
+ - Host authorization patterns default TLDs from `ASK_LOCAL_TLD`.
108
+ - WebSocket Upgrade end-to-end test (RFC 6455 handshake + frame echo),
109
+ hop-loop 508 rejection, chunked framing, and spinning-loop fix.
66
110
 
67
111
  ### Fixed
68
112
 
@@ -81,9 +125,6 @@
81
125
  - Bidirectional streaming terminates promptly on `Connection: close`
82
126
  (each pump direction closes its peer on EOF).
83
127
  - `alias --remove` now appends the default TLD.
84
- - `base64` declared as a runtime dependency (left the default gems in
85
- Ruby 3.4).
86
- - Missing `require "optparse"` lost in the CLI split.
87
128
  - Install generator `source_root` pointed at a doubled path; generator
88
129
  file checks now resolve against `destination_root`.
89
130
  - Port-flag injection no longer double-sets an explicit `$PORT`.
data/README.md CHANGED
@@ -8,18 +8,54 @@ Zero runtime dependencies — Ruby stdlib only (`openssl`, `socket`).
8
8
 
9
9
  ```bash
10
10
  gem install ask-local
11
+ # Interactive shortcut: `askl` is the same binary (shell-friendly alias).
12
+ # Keep `ask-local` in logs and docs so `grep` stays useful.
13
+ ask-local start # setup + boot in one go (or plain `ask-local`)
14
+ ask-local setup # once per machine: CA trust + port 443 + hosts + verify
11
15
  cd ~/code/myapp && ask-local
12
16
  # -> https://myapp.localhost
13
17
  ```
14
18
 
15
- ## How it works
19
+ ## The no-fallback promise
20
+
21
+ ask-local never silently degrades to a `:<port>` URL. Clean
22
+ `https://<app>.localhost` requires the proxy on port 443; if 443
23
+ cannot be bound, you get a hard error pointing at `ask-local setup`
24
+ — never a booted app on `https://app.localhost:1355` that silently
25
+ poisons OAuth callbacks, mailer hosts, and webhooks downstream.
26
+
27
+ The only port-suffixed URLs are the ones you explicitly ask for:
28
+ `ask-local proxy start -p 1355` (CI/sandboxes where 443 is impossible).
29
+ There the suffix is honest, and `ASK_LOCAL_URL` carries it faithfully.
30
+
31
+ ## The one-file model
32
+
33
+ Every app declares `config/local.yml` (Kamal-style) — the single source
34
+ of truth for service name, proxy TLD/host, processes, and env:
35
+
36
+ ```yaml
37
+ service: myapp
38
+ proxy:
39
+ tld: localhost
40
+ processes:
41
+ web:
42
+ cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
43
+ proxy: true
44
+ worker:
45
+ cmd: bundle exec sidekiq
46
+ proxy: false
47
+ ```
48
+
49
+ `ask-local init` creates the file (migrating an existing Procfile);
50
+ Rails apps need no extra gem — ask-local injects `RAILS_DEVELOPMENT_HOSTS`
51
+ so the proxied hostname is allowed automatically. `ask-local`
52
+ then boots every process, assigns each a `$PORT`, injects
53
+ `ASK_LOCAL_URL`, registers routes for HTTP processes, supervises the
54
+ whole tree, and cleans up when one exits.
16
55
 
17
- 1. `ask-local` infers your app name (Rails module, gemspec,
18
- `package.json`, git root, or directory) and boots it — managed Rack
19
- apps on a unix socket (zero TCP ports), anything else via `PORT`.
20
- 2. Registers `hostname -> backend -> pid` in `~/.ask-local/routes.json`.
21
- 3. The reverse proxy (HTTPS on 443, per-host certs from a local CA)
22
- routes by `Host` header to your app.
56
+ Variants are file overlays: `config/local.<variant>.yml` deep-merges on
57
+ top of `config/local.yml`, selected by `ASK_LOCAL_VARIANT` (Kamal's
58
+ destination pattern).
23
59
 
24
60
  `.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
25
61
  no DNS server, no `/etc/resolver`. Safari may need `ask-local hosts sync`.
@@ -94,7 +130,7 @@ Procfile lines that are compound (`&&`, `||`, `|`, `;`) are refused with
94
130
  guidance rather than silently mis-injected.
95
131
 
96
132
  For Rails integration (hosts, Action Cable origins, Procfile rewrite,
97
- generators), see `ask-local-rails`.
133
+ generators) deprecated; core covers Rails now.
98
134
 
99
135
  ## WebSockets
100
136
 
@@ -133,7 +169,7 @@ Run-mode (TCP) routes and static aliases are never supervised.
133
169
 
134
170
  | Gem | How ask-local helps |
135
171
  |---|---|
136
- | `ask-rails` | `ask_local:install` equivalent wiring: `config.hosts` patterns, `allowed_request_origins` for Cable, mailer/OmniAuth hosts from `ASK_LOCAL_URL` |
172
+ | `ask-rails` | ask-local core injects `RAILS_DEVELOPMENT_HOSTS`; Cable origins + helpers live in the deprecated ask-local-rails |
137
173
  | `ask-rails-harness` | Its 9 Rails tools (routes, models, DB, logs) run against the app the proxy serves; `DevUrl` gives the agent the stable URL instead of a guessed port |
138
174
  | `ask-app-server` | The JSON-RPC/stdio session host sits behind `https://api.<app>.localhost`; editor/IDE clients use `ask-local get` output |
139
175
  | `ask-mcp` | MCP servers get named URLs per service (`mcp.<app>.localhost`), no port coordination across servers |
@@ -190,6 +226,19 @@ bundle install
190
226
  bundle exec rake test
191
227
  ```
192
228
 
229
+ ## Non-goals (deliberate)
230
+
231
+ - **HTTP/2.** Ruby dev servers serve a handful of requests, not Vite's
232
+ hundreds of unbundled files — the multiplexing win doesn't apply, and
233
+ ALPN/HPACK/stream state would triple the proxy's auditable surface.
234
+ - **LAN/mDNS or tunneled sharing.** mDNS behaves differently on every
235
+ network; third-party tunnels need CLIs, auth state, and accounts.
236
+ The `kamal` preview-deploy snippet covers "show this branch to
237
+ someone" on real infrastructure instead.
238
+ - **Production serving.** The proxy binds loopback only, the CA is
239
+ self-signed, and there is no buffering or rate limiting.
240
+
241
+
193
242
  The `ask-local-apps` fixture fleet (sibling checkout) exercises
194
243
  detection, inference, and boot across Rails variants, Roda, Sinatra,
195
244
  bare Rack, Jekyll, compound Procfiles, and a monorepo. CI runs the
data/bin/askl ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/ask-local"
5
+
6
+ exit Ask::Local::CLI.run(ARGV)
@@ -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
55
+
56
+ runner = Runner.new(store: ctx.store)
57
+ children = []
58
+ routes_registered = []
71
59
 
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)
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,28 +205,39 @@ module Ask
225
205
  end
226
206
  end
227
207
 
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
+
228
214
  def ensure_proxy!(ctx)
229
215
  port = ctx.proxy_port
230
216
  tls = ctx.proxy_tls
231
217
  if ProxyControl.listening?(port)
232
- if ProxyControl.ours?(port, tls: tls)
233
- return
234
- end
235
-
218
+ return if ProxyControl.ours?(port, tls: tls)
236
219
  $stderr.puts "Error: port #{port} is in use by another process."
220
+ $stderr.puts " Stop it, or ask-local proxy start -p <port>"
221
+ exit 1
237
222
  end
238
-
239
223
  privileged = port < 1024 && !ProxyControl.root?
240
224
  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"
225
+ $stderr.puts "Error: proxy is not running and port #{port} needs root."
226
+ $stderr.puts " Run this once: ask-local setup"
227
+ $stderr.puts " Or start the proxy by hand: sudo ask-local proxy start"
228
+ exit 1
229
+ end
230
+ puts "Starting proxy#{privileged ? " (sudo)" : ""}..."
231
+ begin
232
+ Ask::Local::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
233
+ rescue Ask::Local::ProxyNotRunningError => e
234
+ $stderr.puts "Error: #{e.message.lines.first&.strip}"
235
+ $stderr.puts " Fix once: ask-local setup"
244
236
  exit 1
245
237
  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
238
  rescue Errno::EACCES
249
- $stderr.puts "Error: could not bind port #{port}. Try: ask-local proxy start -p 1355"
239
+ $stderr.puts "Error: permission denied binding port #{port}."
240
+ $stderr.puts " Fix once: ask-local setup"
250
241
  exit 1
251
242
  end
252
243
  end