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 +4 -4
- data/CHANGELOG.md +73 -32
- data/README.md +29 -0
- data/bin/askl +6 -0
- data/lib/ask/local/cli/boot.rb +31 -8
- data/lib/ask/local/cli/system.rb +215 -1
- data/lib/ask/local/cli.rb +6 -2
- data/lib/ask/local/proxy_control.rb +38 -13
- data/lib/ask/local/version.rb +1 -1
- data/lib/ask/skills/ask-local/SKILL.md +8 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a9b2c40e96d2ccde2095d698a79684a3dec1335afe9ac4acc1773405f520002d
|
|
4
|
+
data.tar.gz: a2a63d630679488b6236a499bb954fa2cf70028c2f00d5dd34c2f76a21ef057b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
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
data/lib/ask/local/cli/boot.rb
CHANGED
|
@@ -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 "
|
|
242
|
-
$stderr.puts "
|
|
243
|
-
$stderr.puts "
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/ask/local/cli/system.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
sock =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
data/lib/ask/local/version.rb
CHANGED
|
@@ -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.
|
|
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
|