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.
@@ -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
- # 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.
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 !ProxyControl.root? && !args.include?("--internal")
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
- ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
200
+ cmd = ["env", "ASK_LOCAL_STATE_DIR=#{state}",
155
201
  RbConfig.ruby, ProxyControl.bin_path,
156
- "service", "install", "--internal")
157
- exit(ok ? 0 : 1)
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 print_linux_unit
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
- Ownership.chown_service_files(path)
210
- system("launchctl", "unload", path) rescue nil
211
- system("launchctl", "load", path) or raise Error, "launchctl load failed"
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
- def print_linux_unit
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
- puts <<~UNIT
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
- def service_uninstall(_ctx)
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
- ok = system("sudo", "env", "ASK_LOCAL_STATE_DIR=#{state}",
241
- RbConfig.ruby, ProxyControl.bin_path, "service", "uninstall", "--internal")
242
- exit(ok ? 0 : 1)
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
- system("launchctl", "unload", path) rescue nil
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
- puts "Remove /etc/systemd/system/ask-local.service, then: sudo systemctl disable --now ask-local"
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
- # One-shot workstation setup: everything needed for clean
267
- # https://<app>.localhost URLs, in order, with a clear fix-it
268
- # message on the first failure. Run this once per machine:
269
- #
270
- # ask-local setup
271
- #
272
- # Steps: trust the local CA -> ensure the proxy serves port 443
273
- # (root service when possible, sudo daemon otherwise) -> sync
274
- # /etc/hosts -> verify with doctor. After this, plain `ask-local`
275
- # in any app dir just works with no :port suffix, ever.
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
- 1. Trust the local CA (no more browser warnings)
284
- 2. Serve port 443 (root service at boot, or sudo daemon now)
285
- 3. Sync /etc/hosts (Safari + custom TLDs)
286
- 4. Verify everything with doctor
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
- --no-service skips the root service and starts a sudo daemon
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
- step("1/4 Trusting local CA") do
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
- unless args.include?("--no-service")
304
- step("2/4 Installing proxy service on port 443") do
305
- unless ensure_root_service(ctx)
306
- abort_setup("Could not install the proxy service.",
307
- "Fallback: `ask-local setup --no-service` for a sudo daemon",
308
- "without boot persistence.")
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
- else
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/4 Syncing /etc/hosts") do
535
+ step("#{no_service ? 3 : 2}/#{steps} Syncing /etc/hosts") do
321
536
  hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
322
- unless Ask::Local::Hosts.sync(hostnames)
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/4 Verifying with doctor") do
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
- until ProxyControl.ours?(port, tls: tls)
381
- return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
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
- true
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, use_branch: false).app
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