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,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ class CLI
6
+ # System commands: proxy, service, hosts, trust, clean, doctor, kamal.
7
+ module SystemCommand
8
+ module_function
9
+
10
+ def doctor(ctx, args)
11
+ json = args.delete("--json")
12
+ failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout, json: !!json)
13
+ exit(failed.zero? ? 0 : 1)
14
+ end
15
+
16
+ def trust(_ctx, _args)
17
+ result = Trust.trust
18
+ if result[:trusted]
19
+ puts "CA trusted."
20
+ else
21
+ $stderr.puts "Error: #{result[:error]}"
22
+ exit 1
23
+ end
24
+ end
25
+
26
+ def clean(ctx, _args)
27
+ ProxyControl.stop(ctx.store)
28
+ result = Trust.untrust
29
+ puts "CA removed from trust store." if result[:removed]
30
+ warn "CA untrust failed: #{result[:error]}" if result[:error]
31
+ Hosts.clean
32
+ require "fileutils"
33
+ FileUtils.rm_rf(ctx.store.dir)
34
+ puts "Cleaned ask-local state."
35
+ end
36
+
37
+ def hosts(ctx, args)
38
+ sub = args.first
39
+ case sub
40
+ when "sync"
41
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
42
+ if Hosts.sync(hostnames)
43
+ puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
44
+ else
45
+ $stderr.puts "Could not write /etc/hosts (try sudo)."
46
+ exit 1
47
+ end
48
+ when "clean"
49
+ Hosts.clean
50
+ puts "Removed ask-local entries from /etc/hosts."
51
+ else
52
+ raise Error, "Usage: ask-local hosts [sync|clean]"
53
+ end
54
+ end
55
+
56
+ def proxy(ctx, args)
57
+ sub = args.first
58
+ case sub
59
+ when "start"
60
+ port, tls, foreground = parse_proxy_start(args[1..])
61
+ tlds = active_tlds(args[1..])
62
+ if foreground
63
+ write_tls_marker(ctx, tls)
64
+ write_tlds_file(ctx, tlds)
65
+ sup = Supervisor.new(store: ctx.store, runner: Runner.new(store: ctx.store),
66
+ on_event: ->(m) { warn m })
67
+ sup.start
68
+ Proxy.new(store: ctx.store, port: port, tls: tls,
69
+ state_dir: ctx.store.dir, supervisor: sup, tlds: tlds).start_foreground
70
+ else
71
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, tlds: tlds)
72
+ puts "Proxy started on port #{port}#{tls ? " (HTTPS)" : " (HTTP)"}."
73
+ end
74
+ when "stop"
75
+ case ProxyControl.stop(ctx.store)
76
+ when :stopped then puts "Proxy stopped."
77
+ when :stale then puts "Removed stale proxy state."
78
+ when :not_running then puts "Proxy is not running."
79
+ when :unknown_process then puts "Port in use by an unknown process."
80
+ end
81
+ else
82
+ raise Error, "Usage: ask-local proxy [start|stop]"
83
+ end
84
+ end
85
+
86
+ def write_tls_marker(ctx, tls)
87
+ ctx.store.ensure_dir
88
+ path = File.join(ctx.store.dir, "proxy.tls")
89
+ tls ? File.write(path, "1") : File.write(path, "0")
90
+ end
91
+
92
+ # TLDs the proxy serves: --tld flags, else ASK_LOCAL_TLD, else
93
+ # localhost. Persisted so auto-restarted daemons agree, and so
94
+ # the 404 page knows which hosts are "ours" (rebinding boundary).
95
+ def active_tlds(args)
96
+ flags = []
97
+ i = 0
98
+ while i < args.length
99
+ if args[i] == "--tld"
100
+ flags << args.fetch(i + 1).to_s
101
+ i += 2
102
+ else
103
+ i += 1
104
+ end
105
+ end
106
+ list = flags.any? ? flags : (ENV["ASK_LOCAL_TLD"]&.split(",")&.map(&:strip) || [])
107
+ list = list.reject(&:empty?).map(&:downcase).uniq
108
+ list.empty? ? [Hostname::DEFAULT_TLD] : list
109
+ end
110
+
111
+ def write_tlds_file(ctx, tlds)
112
+ ctx.store.ensure_dir
113
+ File.write(File.join(ctx.store.dir, "proxy.tlds"), "#{tlds.join("\n")}\n")
114
+ rescue SystemCallError
115
+ nil
116
+ end
117
+
118
+ def parse_proxy_start(args)
119
+ port = nil
120
+ tls = true
121
+ foreground = false
122
+ i = 0
123
+ while i < args.length
124
+ case args[i]
125
+ when "-p", "--port" then port = args.fetch(i + 1).to_i; i += 2
126
+ when "--no-tls" then tls = false; i += 1
127
+ when "--https" then tls = true; i += 1
128
+ when "--foreground" then foreground = true; i += 1
129
+ when "--tld" then i += 2 # consumed by active_tlds
130
+ else i += 1
131
+ end
132
+ end
133
+ [port || ProxyControl.default_port(tls), tls, foreground]
134
+ end
135
+
136
+ def service(ctx, args)
137
+ sub = args.first
138
+ case sub
139
+ when "install" then service_install(ctx, args)
140
+ when "uninstall" then service_uninstall
141
+ when "status" then service_status(ctx)
142
+ else raise Error, "Usage: ask-local service [install|uninstall|status]"
143
+ end
144
+ end
145
+
146
+ # Root-owned LaunchDaemon binding 80/443 at boot (puma-dev model).
147
+ # Re-execs under sudo; the proxy runs with the invoking user's state
148
+ # dir so routes registered by unprivileged CLIs are shared
149
+ # (portless pattern). A root proxy can also write /etc/hosts.
150
+ def service_install(ctx, args)
151
+ if !ProxyControl.root? && !args.include?("--internal")
152
+ puts "Installing system service (sudo required)..."
153
+ state = Certs.state_dir
154
+ ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
155
+ RbConfig.ruby, ProxyControl.bin_path,
156
+ "service", "install", "--internal")
157
+ exit(ok ? 0 : 1)
158
+ end
159
+ case RUBY_PLATFORM
160
+ when /darwin/ then install_launchd(ctx)
161
+ when /linux/ then print_linux_unit
162
+ else raise Error, "Service install not supported on #{RUBY_PLATFORM}"
163
+ end
164
+ end
165
+
166
+ def user_home_for_service
167
+ sudo_user = ENV["SUDO_USER"]
168
+ if sudo_user && !sudo_user.empty?
169
+ require "etc"
170
+ Etc.getpwnam(sudo_user).dir
171
+ else
172
+ Certs.home
173
+ end
174
+ rescue ArgumentError
175
+ Certs.home
176
+ end
177
+
178
+ def install_launchd(ctx)
179
+ require "etc"
180
+ home = user_home_for_service
181
+ state_dir = ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
182
+ dir = "/Library/LaunchDaemons"
183
+ FileUtils.mkdir_p(dir)
184
+ plist = <<~PLIST
185
+ <?xml version="1.0" encoding="UTF-8"?>
186
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
187
+ <plist version="1.0">
188
+ <dict>
189
+ <key>Label</key><string>dev.ask.local</string>
190
+ <key>ProgramArguments</key>
191
+ <array>
192
+ <string>#{RbConfig.ruby}</string>
193
+ <string>#{ProxyControl.bin_path}</string>
194
+ <string>proxy</string><string>start</string><string>--foreground</string>
195
+ </array>
196
+ <key>EnvironmentVariables</key>
197
+ <dict>
198
+ <key>ASK_LOCAL_STATE_DIR</key><string>#{state_dir}</string>
199
+ <key>HOME</key><string>#{home}</string>
200
+ </dict>
201
+ <key>KeepAlive</key><true/>
202
+ <key>RunAtLoad</key><true/>
203
+ </dict>
204
+ </plist>
205
+ PLIST
206
+ path = File.join(dir, "dev.ask.local.plist")
207
+ File.write(path, plist)
208
+ File.chmod(0o644, path)
209
+ Ownership.chown_service_files(path)
210
+ system("launchctl", "unload", path) rescue nil
211
+ system("launchctl", "load", path) or raise Error, "launchctl load failed"
212
+ puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
213
+ puts "Restart your machine or run: sudo launchctl load #{path}"
214
+ end
215
+
216
+ def print_linux_unit
217
+ home = user_home_for_service
218
+ state_dir = ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
219
+ puts <<~UNIT
220
+ # /etc/systemd/system/ask-local.service (binds 80/443 at boot)
221
+ [Unit]
222
+ After=network.target
223
+
224
+ [Service]
225
+ ExecStart=#{RbConfig.ruby} #{ProxyControl.bin_path} proxy start --foreground
226
+ Environment=ASK_LOCAL_STATE_DIR=#{state_dir}
227
+ Environment=HOME=#{home}
228
+
229
+ [Install]
230
+ WantedBy=multi-user.target
231
+
232
+ Install with: sudo cp ask-local.service /etc/systemd/system/ && sudo systemctl enable --now ask-local
233
+ (Run that install command with sudo so the service is root-owned.)
234
+ UNIT
235
+ end
236
+
237
+ def service_uninstall(_ctx)
238
+ if !ProxyControl.root?
239
+ state = Certs.state_dir
240
+ ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
241
+ RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal")
242
+ exit(ok ? 0 : 1)
243
+ end
244
+ case RUBY_PLATFORM
245
+ when /darwin/
246
+ path = "/Library/LaunchDaemons/dev.ask.local.plist"
247
+ system("launchctl", "unload", path) rescue nil
248
+ FileUtils.rm_f(path)
249
+ puts "Removed root LaunchDaemon."
250
+ else
251
+ puts "Remove /etc/systemd/system/ask-local.service, then: sudo systemctl disable --now ask-local"
252
+ end
253
+ end
254
+
255
+ def service_status(ctx)
256
+ port = ProxyControl.proxy_port(ctx.store)
257
+ if port.nil? || !ProxyControl.listening?(port)
258
+ puts "Proxy not running."
259
+ elsif ProxyControl.ours?(port, tls: ProxyControl.proxy_tls(ctx.store))
260
+ puts "Proxy running on port #{port}."
261
+ else
262
+ puts "Port #{port} in use by another process."
263
+ end
264
+ end
265
+
266
+ # ask-local kamal <variant> [--app myapp] [--domain preview.example.com]
267
+ def kamal(_ctx, args)
268
+ opts = {}
269
+ rest = []
270
+ i = 0
271
+ a = args.dup
272
+ while i < a.length
273
+ case a[i]
274
+ when "--app" then opts[:app] = a.fetch(i + 1); i += 2
275
+ when "--domain" then opts[:domain] = a.fetch(i + 1); i += 2
276
+ else rest << a[i]; i += 1
277
+ end
278
+ end
279
+ variant = rest.first
280
+ raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com]" unless variant
281
+
282
+ app = opts[:app] || Resolver.resolve(Dir.pwd, use_branch: false).app
283
+ domain = opts[:domain] || ENV["ASK_LOCAL_KAMAL_DOMAIN"] || "preview.example.com"
284
+ slug = Sanitize.hostname_label(variant)
285
+ puts "# Paste into deploy.yml proxy section for a preview of variant #{slug}:"
286
+ puts "proxy:"
287
+ puts " ssl: true"
288
+ puts " hosts:"
289
+ puts " - #{app}-#{slug}.#{domain}"
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Ask
6
+ module Local
7
+ # Command-line interface. Thin dispatcher: every command lives in
8
+ # lib/ask/local/cli/{boot,routes,system}.rb behind a shared Context.
9
+ # OptionParser only, no Thor.
10
+ #
11
+ # In non-interactive environments (no TTY or CI=1) we fail early with
12
+ # a clear message instead of prompting (portless lesson).
13
+ class CLI
14
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open].freeze
15
+
16
+ def self.run(argv)
17
+ new.run(argv)
18
+ 0
19
+ rescue Error => e
20
+ $stderr.puts "Error: #{e.message}"
21
+ 1
22
+ rescue OptionParser::InvalidOption => e
23
+ $stderr.puts "Error: #{e.message}"
24
+ 1
25
+ end
26
+
27
+ def run(argv)
28
+ args = argv.dup
29
+ if args.empty? || (!SUBCOMMANDS.include?(args.first) && !args.first.start_with?("-"))
30
+ return BootCommand.run_inferred(Context.new, args)
31
+ end
32
+
33
+ cmd = args.shift
34
+ ctx = Context.new
35
+ case cmd
36
+ when "run" then BootCommand.run_explicit(ctx, args)
37
+ when "get" then RoutesCommand.get(ctx, args)
38
+ when "alias" then RoutesCommand.alias_add(ctx, args)
39
+ when "hosts" then SystemCommand.hosts(ctx, args)
40
+ when "list" then RoutesCommand.list(ctx, args)
41
+ when "doctor" then SystemCommand.doctor(ctx, args)
42
+ when "trust" then SystemCommand.trust(ctx, args)
43
+ when "clean" then SystemCommand.clean(ctx, args)
44
+ when "prune" then RoutesCommand.prune(ctx, args)
45
+ when "proxy" then SystemCommand.proxy(ctx, args)
46
+ when "service" then SystemCommand.service(ctx, args)
47
+ when "kamal" then SystemCommand.kamal(ctx, args)
48
+ when "stop"
49
+ exit RoutesCommand.stop(ctx, args)
50
+ when "restart" then RoutesCommand.restart(ctx, args)
51
+ when "log" then RoutesCommand.log(ctx, args)
52
+ when "status" then RoutesCommand.status(ctx, args)
53
+ when "open" then RoutesCommand.open(ctx, args)
54
+ when "--help", "-h" then help
55
+ when "--version", "-v" then puts "ask-local #{VERSION}"
56
+ else BootCommand.run_named(ctx, cmd, args)
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def help
63
+ puts <<~HELP
64
+ ask-local - Stable named .localhost URLs for Ruby development.
65
+
66
+ Usage:
67
+ ask-local Infer name, boot app -> https://<app>.localhost
68
+ ask-local run [cmd] Same, with explicit command
69
+ ask-local <name> <cmd> Run with explicit name
70
+ ask-local get <name> Print URL for a service
71
+ ask-local alias <name> <port> Static route (e.g. Docker)
72
+ ask-local list Show active routes (+ backend liveness)
73
+ ask-local status Show effective naming context here
74
+ ask-local open [name] Open the app URL in a browser
75
+ ask-local doctor Check proxy, routes, DNS, CA trust
76
+ ask-local trust Add local CA to trust store
77
+ ask-local clean Remove state and hosts entries
78
+ ask-local prune Remove stale routes
79
+ ask-local proxy start|stop Control the proxy
80
+ ask-local service install|status|uninstall OS startup service
81
+ ask-local hosts sync|clean Manage /etc/hosts entries
82
+ ask-local kamal <variant> Preview-deploy snippet for Kamal
83
+ ask-local stop Stop this app's backend + routes
84
+ ask-local restart Touch tmp/restart.txt
85
+ ask-local log [-f] [n] Tail (or follow) this app's backend log
86
+
87
+ Flags: --name, --service, --variant, --tld, --branch, --force,
88
+ --app-port, --proc (pick a Procfile process, e.g. --proc web)
89
+ Env: ASK_LOCAL_NAME/SERVICE/VARIANT/TLD/PORT/STATE_DIR, ASK_LOCAL_BRANCH=1
90
+ HELP
91
+ end
92
+
93
+ # Backwards-compatible access for tests written against the old
94
+ # monolith: CLI.new.send(:inject_port_flags / :procfile_command).
95
+ def inject_port_flags(command, port)
96
+ BootCommand.inject_port_flags(command, port)
97
+ end
98
+
99
+ def procfile_command(process = nil)
100
+ BootCommand.procfile_command(process)
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ask
6
+ module Local
7
+ # Optional ask-local.json in the app directory.
8
+ #
9
+ # { "name": "myapp" }
10
+ # { "name": "myapp", "service": "api", "variant": "demo",
11
+ # "tlds": ["localhost"], "appPort": 3000 }
12
+ #
13
+ # Monorepo roots may add an "apps" map keyed by path relative to the
14
+ # config dir; top-level fields apply only in single-app mode.
15
+ class Config
16
+ FILENAME = "ask-local.json"
17
+ TOP_KEYS = %w[name service variant tlds appPort proxy apps].freeze
18
+ APP_KEYS = %w[name service variant tlds appPort proxy].freeze
19
+
20
+ attr_reader :data, :dir
21
+
22
+ def self.load(dir = Dir.pwd)
23
+ path = File.join(dir, FILENAME)
24
+ return nil unless File.file?(path)
25
+
26
+ parsed = JSON.parse(File.read(path))
27
+ raise ConfigError, "#{path} must be a JSON object" unless parsed.is_a?(Hash)
28
+
29
+ new(parsed, dir, path)
30
+ rescue JSON::ParserError => e
31
+ raise ConfigError, "Invalid JSON in #{path}: #{e.message}"
32
+ end
33
+
34
+ def initialize(data, dir, path = FILENAME)
35
+ @data = data
36
+ @dir = dir
37
+ @path = path
38
+ validate!
39
+ end
40
+
41
+ def app_config(package_dir = dir)
42
+ return slice(APP_KEYS) unless data["apps"].is_a?(Hash)
43
+
44
+ rel = relative(package_dir)
45
+ return {} if rel.nil? || rel.start_with?("..")
46
+
47
+ candidate = rel
48
+ loop do
49
+ hit = data["apps"][candidate]
50
+ return hit.select { |k, _| APP_KEYS.include?(k) } if hit.is_a?(Hash)
51
+
52
+ parent = File.dirname(candidate)
53
+ break if parent == "." || parent == candidate
54
+
55
+ candidate = parent
56
+ end
57
+ {}
58
+ end
59
+
60
+ def [](key)
61
+ data[key]
62
+ end
63
+
64
+ private
65
+
66
+ def slice(keys)
67
+ data.select { |k, _| keys.include?(k) }
68
+ end
69
+
70
+ def relative(package_dir)
71
+ require "pathname"
72
+ Pathname.new(File.expand_path(package_dir))
73
+ .relative_path_from(Pathname.new(File.expand_path(dir))).to_s
74
+ rescue ArgumentError
75
+ nil
76
+ end
77
+
78
+ def validate!
79
+ data.each_key do |key|
80
+ warn "Warning: Unknown key #{key.inspect} in #{@path}. Known keys: #{TOP_KEYS.join(", ")}" unless TOP_KEYS.include?(key)
81
+ end
82
+ validate_app_fields(data, "top level")
83
+ if data["apps"]
84
+ raise ConfigError, %("apps" in #{@path} must be an object) unless data["apps"].is_a?(Hash)
85
+
86
+ data["apps"].each do |name, entry|
87
+ raise ConfigError, %("apps.#{name}" in #{@path} must be an object) unless entry.is_a?(Hash)
88
+
89
+ validate_app_fields(entry, "apps.#{name}")
90
+ end
91
+ end
92
+ end
93
+
94
+ def validate_app_fields(fields, prefix)
95
+ if fields["appPort"] && !(fields["appPort"].is_a?(Integer) && fields["appPort"].between?(1, 65_535))
96
+ raise ConfigError, %("#{prefix}.appPort" in #{@path} must be an integer 1-65535)
97
+ end
98
+ if fields.key?("proxy") && ![true, false].include?(fields["proxy"])
99
+ raise ConfigError, %("#{prefix}.proxy" in #{@path} must be a boolean)
100
+ end
101
+ %w[name service variant].each do |key|
102
+ next unless fields.key?(key)
103
+ next if fields[key].is_a?(String) && !fields[key].strip.empty?
104
+
105
+ raise ConfigError, %("#{prefix}.#{key}" in #{@path} must be a non-empty string)
106
+ end
107
+ if fields["tlds"] && !(fields["tlds"].is_a?(Array) && fields["tlds"].all? { |t| t.is_a?(String) })
108
+ raise ConfigError, %("#{prefix}.tlds" in #{@path} must be an array of strings)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ # Read-only health checks: proxy, routes, DNS, CA trust.
6
+ # Never changes state; safe for agents to call any time.
7
+ module Doctor
8
+ Check = Struct.new(:name, :ok, :message, keyword_init: true)
9
+
10
+ module_function
11
+
12
+ def run(store:, port: nil, tls: nil)
13
+ checks = []
14
+ port ||= ProxyControl.proxy_port(store)
15
+ tls = ProxyControl.proxy_tls(store) if tls.nil?
16
+
17
+ checks << check_state_dir(store)
18
+ checks << check_disk(store)
19
+ checks << check_proxy(port, tls: tls)
20
+ checks << check_routes(store)
21
+ checks << check_dns(store)
22
+ checks << check_ca
23
+ checks
24
+ end
25
+
26
+ # ENOSPC on the state dir looks like our bug (socket bind fails,
27
+ # route writes vanish). Warn well before that: 100MB is already
28
+ # unreasonable for route files plus a rotated proxy log.
29
+ DISK_WARN_BYTES = 100 * 1024 * 1024
30
+
31
+ def check_disk(store)
32
+ used = Log.disk_usage(store.dir)
33
+ if used > DISK_WARN_BYTES
34
+ Check.new(name: "disk", ok: false,
35
+ message: "state dir uses #{Log.human_bytes(used)} — " \
36
+ "check #{File.join(store.dir, "proxy.log*")} for runaway logging")
37
+ else
38
+ Check.new(name: "disk", ok: true,
39
+ message: "state dir uses #{Log.human_bytes(used)}")
40
+ end
41
+ end
42
+
43
+ # Root-owned state files lock the unprivileged CLI out of its own
44
+ # state — the failure then looks like corruption, not permissions.
45
+ # Say so plainly.
46
+ def check_state_dir(store)
47
+ dir = store.dir
48
+ begin
49
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
50
+ probe = File.join(dir, ".writability-probe")
51
+ File.write(probe, "1")
52
+ File.unlink(probe)
53
+ Check.new(name: "state", ok: true, message: "#{dir} writable")
54
+ rescue SystemCallError => e
55
+ Check.new(name: "state", ok: false,
56
+ message: "#{dir} not writable (#{e.message}) — " \
57
+ "if a root proxy wrote here, run: sudo chown -R $USER #{dir}")
58
+ end
59
+ end
60
+
61
+ def check_proxy(port, tls:)
62
+ if port.nil?
63
+ return Check.new(name: "proxy", ok: false,
64
+ message: "not running — run: ask-local proxy start")
65
+ end
66
+ unless ProxyControl.listening?(port)
67
+ return Check.new(name: "proxy", ok: false,
68
+ message: "port #{port} not listening — run: ask-local proxy start")
69
+ end
70
+ if ProxyControl.ours?(port, tls: tls)
71
+ Check.new(name: "proxy", ok: true, message: "listening on port #{port}")
72
+ else
73
+ Check.new(name: "proxy", ok: false,
74
+ message: "port #{port} is in use by another process")
75
+ end
76
+ end
77
+
78
+ def check_routes(store)
79
+ routes = store.load_routes
80
+ if routes.empty?
81
+ Check.new(name: "routes", ok: true, message: "no active routes")
82
+ else
83
+ stale = store.load_routes_raw.length - routes.length
84
+ msg = "#{routes.length} active route(s)"
85
+ msg += " (#{stale} stale pruned)" if stale.positive?
86
+ Check.new(name: "routes", ok: true, message: msg)
87
+ end
88
+ end
89
+
90
+ def check_dns(store)
91
+ hostnames = store.load_routes.map { |r| r["hostname"] }
92
+ return Check.new(name: "dns", ok: true, message: "no routes to resolve") if hostnames.empty?
93
+
94
+ missing = Hosts.unresolved(hostnames)
95
+ if missing.empty?
96
+ Check.new(name: "dns", ok: true, message: "all #{hostnames.length} hostname(s) resolve")
97
+ else
98
+ Check.new(name: "dns", ok: false,
99
+ message: "#{missing.join(", ")} do not resolve — run: ask-local hosts sync")
100
+ end
101
+ end
102
+
103
+ def check_ca
104
+ dir = Certs.state_dir
105
+ paths = Certs.ca_paths(dir)
106
+ unless File.file?(paths[:cert])
107
+ return Check.new(name: "ca", ok: false, message: "no CA yet — run: ask-local trust")
108
+ end
109
+ if Certs.trusted?(dir)
110
+ Check.new(name: "ca", ok: true, message: "CA trusted")
111
+ else
112
+ Check.new(name: "ca", ok: false, message: "CA not trusted — run: ask-local trust")
113
+ end
114
+ end
115
+
116
+ def print(checks, out: $stdout, json: false)
117
+ if json
118
+ require "json"
119
+ out.puts JSON.generate({
120
+ checks: checks.map { |c| { name: c.name, ok: c.ok, message: c.message } },
121
+ failed: checks.count { |c| !c.ok }
122
+ })
123
+ return checks.count { |c| !c.ok }
124
+ end
125
+ failed = 0
126
+ checks.each do |c|
127
+ mark = c.ok ? "ok" : "FAIL"
128
+ failed += 1 unless c.ok
129
+ out.puts " [#{mark}] #{c.name}: #{c.message}"
130
+ end
131
+ failed
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Local
5
+ class Error < StandardError; end
6
+ class ConfigError < Error; end
7
+ class RouteConflictError < Error
8
+ attr_reader :hostname, :existing_pid
9
+
10
+ def initialize(hostname, existing_pid)
11
+ @hostname = hostname
12
+ @existing_pid = existing_pid
13
+ super("\"#{hostname}\" is already registered by a running process " \
14
+ "(PID #{existing_pid}). Use --force to override.")
15
+ end
16
+ end
17
+ class ProxyNotRunningError < Error; end
18
+ class CertError < Error; end
19
+ end
20
+ end