ask-local 0.1.0 → 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b2c1d943d3bbfb315651a8bd50deb3519f325822e894f1c4040d30b7523cb46
4
- data.tar.gz: 9ad93192b70b62cae5292b5a1784bd9086e4103dba7d4f2a07aa83b7327bf795
3
+ metadata.gz: a9b2c40e96d2ccde2095d698a79684a3dec1335afe9ac4acc1773405f520002d
4
+ data.tar.gz: a2a63d630679488b6236a499bb954fa2cf70028c2f00d5dd34c2f76a21ef057b
5
5
  SHA512:
6
- metadata.gz: 74920fa209896d3a66f67245c6757f45f9c23793147dabfdf7e1f088f0c6af9fcb41442f44dcd1657816617aa88eaa318a5c57fde6035dc767704812bef7f0ff
7
- data.tar.gz: c65585ddefac14be0b4898f3e5af9b96f370826bb11b43bc2f2ace9ce3dd350b1f8ba6b0a6b6bd45f2f43eecf1a34f4831bb1fff439087b01ce6ef1da379d169
6
+ metadata.gz: dc721fac709387e593ed445f03080e994fcd2614ec5ff74e92ba4ccccf17dea61d59029ab187eaffe1eca52a088c817a36d86cdf7e7c13a8d3fd5878d8284f7a
7
+ data.tar.gz: 16c12424f982fc3db86c2fd210cae69e7890abc22c3f1cdd8c747cfac9ab2c15952c431ee72557144d5dfccd8bc9c95aebe0ab94b6cb3b30c95faa33329a8b5a
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,10 +8,26 @@ 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
 
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
+
15
31
  ## How it works
16
32
 
