ask-local 0.1.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.
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "monitor"
5
+
6
+ module Ask
7
+ module Local
8
+ # Daemon-owned supervision for managed socket apps (puma-dev model).
9
+ #
10
+ # The proxy daemon is long-lived and sees every request, so it owns:
11
+ # last-used tracking (idle kill), tmp/restart.txt watching, backend
12
+ # liveness, and boot-on-request for stopped apps. Run-mode (tcp)
13
+ # routes and static aliases are never supervised.
14
+ #
15
+ # State is in-memory (the daemon is the only supervisor); routes.json
16
+ # stays declarative. Killing is graceful-first with a short KILL
17
+ # fallback so puma drains.
18
+ class Supervisor
19
+ DEFAULT_IDLE = 900 # 15 minutes
20
+
21
+ attr_reader :interval
22
+
23
+ def initialize(store:, runner:, interval: 5.0, idle_timeout: nil, on_event: nil)
24
+ @store = store
25
+ @runner = runner
26
+ @interval = interval
27
+ @idle_timeout = idle_timeout || Supervisor.env_idle || DEFAULT_IDLE
28
+ @on_event = on_event || ->(msg) { warn "[ask-local] #{msg}" }
29
+ # Monitor (reentrant): touch → st → state_for nest legitimately.
30
+ @state = {}.extend(MonitorMixin)
31
+ @boot_locks = {}
32
+ @thread = nil
33
+ end
34
+
35
+ def self.env_idle
36
+ v = ENV["ASK_LOCAL_IDLE_TIMEOUT"]
37
+ return nil unless v && v.to_f >= 0
38
+
39
+ v.to_f
40
+ end
41
+
42
+ def supervised?(route)
43
+ route["kind"] == "socket" && route["spec"].is_a?(Hash) && route["spec"]["dir"].is_a?(String)
44
+ end
45
+
46
+ def touch(hostname)
47
+ @state.synchronize do
48
+ st(hostname)[:last_used] = clock_now
49
+ end
50
+ end
51
+
52
+ # Boot a stopped app on demand. Returns the route (unchanged — the
53
+ # target path is deterministic) or nil on boot failure.
54
+ def ensure_running(route, timeout: 60)
55
+ return route unless supervised?(route)
56
+
57
+ socket_alive?(route) ? route : boot(route, timeout)
58
+ end
59
+
60
+ # Start the background supervision thread.
61
+ def start
62
+ @thread ||= Thread.new do
63
+ loop do
64
+ sleep @interval
65
+ begin
66
+ tick
67
+ rescue StandardError => e
68
+ @on_event.call("supervision error: #{e.message}")
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ def stop
75
+ @thread&.kill
76
+ @thread = nil
77
+ end
78
+
79
+ # One supervision pass (public for tests): kills idle/dead/restarted
80
+ # backends. Rebooting happens lazily on the next request.
81
+ def tick(now = clock_now)
82
+ @store.load_routes.each do |route|
83
+ next unless supervised?(route)
84
+
85
+ hostname = route["hostname"]
86
+ st = state_for(hostname, route, now)
87
+ next if st[:restarting]
88
+
89
+ if restart_changed?(route, st)
90
+ @on_event.call("restart.txt changed for #{hostname} — stopping backend")
91
+ kill_backend(route)
92
+ st[:restarting] = true
93
+ elsif !socket_alive?(route)
94
+ @on_event.call("backend for #{hostname} is down — will boot on next request")
95
+ st[:restarting] = true
96
+ elsif idle?(st, now)
97
+ @on_event.call("#{hostname} idle — stopping backend (boots on next request)")
98
+ kill_backend(route)
99
+ st[:restarting] = true
100
+ end
101
+ end
102
+ end
103
+
104
+ # Kill every supervised backend (daemon shutdown).
105
+ def shutdown
106
+ @store.load_routes.each do |route|
107
+ kill_backend(route) if supervised?(route)
108
+ end
109
+ end
110
+
111
+ def idle?(st, now)
112
+ return false if @idle_timeout.zero?
113
+ return false unless st[:last_used]
114
+
115
+ (now - st[:last_used]) > @idle_timeout
116
+ end
117
+
118
+ def socket_alive?(route)
119
+ target = route["target"]
120
+ return false unless target && File.socket?(target)
121
+
122
+ UNIXSocket.new(target).close
123
+ true
124
+ rescue SystemCallError, IOError
125
+ false
126
+ end
127
+
128
+ def backend_pid(route)
129
+ sidecar = File.join(@store.dir, "backend-#{route["hostname"]}.pid")
130
+ return nil unless File.file?(sidecar)
131
+
132
+ pid = File.read(sidecar).strip.to_i
133
+ pid.positive? ? pid : nil
134
+ rescue SystemCallError, ArgumentError
135
+ nil
136
+ end
137
+
138
+ def kill_backend(route)
139
+ pid = backend_pid(route)
140
+ if pid && pid_alive?(pid)
141
+ begin
142
+ Process.kill("TERM", pid)
143
+ wait_exit(pid, 10)
144
+ rescue SystemCallError
145
+ nil
146
+ end
147
+ end
148
+ FileUtils.rm_f(route["target"]) if route["target"]
149
+ FileUtils.rm_f(File.join(@store.dir, "backend-#{route["hostname"]}.pid"))
150
+ end
151
+
152
+ private
153
+
154
+ def clock_now
155
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
156
+ end
157
+
158
+ def state_for(hostname, route, now)
159
+ @state.synchronize do
160
+ s = st(hostname)
161
+ # First sighting: baseline so a freshly booted app gets a full
162
+ # idle window and an existing restart.txt doesn't count as changed.
163
+ s[:restart_mtime] = restart_mtime(route) if s[:restart_mtime].nil?
164
+ s[:last_used] = now if s[:last_used].nil?
165
+ s
166
+ end
167
+ end
168
+
169
+ def st(hostname)
170
+ @state.synchronize { @state[hostname] ||= {} }
171
+ end
172
+
173
+ def restart_mtime(route)
174
+ path = restart_path(route)
175
+ path && File.exist?(path) ? File.mtime(path) : nil
176
+ rescue SystemCallError
177
+ nil
178
+ end
179
+
180
+ def restart_changed?(route, st)
181
+ path = restart_path(route)
182
+ return false unless path
183
+
184
+ current = File.exist?(path) ? File.mtime(path) : nil
185
+ if current != st[:restart_mtime]
186
+ st[:restart_mtime] = current
187
+ true
188
+ else
189
+ false
190
+ end
191
+ rescue SystemCallError
192
+ false
193
+ end
194
+
195
+ def restart_path(route)
196
+ dir = route.dig("spec", "dir")
197
+ dir ? File.join(dir, "tmp", "restart.txt") : nil
198
+ end
199
+
200
+ def boot(route, timeout)
201
+ hostname = route["hostname"]
202
+ lock = @boot_locks[hostname] ||= Mutex.new
203
+ lock.synchronize do
204
+ # Double-check under the boot lock.
205
+ return route if socket_alive?(route)
206
+
207
+ spec = route["spec"]
208
+ @on_event.call("booting #{hostname} on request (#{spec["dir"]})")
209
+ url = Hostname.url(hostname, port: ProxyControl.proxy_port(@store),
210
+ tls: ProxyControl.proxy_tls(@store))
211
+ @runner.boot_supervised(name: hostname, hostname: hostname,
212
+ url: url, dir: spec["dir"])
213
+ st = st(hostname)
214
+ st[:last_used] = clock_now
215
+ route
216
+ end
217
+ rescue StandardError => e
218
+ @on_event.call("boot failed for #{hostname}: #{e.message.lines.first&.strip}")
219
+ nil
220
+ end
221
+
222
+ def wait_exit(pid, timeout)
223
+ deadline = clock_now + timeout
224
+ while clock_now < deadline
225
+ return unless pid_alive?(pid)
226
+
227
+ sleep 0.2
228
+ end
229
+ begin
230
+ Process.kill("KILL", pid)
231
+ rescue SystemCallError
232
+ nil
233
+ end
234
+ end
235
+
236
+ def pid_alive?(pid)
237
+ Process.kill(0, pid)
238
+ true
239
+ rescue SystemCallError
240
+ false
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ask
6
+ module Local
7
+ # Install the local CA into the OS trust store.
8
+ module Trust
9
+ module_function
10
+
11
+ def platform
12
+ case RUBY_PLATFORM
13
+ when /darwin/ then :macos
14
+ when /linux/ then :linux
15
+ when /mingw|mswin/ then :windows
16
+ else :unknown
17
+ end
18
+ end
19
+
20
+ def trust(dir = Certs.state_dir)
21
+ paths = Certs.ensure_ca(dir)
22
+ case platform
23
+ when :macos then trust_macos(paths[:cert])
24
+ when :linux then trust_linux(paths[:cert])
25
+ when :windows then trust_windows(paths[:cert])
26
+ else return { trusted: false, error: "Unsupported platform: #{RUBY_PLATFORM}" }
27
+ end
28
+ Certs.mark_trusted(dir)
29
+ { trusted: true }
30
+ rescue StandardError => e
31
+ { trusted: false, error: e.message }
32
+ end
33
+
34
+ def trust_macos(cert_path)
35
+ keychain = login_keychain
36
+ _out, status = Open3.capture2("security", "add-trusted-cert",
37
+ "-r", "trustRoot", "-k", keychain, cert_path)
38
+ raise CertError, "security add-trusted-cert failed" unless status.success?
39
+ end
40
+
41
+ def login_keychain
42
+ out, status = Open3.capture2("security", "default-keychain")
43
+ if status.success? && (m = out.match(/"(.+)"/))
44
+ m[1]
45
+ else
46
+ File.join(Certs.home, "Library", "Keychains", "login.keychain-db")
47
+ end
48
+ end
49
+
50
+ def trust_linux(cert_path)
51
+ dest_dir, update_cmd = linux_ca_config
52
+ FileUtils.mkdir_p(dest_dir)
53
+ FileUtils.cp(cert_path, File.join(dest_dir, "ask-local-ca.crt"))
54
+ _out, status = Open3.capture2(update_cmd)
55
+ raise CertError, "#{update_cmd} failed" unless status.success?
56
+ end
57
+
58
+ def linux_ca_config
59
+ os_release = begin
60
+ File.read("/etc/os-release").downcase
61
+ rescue SystemCallError
62
+ ""
63
+ end
64
+ if os_release.include?("arch")
65
+ ["/etc/ca-certificates/trust-source/anchors", "update-ca-trust"]
66
+ elsif os_release.match?(/fedora|rhel|centos/)
67
+ ["/etc/pki/ca-trust/source/anchors", "update-ca-trust"]
68
+ elsif os_release.include?("suse")
69
+ ["/etc/pki/trust/anchors", "update-ca-certificates"]
70
+ else
71
+ ["/usr/local/share/ca-certificates", "update-ca-certificates"]
72
+ end
73
+ end
74
+
75
+ def trust_windows(cert_path)
76
+ _out, status = Open3.capture2("certutil", "-addstore", "-user", "Root", cert_path)
77
+ raise CertError, "certutil failed" unless status.success?
78
+ end
79
+
80
+ # Best-effort removal of the CA from the OS trust store. Only
81
+ # attempts when our marker says we trusted it; used by `clean`.
82
+ def untrust(dir = Certs.state_dir)
83
+ return { removed: true } unless Certs.trusted?(dir)
84
+
85
+ paths = Certs.ca_paths(dir)
86
+ errors = []
87
+ case platform
88
+ when :macos
89
+ Open3.capture2("security", "remove-trusted-cert", paths[:cert])
90
+ # delete-certificate fails silently when no match remains; loop
91
+ # to clear duplicate CN entries from each keychain.
92
+ [login_keychain, "/Library/Keychains/System.keychain"].each do |kc|
93
+ 5.times do
94
+ Open3.capture2("security", "delete-certificate", "-c", CA_COMMON_NAME, kc)
95
+ end
96
+ rescue SystemCallError
97
+ nil
98
+ end
99
+ when :linux
100
+ dest_dir, update_cmd = linux_ca_config
101
+ dest = File.join(dest_dir, "ask-local-ca.crt")
102
+ FileUtils.rm_f(dest) if File.file?(dest)
103
+ Open3.capture2(update_cmd)
104
+ when :windows
105
+ Open3.capture2("certutil", "-delstore", "-user", "Root", CA_COMMON_NAME)
106
+ end
107
+ trusted_after = begin
108
+ Certs.trusted?(dir)
109
+ rescue StandardError
110
+ false
111
+ end
112
+ File.unlink(File.join(dir, "ca.trusted")) if File.file?(File.join(dir, "ca.trusted"))
113
+ if trusted_after
114
+ { removed: false, error: errors.empty? ? "CA still trusted (remove manually)" : errors.join("; ") }
115
+ else
116
+ { removed: true }
117
+ end
118
+ rescue StandardError => e
119
+ { removed: false, error: e.message }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "pathname"
5
+
6
+ module Ask
7
+ module Local
8
+ # Variant resolution: the malleable axis of the hostname.
9
+ #
10
+ # Precedence: explicit --variant flag -> ASK_LOCAL_VARIANT env ->
11
+ # linked git worktree branch -> current git branch (opt-in via
12
+ # --branch / ASK_LOCAL_BRANCH=1) -> none.
13
+ #
14
+ # Main/master (and detached HEAD) never produce a variant.
15
+ module Variant
16
+ DEFAULT_BRANCHES = %w[main master].freeze
17
+
18
+ module_function
19
+
20
+ # Returns [variant, source] or nil.
21
+ def resolve(cwd = Dir.pwd, explicit: nil, use_branch: false)
22
+ return labeled(explicit, "flag") if present?(explicit)
23
+
24
+ env = ENV["ASK_LOCAL_VARIANT"]
25
+ return labeled(env, "ASK_LOCAL_VARIANT") if present?(env)
26
+
27
+ worktree = worktree_prefix(cwd)
28
+ return [worktree, "git worktree"] if worktree
29
+
30
+ branch_flag = use_branch || %w[1 true].include?(ENV["ASK_LOCAL_BRANCH"])
31
+ return nil unless branch_flag
32
+
33
+ branch = current_branch(cwd)
34
+ prefix = branch_to_prefix(branch)
35
+ prefix ? [prefix, "git branch"] : nil
36
+ end
37
+
38
+ def apply(base_name, variant)
39
+ variant ? "#{variant}.#{base_name}" : base_name
40
+ end
41
+
42
+ # NOTE: do not add a `private` keyword in this module — it would
43
+ # cancel `module_function` mode and demote the helpers below to
44
+ # plain private instance methods. They stay module functions
45
+ # (private as instance methods) by omitting it.
46
+ def present?(value)
47
+ !value.nil? && !value.to_s.strip.empty?
48
+ end
49
+
50
+ def labeled(value, source)
51
+ label = Sanitize.hostname_label(value)
52
+ label.empty? ? nil : [label, source]
53
+ end
54
+
55
+ def branch_to_prefix(branch)
56
+ return nil if branch.nil? || branch.empty?
57
+ return nil if branch == "HEAD" || DEFAULT_BRANCHES.include?(branch)
58
+
59
+ last = branch.split("/").last.to_s
60
+ label = Sanitize.hostname_label(last)
61
+ label.empty? ? nil : label
62
+ end
63
+
64
+ # Only linked worktrees (created via `git worktree add`) get a prefix.
65
+ # Developers on feature branches in their main checkout keep the bare name.
66
+ def worktree_prefix(cwd)
67
+ list_out, list_status = git(cwd, "worktree", "list", "--porcelain")
68
+ return nil unless list_status.success?
69
+
70
+ count = list_out.lines.count { |l| l.start_with?("worktree ") }
71
+ return nil if count <= 1
72
+
73
+ git_dir, s1 = git(cwd, "rev-parse", "--git-dir")
74
+ common_dir, s2 = git(cwd, "rev-parse", "--git-common-dir")
75
+ return nil unless s1.success? && s2.success?
76
+
77
+ # Same dir => main worktree, no prefix.
78
+ expanded = File.expand_path(git_dir.strip, cwd)
79
+ expanded_common = File.expand_path(common_dir.strip, cwd)
80
+ return nil if expanded == expanded_common
81
+
82
+ branch, s3 = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
83
+ return nil unless s3.success?
84
+
85
+ branch_to_prefix(branch.strip)
86
+ rescue SystemCallError
87
+ filesystem_worktree_prefix(cwd)
88
+ end
89
+
90
+ # Fallback when git CLI is unavailable: a linked worktree has a .git
91
+ # FILE pointing into a /worktrees/ path (submodules point to /modules/).
92
+ def filesystem_worktree_prefix(cwd)
93
+ dir = Pathname.new(File.expand_path(cwd))
94
+ until dir.root?
95
+ git_path = dir.join(".git")
96
+ if git_path.file?
97
+ content = git_path.read.strip
98
+ match = content.match(/\Agitdir:\s*(.+)\z/)
99
+ if match && match[1].match?(%r{[/\\]worktrees[/\\][^/\\]+\z})
100
+ head = File.join(File.expand_path(match[1], dir.to_s), "HEAD")
101
+ branch = read_branch_from_head(head)
102
+ prefix = branch_to_prefix(branch.to_s)
103
+ return prefix ? [prefix, "git worktree"].first : nil
104
+ end
105
+ return nil
106
+ end
107
+ return nil if git_path.directory?
108
+
109
+ dir = dir.parent
110
+ end
111
+ nil
112
+ rescue SystemCallError
113
+ nil
114
+ end
115
+
116
+ def read_branch_from_head(head_path)
117
+ content = File.read(head_path).strip
118
+ match = content.match(%r{\Aref:\s*refs/heads/(.+)\z})
119
+ match && match[1]
120
+ rescue SystemCallError
121
+ nil
122
+ end
123
+
124
+ def current_branch(cwd)
125
+ out, status = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
126
+ status.success? ? out.strip : nil
127
+ end
128
+
129
+ def git(cwd, *args)
130
+ Open3.capture2("git", *args, chdir: cwd, err: File::NULL)
131
+ rescue SystemCallError, ArgumentError
132
+ ["", nil]
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,89 @@
1
+ ---
2
+ name: ask-local
3
+ description: Run Ruby apps through ask-local for stable named .localhost URLs (e.g. https://myapp.localhost instead of http://localhost:3000). Use when booting dev servers (Rails, Rack, Roda, Sinatra, Jekyll, Procfile apps), wiring frontend to API, configuring OAuth callbacks or webhooks, debugging port conflicts, or working in git worktrees.
4
+ ---
5
+
6
+ # Local Dev with ask-local
7
+
8
+ Never invent ports. Never parse them from logs. Every app has a stable URL.
9
+
10
+ ## Booting apps
11
+
12
+ ```bash
13
+ ask-local # infer name, boot -> https://<app>.localhost
14
+ ask-local --service api # -> https://api.myapp.localhost
15
+ ask-local run -- bin/dev # Procfile apps, PORT injected
16
+ ask-local run --proc worker # boot a specific Procfile process
17
+ ```
18
+
19
+ Managed apps are supervised by the proxy daemon: they idle-stop after 15
20
+ minutes (`ASK_LOCAL_IDLE_TIMEOUT`), stop when `tmp/restart.txt` is
21
+ touched, and boot again on the next request.
22
+
23
+ ## Lifecycle
24
+
25
+ ```bash
26
+ ask-local stop # stop this app's backend + routes
27
+ ask-local restart # touch tmp/restart.txt (supervised reboot)
28
+ ask-local log [n] # tail this app's backend log
29
+ ```
30
+
31
+ The runner injects `ASK_LOCAL_URL`, `PORT`, and `HOST=127.0.0.1` into the
32
+ child. In Rails, read it via `Ask::Local::Rails.url` — never hardcode
33
+ `localhost:3000`.
34
+
35
+ ## Cross-service wiring
36
+
37
+ ```bash
38
+ ask-local get backend # -> https://backend.localhost
39
+ ```
40
+
41
+ Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
42
+ targets. Do not guess ports.
43
+
44
+ ## Variants (worktrees, branches, demos)
45
+
46
+ Hostnames compose as `{variant}.{service}.{app}.{tld}`. Linked worktrees
47
+ get a branch prefix automatically (`fix-ui.myapp.localhost`); main keeps
48
+ the bare name. Override with `--variant` or `ASK_LOCAL_VARIANT`.
49
+
50
+ ## OAuth and webhooks
51
+
52
+ Build callback URLs from `ASK_LOCAL_URL`:
53
+
54
+ ```ruby
55
+ callback = "#{Ask::Local::Rails.url}/auth/google/callback"
56
+ ```
57
+
58
+ Strict providers (Google, Apple) reject `.localhost`. Serve the app on a
59
+ domain you own instead — no code change:
60
+
61
+ ```bash
62
+ ask-local --tld local.example.com # -> https://myapp.local.example.com
63
+ ```
64
+
65
+ ## Troubleshooting
66
+
67
+ ```bash
68
+ ask-local doctor # read-only: proxy, routes, DNS, CA trust
69
+ ask-local list # active routes
70
+ ask-local prune # clear stale routes from crashed sessions
71
+ ```
72
+
73
+ Prefer `list --json`, `status --json`, and `doctor --json` when parsing
74
+ output programmatically — stable keys, no prose scraping.
75
+
76
+ If a hostname does not resolve: `ask-local hosts sync`. If the browser
77
+ warns about TLS: `ask-local trust`. Never run dev servers on bare ports
78
+ alongside ask-local — they bypass routing and reintroduce conflicts.
79
+
80
+ ## When NOT to use ask-local
81
+
82
+ - **CI pipelines**: no TTY, no sudo, no browsers. Run the app's own test
83
+ command directly; ask-local fails fast here by design.
84
+ - **Production consoles and servers**: the proxy binds loopback only and
85
+ the CA is self-signed. Use Kamal + kamal-proxy for anything real.
86
+ - **Docker-internal networking**: containers reach each other by service
87
+ name on the compose network, not via the host's `.localhost`.
88
+ - **Debugging the proxy itself**: use `ask-local proxy start --foreground`
89
+ and read the log; do not layer another ask-local on top.
data/lib/ask-local.rb ADDED
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ask/local/version"
4
+ require_relative "ask/local/errors"
5
+ require_relative "ask/local/sanitize"
6
+ require_relative "ask/local/hostname"
7
+ require_relative "ask/local/inference"
8
+ require_relative "ask/local/variant"
9
+ require_relative "ask/local/framework"
10
+ require_relative "ask/local/config"
11
+ require_relative "ask/local/ownership"
12
+ require_relative "ask/local/log"
13
+ require_relative "ask/local/route_store"
14
+ require_relative "ask/local/certs"
15
+ require_relative "ask/local/ports"
16
+ require_relative "ask/local/hosts"
17
+ require_relative "ask/local/proxy"
18
+ require_relative "ask/local/proxy_control"
19
+ require_relative "ask/local/supervisor"
20
+ require_relative "ask/local/runner"
21
+ require_relative "ask/local/resolver"
22
+ require_relative "ask/local/trust"
23
+ require_relative "ask/local/doctor"
24
+ require_relative "ask/local/cli/context"
25
+ require_relative "ask/local/cli/boot"
26
+ require_relative "ask/local/cli/routes"
27
+ require_relative "ask/local/cli/system"
28
+ require_relative "ask/local/cli"
29
+
30
+ # Stable named .localhost URLs for Ruby development.
31
+ #
32
+ # Ask::Local replaces memorized ports with stable hostnames:
33
+ # `ask-local` in your app dir boots it at https://<app>.localhost.
34
+ module Ask
35
+ module Local
36
+ end
37
+ end