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 +4 -4
- data/CHANGELOG.md +73 -32
- data/README.md +58 -9
- data/bin/askl +6 -0
- data/lib/ask/local/cli/boot.rb +150 -159
- data/lib/ask/local/cli/routes.rb +17 -13
- data/lib/ask/local/cli/system.rb +272 -2
- data/lib/ask/local/cli.rb +7 -3
- data/lib/ask/local/config.rb +342 -59
- data/lib/ask/local/procfile.rb +139 -0
- data/lib/ask/local/proxy_control.rb +38 -13
- data/lib/ask/local/resolver.rb +62 -94
- data/lib/ask/local/runner.rb +8 -3
- data/lib/ask/local/version.rb +1 -1
- data/lib/ask/skills/ask-local/SKILL.md +57 -33
- data/lib/ask-local.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7cab052002c8e0c03a4ed39fd2ff9b4d43474b6e2b969cd5ba0a5e5122c0229b
|
|
4
|
+
data.tar.gz: 600b5127b34523e88d1fe8a08434309fd7690ab33217f7eb33fbf18b4d3f851b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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-
|
|
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,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
|
-
##
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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)
|
|
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` |
|
|
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
data/lib/ask/local/cli/boot.rb
CHANGED
|
@@ -3,109 +3,143 @@
|
|
|
3
3
|
module Ask
|
|
4
4
|
module Local
|
|
5
5
|
class CLI
|
|
6
|
-
# Boot commands:
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
59
|
-
primary
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return nil unless
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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 "
|
|
242
|
-
$stderr.puts "
|
|
243
|
-
$stderr.puts "Or
|
|
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:
|
|
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
|