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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ # Resolve the effective {app, service, variant, tlds} for a directory.
6
+ #
7
+ # Precedence per axis: CLI flag > ENV > ask-local.json > inference.
8
+ # Variant adds linked-worktree branch and opt-in current branch.
9
+ module Resolver
10
+ module_function
11
+
12
+ Result = Struct.new(:app, :service, :variant, :tlds, :sources, keyword_init: true)
13
+
14
+ def resolve(dir = Dir.pwd, name: nil, service: nil, variant: nil,
15
+ tlds: nil, use_branch: false)
16
+ config, config_dir = find_config(dir)
17
+ app_cfg = config ? config.app_config(dir) : {}
18
+ config_source = config_dir ? "ask-local.json (#{relative_label(config_dir, dir)})" : "ask-local.json"
19
+
20
+ app, app_source = first_present(
21
+ [name, "flag"],
22
+ [ENV["ASK_LOCAL_NAME"], "ASK_LOCAL_NAME"],
23
+ [app_cfg["name"], config_source],
24
+ [Inference.infer(dir), :infer]
25
+ )
26
+ app, app_source = app_source == :infer ? app : [app, app_source]
27
+
28
+ service, service_source = first_present(
29
+ [service, "flag"],
30
+ [ENV["ASK_LOCAL_SERVICE"], "ASK_LOCAL_SERVICE"],
31
+ [app_cfg["service"], config_source]
32
+ )
33
+
34
+ variant_value, variant_source = if !variant.nil? || ENV["ASK_LOCAL_VARIANT"] || app_cfg["variant"]
35
+ first_present(
36
+ [variant, "flag"],
37
+ [ENV["ASK_LOCAL_VARIANT"], "ASK_LOCAL_VARIANT"],
38
+ [app_cfg["variant"], config_source]
39
+ )
40
+ else
41
+ Variant.resolve(dir, use_branch: use_branch) || [nil, nil]
42
+ end
43
+
44
+ tld_list = parse_tlds(tlds) || parse_tlds(ENV["ASK_LOCAL_TLD"]) ||
45
+ Array(app_cfg["tlds"]) || [Hostname::DEFAULT_TLD]
46
+ tld_list = [Hostname::DEFAULT_TLD] if tld_list.empty?
47
+ tld_list.each do |tld|
48
+ raise ConfigError, "Invalid TLD #{tld.inspect}" unless Sanitize.valid_tld?(tld.downcase)
49
+ end
50
+ tld_list = tld_list.map(&:downcase).uniq
51
+
52
+ Result.new(
53
+ app: Sanitize.hostname_label(app),
54
+ service: service && Sanitize.hostname_label(service),
55
+ variant: variant_value && Sanitize.hostname_label(variant_value),
56
+ tlds: tld_list,
57
+ sources: { app: app_source, service: service_source, variant: variant_source }
58
+ )
59
+ end
60
+
61
+ def hostnames(result)
62
+ Hostname.build(app: result.app, service: result.service,
63
+ variant: result.variant, tlds: result.tlds)
64
+ end
65
+
66
+ # Walk up for the nearest ask-local.json; a root with an "apps" map
67
+ # covers subdirectories (monorepo). A nearer config without a match
68
+ # does not block a farther one with an apps entry.
69
+ def find_config(dir)
70
+ current = File.expand_path(dir)
71
+ fallback = nil
72
+ loop do
73
+ begin
74
+ loaded = Config.load(current)
75
+ if loaded
76
+ if loaded.data["apps"].is_a?(Hash)
77
+ return [loaded, current]
78
+ else
79
+ fallback ||= [loaded, current]
80
+ end
81
+ end
82
+ rescue ConfigError
83
+ nil
84
+ end
85
+ parent = File.dirname(current)
86
+ break if parent == current
87
+
88
+ current = parent
89
+ end
90
+ fallback || [nil, nil]
91
+ end
92
+
93
+ def relative_label(config_dir, dir)
94
+ return "." if File.expand_path(config_dir) == File.expand_path(dir)
95
+
96
+ require "pathname"
97
+ Pathname.new(File.expand_path(dir))
98
+ .relative_path_from(Pathname.new(File.expand_path(config_dir))).to_s
99
+ rescue ArgumentError
100
+ "."
101
+ end
102
+
103
+ # NOTE: no `private` keyword here — it would cancel
104
+ # `module_function` mode (see Variant for details).
105
+ def first_present(*pairs)
106
+ pairs.each do |value, source|
107
+ if source == :infer
108
+ inferred, from = value
109
+ return [inferred, from] unless inferred.nil? || inferred.to_s.empty?
110
+ elsif !value.nil? && !value.to_s.strip.empty?
111
+ return [value.to_s.strip, source]
112
+ end
113
+ end
114
+ [nil, nil]
115
+ end
116
+
117
+ def parse_tlds(value)
118
+ return nil if value.nil?
119
+ return value.map(&:to_s) if value.is_a?(Array)
120
+
121
+ parts = value.to_s.split(",").map(&:strip).reject(&:empty?)
122
+ parts.empty? ? nil : parts
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Ask
7
+ module Local
8
+ # Persistent hostname -> backend mapping in ~/.ask-local/routes.json.
9
+ #
10
+ # Entries: {hostname, target, kind, pid} where target is a unix socket
11
+ # path (kind "socket") or "127.0.0.1:PORT" (kind "tcp"), and pid 0
12
+ # marks a static alias. Guarded by flock; stale PIDs pruned on read.
13
+ # Modeled on portless RouteStore and puma-dev's AppPool registry.
14
+ class RouteStore
15
+ FILE_MODE = 0o644
16
+ DIR_MODE = 0o755
17
+
18
+ attr_reader :dir, :routes_path, :pid_path, :port_path
19
+
20
+ def initialize(dir, on_warning: nil)
21
+ @dir = dir
22
+ @routes_path = File.join(dir, "routes.json")
23
+ @pid_path = File.join(dir, "proxy.pid")
24
+ @port_path = File.join(dir, "proxy.port")
25
+ @on_warning = on_warning
26
+ end
27
+
28
+ def ensure_dir
29
+ FileUtils.mkdir_p(dir, mode: DIR_MODE)
30
+ File.chmod(DIR_MODE, dir)
31
+ Ownership.fix(dir)
32
+ rescue SystemCallError
33
+ nil
34
+ end
35
+
36
+ def with_lock
37
+ ensure_dir
38
+ File.open("#{routes_path}.lock", File::CREAT | File::RDWR, FILE_MODE) do |f|
39
+ f.flock(File::LOCK_EX)
40
+ yield
41
+ end
42
+ end
43
+
44
+ def load_routes(prune: false)
45
+ return [] unless File.file?(routes_path)
46
+
47
+ parsed = JSON.parse(File.read(routes_path))
48
+ unless parsed.is_a?(Array)
49
+ @on_warning&.call("Corrupted routes file (expected array): #{routes_path}")
50
+ return []
51
+ end
52
+ routes = parsed.select { |r| valid_route?(r) }
53
+ alive = routes.select { |r| r["pid"] == 0 || alive?(r["pid"]) }
54
+ save_routes(alive) if prune && alive.length != routes.length
55
+ alive
56
+ rescue JSON::ParserError
57
+ @on_warning&.call("Corrupted routes file (invalid JSON): #{routes_path}")
58
+ []
59
+ rescue SystemCallError
60
+ []
61
+ end
62
+
63
+ def load_routes_raw
64
+ return [] unless File.file?(routes_path)
65
+
66
+ parsed = JSON.parse(File.read(routes_path))
67
+ parsed.is_a?(Array) ? parsed.select { |r| valid_route?(r) } : []
68
+ rescue JSON::ParserError, SystemCallError
69
+ []
70
+ end
71
+
72
+ # Returns killed pid when force takes over a live route.
73
+ # `spec` marks daemon-supervised managed apps ({dir: ...}); the
74
+ # proxy supervisor may idle-kill and boot-on-request those backends.
75
+ def add_route(hostname, target, pid, kind:, force: false, spec: nil)
76
+ killed = nil
77
+ with_lock do
78
+ routes = load_routes(prune: true)
79
+ existing = routes.find { |r| r["hostname"] == hostname }
80
+ if existing && existing["pid"] != pid && alive?(existing["pid"])
81
+ raise RouteConflictError.new(hostname, existing["pid"]) unless force
82
+
83
+ begin
84
+ Process.kill("TERM", existing["pid"])
85
+ killed = existing["pid"]
86
+ rescue SystemCallError
87
+ nil
88
+ end
89
+ end
90
+ routes.reject! { |r| r["hostname"] == hostname }
91
+ entry = { "hostname" => hostname, "target" => target, "kind" => kind, "pid" => pid }
92
+ entry["spec"] = spec if spec
93
+ routes << entry
94
+ save_routes(routes)
95
+ end
96
+ killed
97
+ end
98
+
99
+ def remove_route(hostname, owner_pid: nil)
100
+ with_lock do
101
+ routes = load_routes(prune: true)
102
+ routes.reject! do |r|
103
+ r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
104
+ end
105
+ save_routes(routes)
106
+ end
107
+ end
108
+
109
+ def prune_stale
110
+ stale = []
111
+ with_lock do
112
+ all = load_routes_raw
113
+ alive, dead = all.partition { |r| r["pid"] == 0 || alive?(r["pid"]) }
114
+ stale = dead
115
+ save_routes(alive) unless dead.empty?
116
+ end
117
+ stale
118
+ end
119
+
120
+ def find(hostname)
121
+ load_routes.find { |r| r["hostname"] == hostname }
122
+ end
123
+
124
+ private
125
+
126
+ def valid_route?(value)
127
+ value.is_a?(Hash) &&
128
+ value["hostname"].is_a?(String) &&
129
+ value["target"].is_a?(String) &&
130
+ value["pid"].is_a?(Integer) &&
131
+ (value["spec"].nil? || (value["spec"].is_a?(Hash) && value["spec"]["dir"].is_a?(String)))
132
+ end
133
+
134
+ def save_routes(routes)
135
+ File.write(routes_path, JSON.pretty_generate(routes))
136
+ File.chmod(FILE_MODE, routes_path)
137
+ Ownership.fix(routes_path)
138
+ rescue SystemCallError
139
+ nil
140
+ end
141
+
142
+ def alive?(pid)
143
+ Process.kill(0, pid)
144
+ true
145
+ rescue SystemCallError
146
+ false
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+ require "socket"
6
+
7
+ module Ask
8
+ module Local
9
+ # Boots and supervises one app backend.
10
+ #
11
+ # Managed Ruby apps (Rails/Rack with puma available): puma bound to a
12
+ # unix socket — zero TCP ports, readiness by dialing the socket
13
+ # (puma-dev model). Without puma, managed degrades to rackup on TCP
14
+ # (run-mode shape). Run mode (everything else): subprocess with
15
+ # injected PORT/ASK_LOCAL_URL, readiness by TCP connect.
16
+ class Runner
17
+ SOCKET_DIR = File.join("tmp", "sockets")
18
+ SOCKET_NAME = "ask-local.sock"
19
+
20
+ App = Struct.new(:name, :hostname, :url, :pid, :target, :kind,
21
+ :command, keyword_init: true)
22
+
23
+ def initialize(store:, on_log: nil)
24
+ @store = store
25
+ @on_log = on_log || ->(msg) { puts msg }
26
+ end
27
+
28
+ # Boot a managed Rack app. Socket when puma exists, else TCP rackup.
29
+ def boot_managed(name:, hostname:, url:, dir:, force: false)
30
+ if puma_available?(dir)
31
+ boot_socket(name: name, hostname: hostname, url: url, dir: dir, force: force)
32
+ else
33
+ boot_tcp_fallback(name: name, hostname: hostname, url: url, dir: dir, force: force)
34
+ end
35
+ end
36
+
37
+ # Boot a backend for an existing route (daemon supervisor). Does not
38
+ # re-register the route — target path is deterministic
39
+ # (dir/tmp/sockets/ask-local.sock) and the route entry stays.
40
+ def boot_supervised(name:, hostname:, url:, dir:)
41
+ socket_path = File.expand_path(File.join(dir, SOCKET_DIR, SOCKET_NAME))
42
+ FileUtils.mkdir_p(File.dirname(socket_path))
43
+ FileUtils.rm_f(socket_path)
44
+ pid = spawn_socket(dir, socket_path, name, url)
45
+ unless wait_for_socket(socket_path, timeout: 60)
46
+ stop_pid(pid)
47
+ raise Error, "App '#{name}' did not boot within 60s. " \
48
+ "Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
49
+ end
50
+ write_backend_pid(hostname, pid)
51
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
52
+ target: socket_path, kind: "socket", command: nil)
53
+ end
54
+
55
+ def boot_socket(name:, hostname:, url:, dir:, force: false)
56
+ socket_path = File.expand_path(File.join(dir, SOCKET_DIR, SOCKET_NAME))
57
+ FileUtils.mkdir_p(File.dirname(socket_path))
58
+ FileUtils.rm_f(socket_path)
59
+ pid = spawn_socket(dir, socket_path, name, url)
60
+
61
+ unless wait_for_socket(socket_path, timeout: 60)
62
+ stop_pid(pid)
63
+ raise Error, "App '#{name}' did not boot within 60s. " \
64
+ "Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
65
+ end
66
+
67
+ @store.add_route(hostname, socket_path, Process.pid, kind: "socket",
68
+ force: force, spec: { "dir" => dir })
69
+ write_backend_pid(hostname, pid)
70
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
71
+ target: socket_path, kind: "socket", command: socket_command(dir, socket_path))
72
+ end
73
+
74
+ def boot_tcp_fallback(name:, hostname:, url:, dir:, force: false)
75
+ port = Ports.find_free
76
+ env = child_env(dir, url: url, port: port)
77
+ config_ru = File.join(dir, "config.ru")
78
+ cmd = ["rackup", "-o", "127.0.0.1", "-p", port.to_s, config_ru]
79
+ pid = with_clean_env { spawn(env, *cmd, chdir: dir, out: log_path(dir, name), err: [:child, :out]) }
80
+ Process.detach(pid)
81
+ unless wait_for_tcp(port, timeout: 60)
82
+ stop_pid(pid)
83
+ raise Error, "App '#{name}' did not boot within 60s. " \
84
+ "Last log lines (#{log_path(dir, name)}):\n#{log_tail(log_path(dir, name))}"
85
+ end
86
+ target = "127.0.0.1:#{port}"
87
+ @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
88
+ write_backend_pid(hostname, pid)
89
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
90
+ target: target, kind: "tcp", command: cmd)
91
+ end
92
+
93
+ # Run an arbitrary command with PORT + ASK_LOCAL_URL. Returns App.
94
+ def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false)
95
+ port ||= Ports.find_free
96
+ env = child_env(dir, url: url, port: port)
97
+ pid = with_clean_env { spawn(env, *command, chdir: dir) }
98
+ Process.detach(pid)
99
+ target = "127.0.0.1:#{port}"
100
+ @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
101
+ write_backend_pid(hostname, pid)
102
+ App.new(name: name, hostname: hostname, url: url, pid: pid,
103
+ target: target, kind: "tcp", command: command)
104
+ end
105
+
106
+ def child_env(dir, url:, port:)
107
+ env = { "ASK_LOCAL_URL" => url }
108
+ env["PORT"] = port.to_s if port
109
+ env["HOST"] = "127.0.0.1"
110
+ ca = File.join(Certs.state_dir, "ca.pem")
111
+ env["NODE_EXTRA_CA_CERTS"] = ca if File.file?(ca)
112
+ env
113
+ end
114
+
115
+ def puma_available?(dir)
116
+ with_clean_env { bundle_puma?(dir) || system_puma? }
117
+ end
118
+
119
+ # Spawned backends must not inherit our own bundle: under
120
+ # `bundle exec` rubygems restricts executables to the Gemfile,
121
+ # hiding system puma/rackup from the child. Strip Bundler env so
122
+ # the app boots with its own gems.
123
+ def with_clean_env(&block)
124
+ if defined?(Bundler) && Bundler.respond_to?(:with_unbundled_env)
125
+ Bundler.with_unbundled_env(&block)
126
+ else
127
+ saved = {}
128
+ %w[BUNDLE_GEMFILE RUBYOPT RUBYLIB GEM_HOME GEM_PATH].each do |k|
129
+ saved[k] = ENV.delete(k)
130
+ end
131
+ begin
132
+ yield
133
+ ensure
134
+ saved.each { |k, v| ENV[k] = v unless v.nil? }
135
+ end
136
+ end
137
+ end
138
+
139
+ private
140
+
141
+ def socket_command(dir, socket_path)
142
+ socket = "unix://#{socket_path}"
143
+ config_ru = File.join(dir, "config.ru")
144
+ # Puma 8 takes config.ru positionally (no --rackup flag).
145
+ if bundle_puma?(dir)
146
+ ["bundle", "exec", "puma", "-b", socket, config_ru]
147
+ else
148
+ ["puma", "-b", socket, config_ru]
149
+ end
150
+ end
151
+
152
+ # Shared spawn for CLI boots and daemon supervision.
153
+ def spawn_socket(dir, socket_path, name, url)
154
+ env = child_env(dir, url: url, port: nil)
155
+ cmd = socket_command(dir, socket_path)
156
+ pid = with_clean_env { spawn(env, *cmd, chdir: dir, out: log_path(dir, name), err: [:child, :out]) }
157
+ Process.detach(pid)
158
+ pid
159
+ end
160
+
161
+ def bundle_puma?(dir)
162
+ gemfile = File.join(dir, "Gemfile")
163
+ return false unless File.file?(gemfile)
164
+
165
+ out, status = Open3.capture2("bundle", "exec", "puma", "-V",
166
+ chdir: dir, err: File::NULL)
167
+ status.success? && !out.empty?
168
+ rescue SystemCallError
169
+ false
170
+ end
171
+
172
+ def system_puma?
173
+ _out, status = Open3.capture2("puma", "-V", err: File::NULL)
174
+ status.success?
175
+ rescue SystemCallError
176
+ false
177
+ end
178
+
179
+ def wait_for_socket(path, timeout:)
180
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
181
+ until File.socket?(path)
182
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
183
+
184
+ sleep 0.25
185
+ end
186
+ begin
187
+ UNIXSocket.new(path).close
188
+ true
189
+ rescue SystemCallError
190
+ false
191
+ end
192
+ end
193
+
194
+ def wait_for_tcp(port, timeout:)
195
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
196
+ loop do
197
+ begin
198
+ TCPSocket.new("127.0.0.1", port).close
199
+ return true
200
+ rescue SystemCallError
201
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
202
+
203
+ sleep 0.25
204
+ end
205
+ end
206
+ end
207
+
208
+ def log_path(dir, name)
209
+ path = File.expand_path(File.join(dir, "log", "ask-local-#{name}.log"))
210
+ FileUtils.mkdir_p(File.dirname(path))
211
+ Log.rotate(path)
212
+ path
213
+ end
214
+
215
+ # Sidecar so `ask-local stop` can find the backend after the CLI
216
+ # that booted it has exited (the route owner pid is the CLI, not
217
+ # the backend). Removed alongside the route on clean stop.
218
+ def write_backend_pid(hostname, pid)
219
+ @store.ensure_dir
220
+ File.write(File.join(@store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
221
+ rescue SystemCallError
222
+ nil
223
+ end
224
+
225
+ def log_tail(path, lines: 10)
226
+ return "(no log file)" unless File.file?(path)
227
+
228
+ File.readlines(path).last(lines).join
229
+ rescue SystemCallError
230
+ "(unreadable log)"
231
+ end
232
+
233
+ def stop_pid(pid)
234
+ Process.kill("TERM", pid)
235
+ rescue SystemCallError
236
+ nil
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Ask
6
+ module Local
7
+ # DNS label sanitization (RFC 1035): lowercase, hyphen-separated,
8
+ # max 63 chars with hash suffix on truncation.
9
+ #
10
+ # Borrowed semantics from portless sanitizeForHostname/truncateLabel,
11
+ # reimplemented in idiomatic Ruby.
12
+ module Sanitize
13
+ MAX_DNS_LABEL_LENGTH = 63
14
+
15
+ module_function
16
+
17
+ def truncate_label(label)
18
+ return label if label.length <= MAX_DNS_LABEL_LENGTH
19
+
20
+ hash = Digest::SHA256.hexdigest(label)[0, 6]
21
+ prefix = label[0, MAX_DNS_LABEL_LENGTH - 7].gsub(/-+\z/, "")
22
+ "#{prefix}-#{hash}"
23
+ end
24
+
25
+ def hostname_label(name)
26
+ sanitized = name.to_s.downcase
27
+ .gsub(/[^a-z0-9-]/, "-")
28
+ .gsub(/-{2,}/, "-")
29
+ .gsub(/\A-+|-+\z/, "")
30
+ truncate_label(sanitized)
31
+ end
32
+
33
+ def valid_tld?(tld)
34
+ return false if tld.nil? || tld.empty? || tld.length > 253
35
+
36
+ tld.split(".").all? do |label|
37
+ !label.empty? && label.length <= 63 &&
38
+ label.match?(/\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end