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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +119 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/bin/ask-local +6 -0
- data/lib/ask/local/certs.rb +179 -0
- data/lib/ask/local/cli/boot.rb +255 -0
- data/lib/ask/local/cli/context.rb +106 -0
- data/lib/ask/local/cli/routes.rb +253 -0
- data/lib/ask/local/cli/system.rb +294 -0
- data/lib/ask/local/cli.rb +104 -0
- data/lib/ask/local/config.rb +113 -0
- data/lib/ask/local/doctor.rb +135 -0
- data/lib/ask/local/errors.rb +20 -0
- data/lib/ask/local/framework.rb +77 -0
- data/lib/ask/local/hostname.rb +46 -0
- data/lib/ask/local/hosts.rb +80 -0
- data/lib/ask/local/inference.rb +144 -0
- data/lib/ask/local/log.rb +61 -0
- data/lib/ask/local/ownership.rb +62 -0
- data/lib/ask/local/ports.rb +47 -0
- data/lib/ask/local/proxy.rb +480 -0
- data/lib/ask/local/proxy_control.rb +199 -0
- data/lib/ask/local/resolver.rb +126 -0
- data/lib/ask/local/route_store.rb +150 -0
- data/lib/ask/local/runner.rb +240 -0
- data/lib/ask/local/sanitize.rb +43 -0
- data/lib/ask/local/supervisor.rb +244 -0
- data/lib/ask/local/trust.rb +123 -0
- data/lib/ask/local/variant.rb +136 -0
- data/lib/ask/local/version.rb +7 -0
- data/lib/ask/skills/ask-local/SKILL.md +89 -0
- data/lib/ask-local.rb +37 -0
- metadata +135 -0
|
@@ -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,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
|