ask-local 0.1.1 → 0.2.1
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 +127 -54
- data/README.md +61 -9
- data/lib/ask/local/cli/boot.rb +145 -174
- data/lib/ask/local/cli/routes.rb +17 -13
- data/lib/ask/local/cli/system.rb +332 -70
- data/lib/ask/local/cli.rb +4 -2
- data/lib/ask/local/command.rb +30 -0
- data/lib/ask/local/config.rb +342 -59
- data/lib/ask/local/hosts.rb +8 -0
- data/lib/ask/local/ownership.rb +0 -7
- data/lib/ask/local/procfile.rb +139 -0
- data/lib/ask/local/proxy.rb +12 -1
- data/lib/ask/local/resolver.rb +62 -94
- data/lib/ask/local/runner.rb +8 -3
- data/lib/ask/local/trust.rb +21 -11
- data/lib/ask/local/version.rb +1 -1
- data/lib/ask/skills/ask-local/SKILL.md +57 -41
- data/lib/ask-local.rb +2 -0
- metadata +3 -1
data/lib/ask/local/cli/system.rb
CHANGED
|
@@ -41,6 +41,15 @@ module Ask
|
|
|
41
41
|
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
42
42
|
if Hosts.sync(hostnames)
|
|
43
43
|
puts "Synced #{hostnames.length} hostname(s) to /etc/hosts."
|
|
44
|
+
elsif !ProxyControl.root? && !hostnames.empty?
|
|
45
|
+
# /etc/hosts is root-owned; once the root service is
|
|
46
|
+
# installed the guidance is "run ask-local hosts sync" — so
|
|
47
|
+
# make that command work by re-running it elevated.
|
|
48
|
+
puts "Writing /etc/hosts needs root — re-running elevated..."
|
|
49
|
+
state = Certs.state_dir
|
|
50
|
+
cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}", RbConfig.ruby,
|
|
51
|
+
ProxyControl.bin_path, "hosts", "sync"]
|
|
52
|
+
exit(elevate(cmd) ? 0 : 1)
|
|
44
53
|
else
|
|
45
54
|
$stderr.puts "Could not write /etc/hosts (try sudo)."
|
|
46
55
|
exit 1
|
|
@@ -136,31 +145,111 @@ module Ask
|
|
|
136
145
|
def service(ctx, args)
|
|
137
146
|
sub = args.first
|
|
138
147
|
case sub
|
|
139
|
-
when "install" then service_install(ctx, args)
|
|
140
|
-
when "uninstall" then service_uninstall
|
|
148
|
+
when "install" then exit(service_install(ctx, args) ? 0 : 1)
|
|
149
|
+
when "uninstall" then exit(service_uninstall(ctx) ? 0 : 1)
|
|
141
150
|
when "status" then service_status(ctx)
|
|
142
151
|
else raise Error, "Usage: ask-local service [install|uninstall|status]"
|
|
143
152
|
end
|
|
144
153
|
end
|
|
145
154
|
|
|
155
|
+
# Print the scoped passwordless-sudo rules that let `service install`
|
|
156
|
+
# (and only it) run without a prompt. The service re-execs the whole
|
|
157
|
+
# gem under sudo, so the safe NOPASSWD grants exactly the gem path +
|
|
158
|
+
# subcommand for the current user — never a bare interpreter. This is
|
|
159
|
+
# how agents and repeat machines get clean :443 without a TTY.
|
|
160
|
+
#
|
|
161
|
+
# macOS: sudo install -o root -g wheel -m 440 <(ask-local sudoers) /etc/sudoers.d/ask-local
|
|
162
|
+
# Linux: sudo install -o root -g root -m 440 <(ask-local sudoers) /etc/sudoers.d/ask-local
|
|
163
|
+
def sudoers(_ctx, _args)
|
|
164
|
+
require "etc"
|
|
165
|
+
ruby = RbConfig.ruby
|
|
166
|
+
bin = ProxyControl.bin_path
|
|
167
|
+
user = ENV.fetch("USER", Etc.getlogin)
|
|
168
|
+
puts <<~SUDOERS
|
|
169
|
+
# ask-local: let #{user} install/run the privileged proxy on port 443
|
|
170
|
+
# without a password prompt. Scoped to ask-local's own service
|
|
171
|
+
# re-exec — the gem path above, not a bare interpreter.
|
|
172
|
+
#{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service install --internal
|
|
173
|
+
#{user} ALL=(root) NOPASSWD: #{ruby} #{bin} service uninstall --internal
|
|
174
|
+
SUDOERS
|
|
175
|
+
end
|
|
176
|
+
|
|
146
177
|
# Root-owned LaunchDaemon binding 80/443 at boot (puma-dev model).
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
178
|
+
# Non-root runs re-exec under sudo once (--internal marks the root
|
|
179
|
+
# half); the proxy runs with the invoking user's state dir so
|
|
180
|
+
# routes registered by unprivileged CLIs are shared. The root half
|
|
181
|
+
# can also write /etc/hosts.
|
|
182
|
+
#
|
|
183
|
+
# Non-interactive runs (agents, CI) use `sudo -n`: never prompts,
|
|
184
|
+
# succeeds only when the scoped NOPASSWD grant from `ask-local
|
|
185
|
+
# sudoers` is installed, and fails fast with guidance otherwise.
|
|
186
|
+
# Interactive runs use plain sudo (one password, then the service
|
|
187
|
+
# is installed for good).
|
|
188
|
+
#
|
|
189
|
+
# Returns true when the service is installed. No exit here: the
|
|
190
|
+
# bare `service install` CLI exits in `service`, while `setup`
|
|
191
|
+
# keeps going (hosts sync, doctor) after a successful install.
|
|
150
192
|
def service_install(ctx, args)
|
|
151
|
-
if
|
|
193
|
+
if ProxyControl.root?
|
|
194
|
+
install_service!(ctx)
|
|
195
|
+
elsif args.include?("--internal")
|
|
196
|
+
raise Error, "`service install --internal` is the root half of the sudo re-exec — run `ask-local service install`"
|
|
197
|
+
else
|
|
152
198
|
puts "Installing system service (sudo required)..."
|
|
153
199
|
state = Certs.state_dir
|
|
154
|
-
|
|
200
|
+
cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}",
|
|
155
201
|
RbConfig.ruby, ProxyControl.bin_path,
|
|
156
|
-
"service", "install", "--internal"
|
|
157
|
-
|
|
202
|
+
"service", "install", "--internal"]
|
|
203
|
+
elevate(cmd)
|
|
158
204
|
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def install_service!(ctx)
|
|
159
208
|
case RUBY_PLATFORM
|
|
160
209
|
when /darwin/ then install_launchd(ctx)
|
|
161
|
-
when /linux/ then
|
|
210
|
+
when /linux/ then install_systemd
|
|
162
211
|
else raise Error, "Service install not supported on #{RUBY_PLATFORM}"
|
|
163
212
|
end
|
|
213
|
+
# Root can write /etc/hosts, so sync the routes registered so
|
|
214
|
+
# far while elevated — Safari works the moment setup finishes
|
|
215
|
+
# (Chrome resolves *.localhost natively).
|
|
216
|
+
sync_hosts_from_routes(ctx)
|
|
217
|
+
true
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def sync_hosts_from_routes(ctx)
|
|
221
|
+
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
222
|
+
if hostnames.empty?
|
|
223
|
+
puts " No routes registered yet — hosts sync will happen on the next boot."
|
|
224
|
+
elsif Ask::Local::Hosts.sync(hostnames)
|
|
225
|
+
puts " Synced #{hostnames.length} hostname(s) to /etc/hosts."
|
|
226
|
+
else
|
|
227
|
+
warn " could not write /etc/hosts (run `sudo ask-local hosts sync` later)"
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Run a privileged command via sudo. Interactive: plain sudo (one
|
|
232
|
+
# prompt). Non-interactive: `sudo -n` — no prompt ever; requires the
|
|
233
|
+
# NOPASSWD grant from `ask-local sudoers`. On failure prints the
|
|
234
|
+
# provisioning hint so agents/CI know exactly what to install.
|
|
235
|
+
def elevate(cmd)
|
|
236
|
+
interactive = $stdin.tty? && ENV["CI"].nil?
|
|
237
|
+
sudo_args = interactive ? ["sudo"] : ["sudo", "-n"]
|
|
238
|
+
ok = Command.run(*sudo_args, *cmd)
|
|
239
|
+
return true if ok
|
|
240
|
+
|
|
241
|
+
# An interactive sudo failure is auth or the elevated command
|
|
242
|
+
# itself (whose error is already on screen) — re-run and read it.
|
|
243
|
+
# The scoped-grant hint only helps passwordless non-interactive
|
|
244
|
+
# runs, where a missing NOPASSWD rule is the usual cause.
|
|
245
|
+
if interactive
|
|
246
|
+
$stderr.puts "sudo failed — re-run `ask-local setup` to try again."
|
|
247
|
+
else
|
|
248
|
+
$stderr.puts "sudo failed — install the scoped grant once:"
|
|
249
|
+
$stderr.puts " ask-local sudoers > /tmp/ask-local.sudoers"
|
|
250
|
+
$stderr.puts " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
|
|
251
|
+
end
|
|
252
|
+
false
|
|
164
253
|
end
|
|
165
254
|
|
|
166
255
|
def user_home_for_service
|
|
@@ -206,17 +295,62 @@ module Ask
|
|
|
206
295
|
path = File.join(dir, "dev.ask.local.plist")
|
|
207
296
|
File.write(path, plist)
|
|
208
297
|
File.chmod(0o644, path)
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
298
|
+
# launchd requires /Library/LaunchDaemons plists to be
|
|
299
|
+
# root-owned; we are root here (sudo re-exec). Enforce it
|
|
300
|
+
# explicitly: File.write keeps an existing file's owner, so a
|
|
301
|
+
# stale user-owned plist from an older version would otherwise
|
|
302
|
+
# survive the overwrite and bootstrap fails with error 5.
|
|
303
|
+
# Trust the CA into the System keychain while elevated: silent
|
|
304
|
+
# (no GUI popup) and trusted for every user on the machine.
|
|
305
|
+
File.chown(0, 0, path) if Process.uid.zero?
|
|
306
|
+
ensure_system_ca_trust
|
|
307
|
+
puts " Registering the launchd service on port 443..."
|
|
308
|
+
launchctl_bootstrap(path)
|
|
212
309
|
puts "Installed root LaunchDaemon on port 443 (state: #{state_dir})."
|
|
213
|
-
puts "Restart your machine or run: sudo launchctl load #{path}"
|
|
214
310
|
end
|
|
215
311
|
|
|
216
|
-
|
|
312
|
+
# Root-only CA trust: the System keychain (all users, no prompt).
|
|
313
|
+
# Safe to call repeatedly — once the marker is set it is a no-op.
|
|
314
|
+
# Prints before doing work: the System keychain add can take a few
|
|
315
|
+
# seconds, and this runs in the root half of setup where silence
|
|
316
|
+
# reads as a hang.
|
|
317
|
+
def ensure_system_ca_trust
|
|
318
|
+
return if Certs.trusted?(Certs.state_dir)
|
|
319
|
+
|
|
320
|
+
puts " Trusting the CA into the System keychain..."
|
|
321
|
+
result = Trust.trust
|
|
322
|
+
warn " CA trust warning: #{result[:error]}" unless result[:trusted]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Modern launchctl system-domain verbs. The legacy `launchctl load`
|
|
326
|
+
# is rejected by current macOS with error 5 (Input/output error) —
|
|
327
|
+
# and worse, it can print that error while still exiting 0, so the
|
|
328
|
+
# old code "succeeded" without the service actually running.
|
|
329
|
+
# bootstrap/bootout are the supported verbs (same as puma-dev and
|
|
330
|
+
# portless); extracted so the command sequence is unit-testable.
|
|
331
|
+
def launchctl_bootstrap(path)
|
|
332
|
+
# Best-effort: booting out a service that was never loaded prints
|
|
333
|
+
# "Boot-out failed: 5" — nothing to clear then, so keep it quiet.
|
|
334
|
+
# Any real leftover is removed silently; bootstrap errors below
|
|
335
|
+
# stay loud.
|
|
336
|
+
Command.run("launchctl", "bootout", "system", path, out: File::NULL, err: File::NULL)
|
|
337
|
+
unless Command.run("launchctl", "bootstrap", "system", path)
|
|
338
|
+
raise Error, "launchctl bootstrap failed — check the plist at #{path}"
|
|
339
|
+
end
|
|
340
|
+
Command.run("launchctl", "enable", "system/dev.ask.local")
|
|
341
|
+
Command.run("launchctl", "kickstart", "-k", "system/dev.ask.local")
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def launchctl_bootout(path)
|
|
345
|
+
Command.run("launchctl", "bootout", "system", path)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Pure unit-file builder (testable without root). Binds 80/443 at
|
|
349
|
+
# boot; the proxy runs with the invoking user's state dir.
|
|
350
|
+
def systemd_unit
|
|
217
351
|
home = user_home_for_service
|
|
218
352
|
state_dir = ENV["ASK_LOCAL_STATE_DIR"] || File.join(home, ".ask-local")
|
|
219
|
-
|
|
353
|
+
<<~UNIT
|
|
220
354
|
# /etc/systemd/system/ask-local.service (binds 80/443 at boot)
|
|
221
355
|
[Unit]
|
|
222
356
|
After=network.target
|
|
@@ -228,28 +362,46 @@ module Ask
|
|
|
228
362
|
|
|
229
363
|
[Install]
|
|
230
364
|
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
365
|
UNIT
|
|
235
366
|
end
|
|
236
367
|
|
|
237
|
-
|
|
368
|
+
# Install + start the systemd unit (mirrors portless). We are root
|
|
369
|
+
# here (sudo re-exec). The unit is written root-owned, then enabled
|
|
370
|
+
# and started.
|
|
371
|
+
def install_systemd
|
|
372
|
+
unit_path = "/etc/systemd/system/ask-local.service"
|
|
373
|
+
File.write(unit_path, systemd_unit)
|
|
374
|
+
File.chmod(0o644, unit_path)
|
|
375
|
+
File.chown(0, 0, unit_path) if Process.uid.zero?
|
|
376
|
+
puts " Registering the systemd service on port 443..."
|
|
377
|
+
Command.run("systemctl", "daemon-reload") or raise Error, "systemctl daemon-reload failed"
|
|
378
|
+
Command.run("systemctl", "enable", "--now", "ask-local") or raise Error, "systemctl enable failed"
|
|
379
|
+
puts "Installed systemd service ask-local on port 443."
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def service_uninstall(ctx)
|
|
238
383
|
if !ProxyControl.root?
|
|
384
|
+
puts "Removing system service (sudo required)..."
|
|
239
385
|
state = Certs.state_dir
|
|
240
|
-
|
|
241
|
-
RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"
|
|
242
|
-
|
|
386
|
+
cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}",
|
|
387
|
+
RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal"]
|
|
388
|
+
return elevate(cmd)
|
|
243
389
|
end
|
|
244
390
|
case RUBY_PLATFORM
|
|
245
391
|
when /darwin/
|
|
246
392
|
path = "/Library/LaunchDaemons/dev.ask.local.plist"
|
|
247
|
-
|
|
393
|
+
launchctl_bootout(path)
|
|
248
394
|
FileUtils.rm_f(path)
|
|
249
395
|
puts "Removed root LaunchDaemon."
|
|
396
|
+
when /linux/
|
|
397
|
+
Command.run("systemctl", "disable", "--now", "ask-local")
|
|
398
|
+
Command.run("systemctl", "daemon-reload")
|
|
399
|
+
FileUtils.rm_f("/etc/systemd/system/ask-local.service")
|
|
400
|
+
puts "Removed systemd service ask-local."
|
|
250
401
|
else
|
|
251
|
-
|
|
402
|
+
raise Error, "Service uninstall not supported on #{RUBY_PLATFORM}"
|
|
252
403
|
end
|
|
404
|
+
true
|
|
253
405
|
end
|
|
254
406
|
|
|
255
407
|
def service_status(ctx)
|
|
@@ -263,16 +415,72 @@ module Ask
|
|
|
263
415
|
end
|
|
264
416
|
end
|
|
265
417
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
418
|
+
|
|
419
|
+
def init(ctx, _args)
|
|
420
|
+
config_path = File.join(Dir.pwd, Config::RELATIVE_PATH)
|
|
421
|
+
if File.file?(config_path)
|
|
422
|
+
$stderr.puts "config/local.yml already exists at #{config_path}"
|
|
423
|
+
return
|
|
424
|
+
end
|
|
425
|
+
FileUtils.mkdir_p(File.join(Dir.pwd, Config::RELATIVE_DIR))
|
|
426
|
+
|
|
427
|
+
# Migrate Procfile.dev / Procfile if present.
|
|
428
|
+
procfile = Ask::Local::Procfile.find_file(Dir.pwd)
|
|
429
|
+
service = Ask::Local::Sanitize.hostname_label(File.basename(Dir.pwd))
|
|
430
|
+
|
|
431
|
+
process_lines = []
|
|
432
|
+
if procfile
|
|
433
|
+
lines = Ask::Local::Procfile.parse_file(procfile)
|
|
434
|
+
lines.each do |l|
|
|
435
|
+
type = Ask::Local::Procfile.classify(l.name) == :background ? "false" : "true"
|
|
436
|
+
process_lines << " #{l.name}:"
|
|
437
|
+
process_lines << " cmd: #{l.command}"
|
|
438
|
+
process_lines << " proxy: #{type}"
|
|
439
|
+
process_lines << " # NOTE: compound line - run explicitly" if l.compound
|
|
440
|
+
end
|
|
441
|
+
else
|
|
442
|
+
# Default: a single web process. Detect Rails (Gemfile with
|
|
443
|
+
# rails) vs plain Rack (config.ru) and pick the right boot.
|
|
444
|
+
has_rails = File.file?(File.join(Dir.pwd, "Gemfile")) &&
|
|
445
|
+
File.read(File.join(Dir.pwd, "Gemfile")).match?(/gem ["']rails["']/)
|
|
446
|
+
cmd =
|
|
447
|
+
if has_rails
|
|
448
|
+
"bundle exec puma -b tcp://127.0.0.1:$PORT config.ru"
|
|
449
|
+
elsif File.file?(File.join(Dir.pwd, "config.ru"))
|
|
450
|
+
"puma -b tcp://127.0.0.1:$PORT config.ru"
|
|
451
|
+
else
|
|
452
|
+
"bin/rails server -p $PORT"
|
|
453
|
+
end
|
|
454
|
+
process_lines << " web:"
|
|
455
|
+
process_lines << " cmd: #{cmd}"
|
|
456
|
+
process_lines << " proxy: true"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
content = <<~YAML
|
|
460
|
+
# config/local.yml — ask-local configuration (Kamal-style).
|
|
461
|
+
# This is the only source of truth. Run `ask-local` to boot everything.
|
|
462
|
+
# Overlay variants with config/local.<variant>.yml.
|
|
463
|
+
|
|
464
|
+
service: #{service}
|
|
465
|
+
|
|
466
|
+
proxy:
|
|
467
|
+
tld: localhost
|
|
468
|
+
|
|
469
|
+
processes:
|
|
470
|
+
#{process_lines.join("\n")}
|
|
471
|
+
|
|
472
|
+
env:
|
|
473
|
+
clear:
|
|
474
|
+
RAILS_ENV: development
|
|
475
|
+
YAML
|
|
476
|
+
File.write(config_path, content)
|
|
477
|
+
puts "Created #{config_path}"
|
|
478
|
+
puts " service: #{service}"
|
|
479
|
+
puts " processes from: #{procfile || 'defaults'}"
|
|
480
|
+
puts "Run `ask-local start` to boot."
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
|
|
276
484
|
def setup(ctx, args)
|
|
277
485
|
if args.include?("--help") || args.include?("-h")
|
|
278
486
|
puts <<~HELP
|
|
@@ -280,52 +488,64 @@ module Ask
|
|
|
280
488
|
|
|
281
489
|
One-shot workstation setup for clean https://<app>.localhost URLs:
|
|
282
490
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
491
|
+
Default: install the root proxy service on 443 — runs under
|
|
492
|
+
sudo ONCE, trusting the CA system-wide in the same step
|
|
493
|
+
(no separate GUI authorization popup).
|
|
494
|
+
--no-service: trust the CA at user level, then run a sudo
|
|
495
|
+
daemon instead (no boot persistence; ephemeral machines).
|
|
287
496
|
|
|
288
|
-
|
|
289
|
-
instead (no boot persistence; good for ephemeral machines).
|
|
497
|
+
Both finish by syncing /etc/hosts and verifying with doctor.
|
|
290
498
|
HELP
|
|
291
499
|
return
|
|
292
500
|
end
|
|
293
501
|
|
|
294
|
-
|
|
295
|
-
result = Ask::Local::Trust.trust
|
|
296
|
-
unless result[:trusted]
|
|
297
|
-
abort_setup("CA trust failed: #{result[:error]}",
|
|
298
|
-
"Run `ask-local trust` manually to see the underlying error,",
|
|
299
|
-
"then re-run `ask-local setup`.")
|
|
300
|
-
end
|
|
301
|
-
end
|
|
502
|
+
puts "Note: setup takes a few seconds while the 443 service installs and starts."
|
|
302
503
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
504
|
+
no_service = args.include?("--no-service")
|
|
505
|
+
steps = no_service ? 4 : 3
|
|
506
|
+
|
|
507
|
+
if no_service
|
|
508
|
+
step("1/#{steps} Trusting local CA") do
|
|
509
|
+
result = Ask::Local::Trust.trust
|
|
510
|
+
unless result[:trusted]
|
|
511
|
+
abort_setup("CA trust failed: #{result[:error]}",
|
|
512
|
+
"Run `ask-local trust` manually to see the underlying error,",
|
|
513
|
+
"then re-run `ask-local setup`.")
|
|
309
514
|
end
|
|
310
515
|
end
|
|
311
|
-
|
|
312
|
-
step("2/4 Starting proxy sudo daemon on port 443") do
|
|
516
|
+
step("2/#{steps} Starting proxy sudo daemon on port 443") do
|
|
313
517
|
unless ensure_sudo_daemon(ctx)
|
|
314
518
|
abort_setup("Could not start the proxy daemon on port 443.",
|
|
315
519
|
"Check the log, then re-run `ask-local setup`.")
|
|
316
520
|
end
|
|
317
521
|
end
|
|
522
|
+
else
|
|
523
|
+
step("1/#{steps} Installing proxy service on port 443 (trusts CA)") do
|
|
524
|
+
# Runs under sudo once; inside, the CA is trusted into the
|
|
525
|
+
# System keychain silently (no GUI popup) and the launchd
|
|
526
|
+
# service is bootstrapped. One password entry, that's all.
|
|
527
|
+
unless ensure_root_service(ctx)
|
|
528
|
+
abort_setup("Could not install the proxy service.",
|
|
529
|
+
"Fallback: `ask-local setup --no-service` for a sudo daemon",
|
|
530
|
+
"without boot persistence.")
|
|
531
|
+
end
|
|
532
|
+
end
|
|
318
533
|
end
|
|
319
534
|
|
|
320
|
-
step("3
|
|
535
|
+
step("#{no_service ? 3 : 2}/#{steps} Syncing /etc/hosts") do
|
|
321
536
|
hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
|
|
322
|
-
|
|
537
|
+
next if hostnames.empty?
|
|
538
|
+
|
|
539
|
+
# The root service install already synced under elevation; a
|
|
540
|
+
# plain re-run must not fail rewriting /etc/hosts unprivileged
|
|
541
|
+
# when the block is already in place.
|
|
542
|
+
unless Ask::Local::Hosts.synced?(hostnames) || Ask::Local::Hosts.sync(hostnames)
|
|
323
543
|
abort_setup("Could not write /etc/hosts.",
|
|
324
544
|
"Run `sudo ask-local hosts sync`, then re-run `ask-local setup`.")
|
|
325
545
|
end
|
|
326
546
|
end
|
|
327
547
|
|
|
328
|
-
step("4
|
|
548
|
+
step("#{no_service ? 4 : 3}/#{steps} Verifying with doctor") do
|
|
329
549
|
failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout)
|
|
330
550
|
if failed.zero?
|
|
331
551
|
puts "\nSetup complete: https://<app>.localhost URLs are ready."
|
|
@@ -349,12 +569,24 @@ module Ask
|
|
|
349
569
|
exit 1
|
|
350
570
|
end
|
|
351
571
|
|
|
572
|
+
# The two ways to get a privileged proxy on 443: a human runs setup
|
|
573
|
+
# once (interactive sudo), or an agent/CI image is pre-provisioned
|
|
574
|
+
# with the scoped NOPASSWD rules from `ask-local sudoers`.
|
|
575
|
+
def privileged_port_hint
|
|
576
|
+
[
|
|
577
|
+
"Human: run this once — ask-local setup",
|
|
578
|
+
"Agent/CI: pre-provision passwordless sudo once —",
|
|
579
|
+
" ask-local sudoers > /tmp/ask-local.sudoers",
|
|
580
|
+
" sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
|
|
581
|
+
]
|
|
582
|
+
end
|
|
583
|
+
|
|
352
584
|
# Install the root service (boot-persistent). Returns true when a
|
|
353
585
|
# proxy is up on 443 afterwards, false otherwise. Never falls back
|
|
354
586
|
# to a high port silently: a :port suffix in URLs would corrupt the
|
|
355
587
|
# stable-URL promise, so failure here is a hard error with guidance.
|
|
356
588
|
def ensure_root_service(ctx)
|
|
357
|
-
service_install(ctx, [])
|
|
589
|
+
return false unless service_install(ctx, [])
|
|
358
590
|
wait_for_ours(ctx, 443, tls: true)
|
|
359
591
|
rescue Error, SystemCallError => e
|
|
360
592
|
warn " service install failed: #{e.message}"
|
|
@@ -366,6 +598,9 @@ module Ask
|
|
|
366
598
|
port, tls = 443, true
|
|
367
599
|
unless ctx.interactive?
|
|
368
600
|
warn " no TTY available for the sudo prompt."
|
|
601
|
+
warn " Agent/CI: pre-provision passwordless sudo once —"
|
|
602
|
+
warn " ask-local sudoers > /tmp/ask-local.sudoers"
|
|
603
|
+
warn " sudo install -o root -g wheel -m 440 /tmp/ask-local.sudoers /etc/sudoers.d/ask-local"
|
|
369
604
|
return false
|
|
370
605
|
end
|
|
371
606
|
ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
|
|
@@ -375,14 +610,44 @@ module Ask
|
|
|
375
610
|
false
|
|
376
611
|
end
|
|
377
612
|
|
|
613
|
+
# Poll until our proxy answers on the port. The freshly installed
|
|
614
|
+
# service takes a few seconds to boot, so show motion instead of a
|
|
615
|
+
# frozen prompt: a spinner on a terminal, dots elsewhere. Silent
|
|
616
|
+
# when the proxy is already up.
|
|
378
617
|
def wait_for_ours(ctx, port, tls:, timeout: 20)
|
|
379
618
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
380
|
-
|
|
381
|
-
|
|
619
|
+
terminal = $stdout.respond_to?(:tty?) && $stdout.tty?
|
|
620
|
+
waiting = false
|
|
621
|
+
frame = 0
|
|
622
|
+
ok = false
|
|
623
|
+
loop do
|
|
624
|
+
if ProxyControl.ours?(port, tls: tls)
|
|
625
|
+
ok = true
|
|
626
|
+
break
|
|
627
|
+
end
|
|
628
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
382
629
|
|
|
630
|
+
unless waiting
|
|
631
|
+
print terminal ? " starting the proxy on port #{port} " : " (starting the proxy on port #{port}"
|
|
632
|
+
waiting = true
|
|
633
|
+
end
|
|
634
|
+
if terminal
|
|
635
|
+
print %w[| / - \\][frame % 4], "\b"
|
|
636
|
+
else
|
|
637
|
+
print "."
|
|
638
|
+
end
|
|
639
|
+
$stdout.flush
|
|
640
|
+
frame += 1
|
|
383
641
|
sleep 0.5
|
|
384
642
|
end
|
|
385
|
-
|
|
643
|
+
if waiting
|
|
644
|
+
if terminal
|
|
645
|
+
puts(ok ? "\r proxy is up on port #{port}." : "\r still not up on port #{port}.")
|
|
646
|
+
else
|
|
647
|
+
puts ")"
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
ok
|
|
386
651
|
end
|
|
387
652
|
|
|
388
653
|
# ask-local start — one-setup-and-go: idempotent workstation setup
|
|
@@ -445,8 +710,7 @@ module Ask
|
|
|
445
710
|
tls = true
|
|
446
711
|
unless Ask::Local::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
|
|
447
712
|
if port < 1024 && !Ask::Local::ProxyControl.root? && !ctx.interactive?
|
|
448
|
-
abort_setup("Proxy is not running and port 443 needs root to bind.",
|
|
449
|
-
"Run this once in a terminal: ask-local setup")
|
|
713
|
+
abort_setup("Proxy is not running and port 443 needs root to bind.", *privileged_port_hint)
|
|
450
714
|
end
|
|
451
715
|
ok =
|
|
452
716
|
if ProxyControl.root?
|
|
@@ -463,9 +727,7 @@ module Ask
|
|
|
463
727
|
false
|
|
464
728
|
end
|
|
465
729
|
unless ok
|
|
466
|
-
abort_setup("Proxy is not running and could not be started on port 443.",
|
|
467
|
-
"Run `ask-local setup` in a terminal (it handles trust + service + hosts),",
|
|
468
|
-
"then re-run `ask-local start`.")
|
|
730
|
+
abort_setup("Proxy is not running and could not be started on port 443.", *privileged_port_hint)
|
|
469
731
|
end
|
|
470
732
|
end
|
|
471
733
|
|
|
@@ -492,7 +754,7 @@ module Ask
|
|
|
492
754
|
raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com] [--tld <tld>]" unless variant
|
|
493
755
|
tld = opts[:tld] || ENV["ASK_LOCAL_TLD"]&.split(",")&.first || "localhost"
|
|
494
756
|
|
|
495
|
-
app = opts[:app] || Resolver.resolve(Dir.pwd
|
|
757
|
+
app = opts[:app] || Resolver.resolve(Dir.pwd).app
|
|
496
758
|
domain = opts[:domain] || ENV["ASK_LOCAL_KAMAL_DOMAIN"] || "preview.example.com"
|
|
497
759
|
slug = Sanitize.hostname_label(variant)
|
|
498
760
|
puts "# Paste into deploy.yml proxy section for a preview of variant #{slug}:"
|
data/lib/ask/local/cli.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Ask
|
|
|
11
11
|
# In non-interactive environments (no TTY or CI=1) we fail early with
|
|
12
12
|
# a clear message instead of prompting (portless lesson).
|
|
13
13
|
class CLI
|
|
14
|
-
SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open setup start].freeze
|
|
14
|
+
SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
|
|
15
15
|
|
|
16
16
|
def self.run(argv)
|
|
17
17
|
new.run(argv)
|
|
@@ -44,7 +44,9 @@ module Ask
|
|
|
44
44
|
when "prune" then RoutesCommand.prune(ctx, args)
|
|
45
45
|
when "proxy" then SystemCommand.proxy(ctx, args)
|
|
46
46
|
when "service" then SystemCommand.service(ctx, args)
|
|
47
|
+
when "sudoers" then SystemCommand.sudoers(ctx, args)
|
|
47
48
|
when "setup" then SystemCommand.setup(ctx, args)
|
|
49
|
+
when "init" then SystemCommand.init(ctx, args)
|
|
48
50
|
when "start" then SystemCommand.start(ctx, args)
|
|
49
51
|
when "kamal" then SystemCommand.kamal(ctx, args)
|
|
50
52
|
when "stop"
|
|
@@ -82,6 +84,7 @@ module Ask
|
|
|
82
84
|
ask-local prune Remove stale routes
|
|
83
85
|
ask-local proxy start|stop Control the proxy
|
|
84
86
|
ask-local service install|status|uninstall OS startup service
|
|
87
|
+
ask-local sudoers Print scoped passwordless-sudo rules for port 443
|
|
85
88
|
ask-local hosts sync|clean Manage /etc/hosts entries
|
|
86
89
|
ask-local kamal <variant> Preview-deploy snippet for Kamal
|
|
87
90
|
ask-local stop Stop this app's backend + routes
|
|
@@ -99,7 +102,6 @@ module Ask
|
|
|
99
102
|
def inject_port_flags(command, port)
|
|
100
103
|
BootCommand.inject_port_flags(command, port)
|
|
101
104
|
end
|
|
102
|
-
|
|
103
105
|
def procfile_command(process = nil)
|
|
104
106
|
BootCommand.procfile_command(process)
|
|
105
107
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module Ask
|
|
6
|
+
module Local
|
|
7
|
+
# Injectable command runner for privileged/child-process work.
|
|
8
|
+
#
|
|
9
|
+
# Every system()/Open3 call that tests must control goes through here
|
|
10
|
+
# (elevation re-exec, launchctl, systemctl, security). Unit tests stub
|
|
11
|
+
# Command.run / Command.capture2 so they never shell out — no real
|
|
12
|
+
# sudo prompt, no keychain mutation, hermetic and CI-safe. The real
|
|
13
|
+
# implementations are the thin wrappers below.
|
|
14
|
+
#
|
|
15
|
+
# A plain class (not module_function): Mocha stubs class methods
|
|
16
|
+
# reliably, whereas module_function singletons dodge the stub and the
|
|
17
|
+
# real command would run.
|
|
18
|
+
class Command
|
|
19
|
+
# True when the command exited 0. Mirrors Kernel#system semantics.
|
|
20
|
+
def self.run(*args)
|
|
21
|
+
system(*args)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# [stdout, Process::Status] — mirrors Open3.capture2.
|
|
25
|
+
def self.capture2(*args)
|
|
26
|
+
Open3.capture2(*args)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|