17
33
  1. `ask-local` infers your app name (Rails module, gemspec,
@@ -190,6 +206,19 @@ bundle install
190
206
  bundle exec rake test
191
207
  ```
192
208
 
209
+ ## Non-goals (deliberate)
210
+
211
+ - **HTTP/2.** Ruby dev servers serve a handful of requests, not Vite's
212
+ hundreds of unbundled files — the multiplexing win doesn't apply, and
213
+ ALPN/HPACK/stream state would triple the proxy's auditable surface.
214
+ - **LAN/mDNS or tunneled sharing.** mDNS behaves differently on every
215
+ network; third-party tunnels need CLIs, auth state, and accounts.
216
+ The `kamal` preview-deploy snippet covers "show this branch to
217
+ someone" on real infrastructure instead.
218
+ - **Production serving.** The proxy binds loopback only, the CA is
219
+ self-signed, and there is no buffering or rate limiting.
220
+
221
+
193
222
  The `ask-local-apps` fixture fleet (sibling checkout) exercises
194
223
  detection, inference, and boot across Rails variants, Roda, Sinatra,
195
224
  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)
@@ -225,28 +225,51 @@ module Ask
225
225
  end
226
226
  end
227
227
 
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.
228
240
  def ensure_proxy!(ctx)
229
241
  port = ctx.proxy_port
230
242
  tls = ctx.proxy_tls
231
243
  if ProxyControl.listening?(port)
232
- if ProxyControl.ours?(port, tls: tls)
233
- return
234
- end
244
+ return if ProxyControl.ours?(port, tls: tls)
235
245
 
236
246
  $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"
248
+ exit 1
237
249
  end
238
250
 
239
251
  privileged = port < 1024 && !ProxyControl.root?
240
252
  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"
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"
244
258
  exit 1
245
259
  end
246
260
  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)
261
+ begin
262
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: privileged)
263
+ rescue Ask::Local::ProxyNotRunningError => e
264
+ $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"
267
+ exit 1
268
+ end
248
269
  rescue Errno::EACCES
249
- $stderr.puts "Error: could not bind port #{port}. Try: ask-local proxy start -p 1355"
270
+ $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"
250
273
  exit 1
251
274
  end
252
275
  end
@@ -263,6 +263,217 @@ module Ask
263
263
  end
264
264
  end
265
265
 
266
+ # One-shot workstation setup: everything needed for clean
267
+ # https://<app>.localhost URLs, in order, with a clear fix-it
268
+ # message on the first failure. Run this once per machine:
269
+ #
270
+ # ask-local setup
271
+ #
272
+ # Steps: trust the local CA -> ensure the proxy serves port 443
273
+ # (root service when possible, sudo daemon otherwise) -> sync
274
+ # /etc/hosts -> verify with doctor. After this, plain `ask-local`
275
+ # in any app dir just works with no :port suffix, ever.
276
+ def setup(ctx, args)
277
+ if args.include?("--help") || args.include?("-h")
278
+ puts <<~HELP
279
+ Usage: ask-local setup [--no-service]
280
+
281
+ One-shot workstation setup for clean https://<app>.localhost URLs:
282
+
283
+ 1. Trust the local CA (no more browser warnings)
284
+ 2. Serve port 443 (root service at boot, or sudo daemon now)
285
+ 3. Sync /etc/hosts (Safari + custom TLDs)
286
+ 4. Verify everything with doctor
287
+
288
+ --no-service skips the root service and starts a sudo daemon
289
+ instead (no boot persistence; good for ephemeral machines).
290
+ HELP
291
+ return
292
+ end
293
+
294
+ step("1/4 Trusting local CA") do
295
+ result = Ask::Local::Trust.trust
296
+ unless result[:trusted]
297
+ abort_setup("CA trust failed: #{result[:error]}",
298
+ "Run `ask-local trust` manually to see the underlying error,",
299
+ "then re-run `ask-local setup`.")
300
+ end
301
+ end
302
+
303
+ unless args.include?("--no-service")
304
+ step("2/4 Installing proxy service on port 443") do
305
+ unless ensure_root_service(ctx)
306
+ abort_setup("Could not install the proxy service.",
307
+ "Fallback: `ask-local setup --no-service` for a sudo daemon",
308
+ "without boot persistence.")
309
+ end
310
+ end
311
+ else
312
+ step("2/4 Starting proxy sudo daemon on port 443") do
313
+ unless ensure_sudo_daemon(ctx)
314
+ abort_setup("Could not start the proxy daemon on port 443.",
315
+ "Check the log, then re-run `ask-local setup`.")
316
+ end
317
+ end
318
+ end
319
+
320
+ step("3/4 Syncing /etc/hosts") do
321
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
322
+ unless Ask::Local::Hosts.sync(hostnames)
323
+ abort_setup("Could not write /etc/hosts.",
324
+ "Run `sudo ask-local hosts sync`, then re-run `ask-local setup`.")
325
+ end
326
+ end
327
+
328
+ step("4/4 Verifying with doctor") do
329
+ failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout)
330
+ if failed.zero?
331
+ puts "\nSetup complete: https://<app>.localhost URLs are ready."
332
+ puts "Try it: cd ~/code/myapp && ask-local"
333
+ else
334
+ abort_setup("Doctor reports #{failed} failing check(s) (see above).",
335
+ "Fix the reported issues, then re-run `ask-local setup`.")
336
+ end
337
+ end
338
+ end
339
+
340
+ def step(label)
341
+ puts "\n==> #{label}..."
342
+ yield
343
+ puts " ok"
344
+ end
345
+
346
+ def abort_setup(problem, *fixes)
347
+ $stderr.puts "\nSetup failed: #{problem}"
348
+ fixes.each { |f| $stderr.puts " #{f}" }
349
+ exit 1
350
+ end
351
+
352
+ # Install the root service (boot-persistent). Returns true when a
353
+ # proxy is up on 443 afterwards, false otherwise. Never falls back
354
+ # to a high port silently: a :port suffix in URLs would corrupt the
355
+ # stable-URL promise, so failure here is a hard error with guidance.
356
+ def ensure_root_service(ctx)
357
+ service_install(ctx, [])
358
+ wait_for_ours(ctx, 443, tls: true)
359
+ rescue Error, SystemCallError => e
360
+ warn " service install failed: #{e.message}"
361
+ false
362
+ end
363
+
364
+ # Sudo daemon for 443 without boot persistence (--no-service).
365
+ def ensure_sudo_daemon(ctx)
366
+ port, tls = 443, true
367
+ unless ctx.interactive?
368
+ warn " no TTY available for the sudo prompt."
369
+ return false
370
+ end
371
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
372
+ wait_for_ours(ctx, port, tls: tls)
373
+ rescue Ask::Local::ProxyNotRunningError, SystemCallError => e
374
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
375
+ false
376
+ end
377
+
378
+ def wait_for_ours(ctx, port, tls:, timeout: 20)
379
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
380
+ until ProxyControl.ours?(port, tls: tls)
381
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
382
+
383
+ sleep 0.5
384
+ end
385
+ true
386
+ end
387
+
388
+ # ask-local start — one-setup-and-go: idempotent workstation setup
389
+ # (trust, 443, hosts) when doctor fails, then boots the app in the
390
+ # current directory. The single command you run day-to-day; existing
391
+ # bare `ask-local` keeps working via BootCommand.run_inferred, and
392
+ # `setup` stays for explicit re-setup.
393
+ def start(ctx, args)
394
+ if args.include?("--help") || args.include?("-h")
395
+ puts <<~HELP
396
+ Usage: ask-local start [name] [cmd...] [options]
397
+
398
+ One-setup-and-go: if the workstation isn't ready (CA, proxy,
399
+ hosts), runs the minimal needed setup first, then boots the
400
+ app in the current directory.
401
+
402
+ ask-local start # infer name, boot -> https://<app>.localhost
403
+ ask-local start myapp # explicit name
404
+ ask-local start -- --help # pass --help to the app, not here
405
+
406
+ Options are passed through to the boot path:
407
+ --name <name> --service <svc> --variant <v> --tld <tld> --branch
408
+
409
+ Setup failures become hard errors pointing at `ask-local setup`;
410
+ non-interactive CI without a running proxy exits immediately.
411
+ HELP
412
+ return
413
+ end
414
+
415
+ # Fast path: every doctor check passes => skip setup entirely.
416
+ # This makes `start` as fast as `ask-local` on a ready machine.
417
+ if needs_workstation_setup?(ctx)
418
+ ensure_workstation!(ctx)
419
+ end
420
+ BootCommand.run_inferred(ctx, args)
421
+ end
422
+
423
+ def needs_workstation_setup?(ctx)
424
+ Ask::Local::Doctor.run(store: ctx.store).any? { |c| !c.ok }
425
+ end
426
+
427
+
428
+ # Quiet workstation setup for `start`: trust the CA, ensure a proxy
429
+ # on 443 (root service, sudo daemon fallback), and sync hosts.
430
+ # Each step is idempotent; only missing pieces run. Non-interactive
431
+ # CI without a proxy fails fast rather than prompting for sudo.
432
+ def ensure_workstation!(ctx)
433
+ # 1. CA
434
+ unless Ask::Local::Certs.trusted?(ctx.store.dir)
435
+ result = Ask::Local::Trust.trust
436
+ unless result[:trusted]
437
+ abort_setup("CA trust failed: #{result[:error]}",
438
+ "Run `ask-local setup` in a terminal (handles trust + service),",
439
+ "then re-run `ask-local start`.")
440
+ end
441
+ end
442
+
443
+ # 2. Proxy on 443
444
+ port = 443
445
+ tls = true
446
+ unless Ask::Local::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
447
+ if port < 1024 && !Ask::Local::ProxyControl.root? && !ctx.interactive?
448
+ abort_setup("Proxy is not running and port 443 needs root to bind.",
449
+ "Run this once in a terminal: ask-local setup")
450
+ end
451
+ ok =
452
+ if ProxyControl.root?
453
+ Ask::Local::CLI::SystemCommand.ensure_root_service(ctx)
454
+ elsif ctx.interactive?
455
+ begin
456
+ Ask::Local::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
457
+ wait_for_ours(ctx, port, tls: tls)
458
+ rescue Ask::Local::ProxyNotRunningError, SystemCallError => e
459
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
460
+ false
461
+ end
462
+ else
463
+ false
464
+ end
465
+ unless ok
466
+ abort_setup("Proxy is not running and could not be started on port 443.",
467
+ "Run `ask-local setup` in a terminal (it handles trust + service + hosts),",
468
+ "then re-run `ask-local start`.")
469
+ end
470
+ end
471
+
472
+ # 3. Hosts (best-effort: only needed for Safari; warn, don't fail)
473
+ unless Hosts.sync(ctx.store.load_routes.map { |r| r["hostname"] })
474
+ warn "Warning: could not write /etc/hosts (try sudo ask-local hosts sync)."
475
+ end
476
+ end
266
477
  # ask-local kamal <variant> [--app myapp] [--domain preview.example.com]
267
478
  def kamal(_ctx, args)
268
479
  opts = {}
@@ -273,11 +484,13 @@ module Ask
273
484
  case a[i]
274
485
  when "--app" then opts[:app] = a.fetch(i + 1); i += 2
275
486
  when "--domain" then opts[:domain] = a.fetch(i + 1); i += 2
487
+ when "--tld" then opts[:tld] = a.fetch(i + 1); i += 2
276
488
  else rest << a[i]; i += 1
277
489
  end
278
490
  end
279
491
  variant = rest.first
280
- raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com]" unless variant
492
+ raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com] [--tld <tld>]" unless variant
493
+ tld = opts[:tld] || ENV["ASK_LOCAL_TLD"]&.split(",")&.first || "localhost"
281
494
 
282
495
  app = opts[:app] || Resolver.resolve(Dir.pwd, use_branch: false).app
283
496
  domain = opts[:domain] || ENV["ASK_LOCAL_KAMAL_DOMAIN"] || "preview.example.com"
@@ -287,6 +500,7 @@ module Ask
287
500
  puts " ssl: true"
288
501
  puts " hosts:"
289
502
  puts " - #{app}-#{slug}.#{domain}"
503
+ puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
290
504
  end
291
505
  end
292
506
  end
data/lib/ask/local/cli.rb CHANGED
@@ -11,7 +11,7 @@ module Ask
11
11
  # In non-interactive environments (no TTY or CI=1) we fail early with
12
12
  # a clear message instead of prompting (portless lesson).
13
13
  class CLI
14
- SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open].freeze
14
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open setup start].freeze
15
15
 
16
16
  def self.run(argv)
17
17
  new.run(argv)
@@ -44,6 +44,8 @@ module Ask
44
44
  when "prune" then RoutesCommand.prune(ctx, args)
45
45
  when "proxy" then SystemCommand.proxy(ctx, args)
46
46
  when "service" then SystemCommand.service(ctx, args)
47
+ when "setup" then SystemCommand.setup(ctx, args)
48
+ when "start" then SystemCommand.start(ctx, args)
47
49
  when "kamal" then SystemCommand.kamal(ctx, args)
48
50
  when "stop"
49
51
  exit RoutesCommand.stop(ctx, args)
@@ -64,7 +66,9 @@ module Ask
64
66
  ask-local - Stable named .localhost URLs for Ruby development.
65
67
 
66
68
  Usage:
67
- ask-local Infer name, boot app -> https://<app>.localhost
69
+ ask-local start [name] [cmd...] One-setup-and-go: setup if needed, then boot -> https://<app>.localhost
70
+ ask-local setup One-shot workstation setup without booting (run once)
71
+ ask-local Bare form of `start` -> https://<app>.localhost
68
72
  ask-local run [cmd] Same, with explicit command
69
73
  ask-local <name> <cmd> Run with explicit name
70
74
  ask-local get <name> Print URL for a service
@@ -64,30 +64,51 @@ module Ask
64
64
  # where only one family answers the check must still succeed.
65
65
  # An explicit regression test pins this (health_test pinning
66
66
  # ensure_proxy's "is that ours" logic against future proxy changes).
67
+ #
68
+ # Probe order matters: plain HTTP first (our proxy byte-peeks and
69
+ # answers plain HTTP even on the TLS port), TLS second. A TLS-first
70
+ # handshake against a foreign plain-HTTP server blocks in connect
71
+ # waiting for a ServerHello that never comes — and connect used to
72
+ # sit outside the timeout, hanging ensure_proxy for over a minute.
73
+ #
74
+ # Speed: if the plain probe gets ANY HTTP response without our
75
+ # header, the server is definitively foreign — no TLS retry. The
76
+ # slow TLS retry only happens when plain yielded zero bytes
77
+ # (connection error, EOF, or timeout against a silent server).
67
78
  def ours?(port, tls:)
68
- probe_ours(port, tls: tls, host: "127.0.0.1") ||
69
- probe_ours(port, tls: tls, host: "::1")
79
+ ["127.0.0.1", "::1"].any? do |host|
80
+ case probe_once(port, tls: false, host: host)
81
+ when :ours then true
82
+ when :foreign then false
83
+ else tls ? probe_once(port, tls: true, host: host) == :ours : false
84
+ end
85
+ end
70
86
  end
71
87
 
72
- def probe_ours(port, tls:, host:)
73
- sock = TCPSocket.new(host, port)
74
- if tls
75
- ctx = OpenSSL::SSL::SSLContext.new
76
- ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
77
- sock = OpenSSL::SSL::SSLSocket.new(sock, ctx)
78
- sock.connect
79
- end
80
- Timeout.timeout(3) do
88
+ # Three outcomes: :ours (our header present), :foreign (an HTTP
89
+ # response without it), :unknown (no response at all).
90
+ def probe_once(port, tls:, host:)
91
+ sock = nil
92
+ Timeout.timeout(5) do
93
+ sock = TCPSocket.new(host, port)
94
+ if tls
95
+ ctx = OpenSSL::SSL::SSLContext.new
96
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
97
+ sock = OpenSSL::SSL::SSLSocket.new(sock, ctx)
98
+ sock.connect
99
+ end
81
100
  sock.write("GET / HTTP/1.1\r\nHost: ask-local-health.invalid\r\nConnection: close\r\n\r\n")
82
101
  head = +""
83
102
  while (chunk = sock.readpartial(4096))
84
103
  head << chunk
85
104
  break if head.include?("\r\n\r\n")
86
105
  end
87
- head.downcase.include?("x-ask-local: 1")
106
+ return :unknown if head.empty?
107
+
108
+ return head.downcase.include?("x-ask-local: 1") ? :ours : :foreign
88
109
  end
89
110
  rescue SystemCallError, OpenSSL::SSL::SSLError, Timeout::Error, IOError, EOFError
90
- false
111
+ :unknown
91
112
  ensure
92
113
  begin
93
114
  sock&.close
@@ -96,6 +117,10 @@ module Ask
96
117
  end
97
118
  end
98
119
 
120
+ def probe_ours(port, tls:, host:)
121
+ probe_once(port, tls: tls, host: host) == :ours
122
+ end
123
+
99
124
  def pid_alive?(pid)
100
125
  Process.kill(0, pid)
101
126
  true
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Local
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.1"
6
6
  end
7
7
  end
@@ -7,6 +7,14 @@ description: Run Ruby apps through ask-local for stable named .localhost URLs (e
7
7
 
8
8
  Never invent ports. Never parse them from logs. Every app has a stable URL.
9
9
 
10
+ First time on a machine, run `ask-local start` in any app dir — it does
11
+ the one-shot CA trust, port 443, and hosts sync if anything is missing,
12
+ then boots. Prefer `ask-local setup` for
13
+ workstation setup without booting. Every later `ask-local` or
14
+ hosts, verify). If any command fails with a privileged-port error, do
15
+ not work around it with `-p` — run `ask-local setup` instead. A `:port`
16
+ suffix in a URL means someone explicitly opted into it.
17
+
10
18
  ## Booting apps
11
19
 
12
20
  ```bash
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-local
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -73,6 +73,7 @@ email:
73
73
  - kaka@myrrlabs.com
74
74
  executables:
75
75
  - ask-local
76
+ - askl
76
77
  extensions: []
77
78
  extra_rdoc_files: []
78
79
  files:
@@ -80,6 +81,7 @@ files:
80
81
  - LICENSE
81
82
  - README.md
82
83
  - bin/ask-local
84
+ - bin/askl
83
85
  - lib/ask-local.rb
84
86
  - lib/ask/local/certs.rb
85
87
  - lib/ask/local/cli.rb