ask-local 0.1.0 → 0.2.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.
@@ -19,13 +19,11 @@ module Ask
19
19
  raise Error, "Usage: ask-local get <name> [--service s] [--variant v] [--tld t]" unless name
20
20
 
21
21
  opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
22
- context = Resolver.resolve(Dir.pwd, service: opts[:service],
23
- variant: opts[:variant], tlds: opts[:tld], use_branch: false)
22
+ context = Resolver.resolve(Dir.pwd, variant: opts[:variant])
24
23
  hostnames = Hostname.build(
25
24
  app: Sanitize.hostname_label(name),
26
- service: context.service,
27
- variant: context.variant,
28
- tlds: context.tlds
25
+ tlds: Array(context.tld || Ask::Local::Hostname::DEFAULT_TLD),
26
+ variant: context.variant
29
27
  )
30
28
  puts Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
31
29
  end
@@ -206,9 +204,11 @@ module Ask
206
204
  end
207
205
  payload = {
208
206
  app: resolved.app, app_source: resolved.sources[:app],
209
- service: resolved.service, service_source: resolved.sources[:service],
207
+ tld: resolved.tld, tld_source: resolved.sources[:tld],
208
+ host: resolved.host, host_source: resolved.sources[:host],
210
209
  variant: resolved.variant, variant_source: resolved.sources[:variant],
211
- tlds: resolved.tlds, urls: urls,
210
+ urls: urls,
211
+ processes: resolved.processes.keys,
212
212
  framework: Framework.detect(Dir.pwd).to_s
213
213
  }
214
214
  if json
@@ -217,9 +217,13 @@ module Ask
217
217
  return
218
218
  end
219
219
  puts "app: #{payload[:app]} (from #{payload[:app_source]})"
220
- puts "service: #{payload[:service] || "web (default, bare)"} (from #{payload[:service_source] || "default"})"
221
- puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no worktree, branch, flag, or env"})"
222
- puts "tlds: #{payload[:tlds].join(", ")}"
220
+ if payload[:host]
221
+ puts "host: #{payload[:host]} (from #{payload[:host_source]})"
222
+ else
223
+ puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
224
+ end
225
+ puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no overlay file, flag, or env"})"
226
+ puts "processes: #{payload[:processes].join(", ")}"
223
227
  puts "urls:"
224
228
  urls.each { |u| puts " #{u}" }
225
229
  puts "framework: #{payload[:framework]}"
@@ -231,10 +235,10 @@ module Ask
231
235
  url =
232
236
  if name
233
237
  opts = ctx.parse_flags(args[1..] || [], %i[service variant tld])
234
- context = Resolver.resolve(Dir.pwd, service: opts[:service],
235
- variant: opts[:variant], tlds: opts[:tld], use_branch: false)
238
+ context = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld])
236
239
  hostnames = Hostname.build(app: Sanitize.hostname_label(name),
237
- service: context.service, variant: context.variant, tlds: context.tlds)
240
+ tlds: Array(context.tld || Ask::Local::Hostname::DEFAULT_TLD),
241
+ variant: context.variant)
238
242
  Hostname.url(hostnames.first, port: ctx.proxy_port, tls: ctx.proxy_tls)
239
243
  else
240
244
  resolved = Resolver.resolve(Dir.pwd)
@@ -263,6 +263,273 @@ module Ask
263
263
  end
264
264
  end
265
265
 
266
+
267
+ def init(ctx, _args)
268
+ config_path = File.join(Dir.pwd, Config::RELATIVE_PATH)
269
+ if File.file?(config_path)
270
+ $stderr.puts "config/local.yml already exists at #{config_path}"
271
+ return
272
+ end
273
+ FileUtils.mkdir_p(File.join(Dir.pwd, Config::RELATIVE_DIR))
274
+
275
+ # Migrate Procfile.dev / Procfile if present.
276
+ procfile = Ask::Local::Procfile.find_file(Dir.pwd)
277
+ service = Ask::Local::Sanitize.hostname_label(File.basename(Dir.pwd))
278
+
279
+ process_lines = []
280
+ if procfile
281
+ lines = Ask::Local::Procfile.parse_file(procfile)
282
+ lines.each do |l|
283
+ type = Ask::Local::Procfile.classify(l.name) == :background ? "false" : "true"
284
+ process_lines << " #{l.name}:"
285
+ process_lines << " cmd: #{l.command}"
286
+ process_lines << " proxy: #{type}"
287
+ process_lines << " # NOTE: compound line - run explicitly" if l.compound
288
+ end
289
+ else
290
+ # Default: a single web process. Detect Rails (Gemfile with
291
+ # rails) vs plain Rack (config.ru) and pick the right boot.
292
+ has_rails = File.file?(File.join(Dir.pwd, "Gemfile")) &&
293
+ File.read(File.join(Dir.pwd, "Gemfile")).match?(/gem ["']rails["']/)
294
+ cmd =
295
+ if has_rails
296
+ "bundle exec puma -b tcp://127.0.0.1:$PORT config.ru"
297
+ elsif File.file?(File.join(Dir.pwd, "config.ru"))
298
+ "puma -b tcp://127.0.0.1:$PORT config.ru"
299
+ else
300
+ "bin/rails server -p $PORT"
301
+ end
302
+ process_lines << " web:"
303
+ process_lines << " cmd: #{cmd}"
304
+ process_lines << " proxy: true"
305
+ end
306
+
307
+ content = <<~YAML
308
+ # config/local.yml — ask-local configuration (Kamal-style).
309
+ # This is the only source of truth. Run `ask-local` to boot everything.
310
+ # Overlay variants with config/local.<variant>.yml.
311
+
312
+ service: #{service}
313
+
314
+ proxy:
315
+ tld: localhost
316
+
317
+ processes:
318
+ #{process_lines.join("\n")}
319
+
320
+ env:
321
+ clear:
322
+ RAILS_ENV: development
323
+ YAML
324
+ File.write(config_path, content)
325
+ puts "Created #{config_path}"
326
+ puts " service: #{service}"
327
+ puts " processes from: #{procfile || 'defaults'}"
328
+ puts "Run `ask-local start` to boot."
329
+ end
330
+
331
+
332
+ def setup(ctx, args)
333
+ if args.include?("--help") || args.include?("-h")
334
+ puts <<~HELP
335
+ Usage: ask-local setup [--no-service]
336
+
337
+ One-shot workstation setup for clean https://<app>.localhost URLs:
338
+
339
+ 1. Trust the local CA (no more browser warnings)
340
+ 2. Serve port 443 (root service at boot, or sudo daemon now)
341
+ 3. Sync /etc/hosts (Safari + custom TLDs)
342
+ 4. Verify everything with doctor
343
+
344
+ --no-service skips the root service and starts a sudo daemon
345
+ instead (no boot persistence; good for ephemeral machines).
346
+ HELP
347
+ return
348
+ end
349
+
350
+ step("1/4 Trusting local CA") do
351
+ result = Ask::Local::Trust.trust
352
+ unless result[:trusted]
353
+ abort_setup("CA trust failed: #{result[:error]}",
354
+ "Run `ask-local trust` manually to see the underlying error,",
355
+ "then re-run `ask-local setup`.")
356
+ end
357
+ end
358
+
359
+ unless args.include?("--no-service")
360
+ step("2/4 Installing proxy service on port 443") do
361
+ unless ensure_root_service(ctx)
362
+ abort_setup("Could not install the proxy service.",
363
+ "Fallback: `ask-local setup --no-service` for a sudo daemon",
364
+ "without boot persistence.")
365
+ end
366
+ end
367
+ else
368
+ step("2/4 Starting proxy sudo daemon on port 443") do
369
+ unless ensure_sudo_daemon(ctx)
370
+ abort_setup("Could not start the proxy daemon on port 443.",
371
+ "Check the log, then re-run `ask-local setup`.")
372
+ end
373
+ end
374
+ end
375
+
376
+ step("3/4 Syncing /etc/hosts") do
377
+ hostnames = ctx.store.load_routes.map { |r| r["hostname"] }
378
+ unless Ask::Local::Hosts.sync(hostnames)
379
+ abort_setup("Could not write /etc/hosts.",
380
+ "Run `sudo ask-local hosts sync`, then re-run `ask-local setup`.")
381
+ end
382
+ end
383
+
384
+ step("4/4 Verifying with doctor") do
385
+ failed = Doctor.print(Doctor.run(store: ctx.store), out: $stdout)
386
+ if failed.zero?
387
+ puts "\nSetup complete: https://<app>.localhost URLs are ready."
388
+ puts "Try it: cd ~/code/myapp && ask-local"
389
+ else
390
+ abort_setup("Doctor reports #{failed} failing check(s) (see above).",
391
+ "Fix the reported issues, then re-run `ask-local setup`.")
392
+ end
393
+ end
394
+ end
395
+
396
+ def step(label)
397
+ puts "\n==> #{label}..."
398
+ yield
399
+ puts " ok"
400
+ end
401
+
402
+ def abort_setup(problem, *fixes)
403
+ $stderr.puts "\nSetup failed: #{problem}"
404
+ fixes.each { |f| $stderr.puts " #{f}" }
405
+ exit 1
406
+ end
407
+
408
+ # Install the root service (boot-persistent). Returns true when a
409
+ # proxy is up on 443 afterwards, false otherwise. Never falls back
410
+ # to a high port silently: a :port suffix in URLs would corrupt the
411
+ # stable-URL promise, so failure here is a hard error with guidance.
412
+ def ensure_root_service(ctx)
413
+ service_install(ctx, [])
414
+ wait_for_ours(ctx, 443, tls: true)
415
+ rescue Error, SystemCallError => e
416
+ warn " service install failed: #{e.message}"
417
+ false
418
+ end
419
+
420
+ # Sudo daemon for 443 without boot persistence (--no-service).
421
+ def ensure_sudo_daemon(ctx)
422
+ port, tls = 443, true
423
+ unless ctx.interactive?
424
+ warn " no TTY available for the sudo prompt."
425
+ return false
426
+ end
427
+ ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
428
+ wait_for_ours(ctx, port, tls: tls)
429
+ rescue Ask::Local::ProxyNotRunningError, SystemCallError => e
430
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
431
+ false
432
+ end
433
+
434
+ def wait_for_ours(ctx, port, tls:, timeout: 20)
435
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
436
+ until ProxyControl.ours?(port, tls: tls)
437
+ return false if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
438
+
439
+ sleep 0.5
440
+ end
441
+ true
442
+ end
443
+
444
+ # ask-local start — one-setup-and-go: idempotent workstation setup
445
+ # (trust, 443, hosts) when doctor fails, then boots the app in the
446
+ # current directory. The single command you run day-to-day; existing
447
+ # bare `ask-local` keeps working via BootCommand.run_inferred, and
448
+ # `setup` stays for explicit re-setup.
449
+ def start(ctx, args)
450
+ if args.include?("--help") || args.include?("-h")
451
+ puts <<~HELP
452
+ Usage: ask-local start [name] [cmd...] [options]
453
+
454
+ One-setup-and-go: if the workstation isn't ready (CA, proxy,
455
+ hosts), runs the minimal needed setup first, then boots the
456
+ app in the current directory.
457
+
458
+ ask-local start # infer name, boot -> https://<app>.localhost
459
+ ask-local start myapp # explicit name
460
+ ask-local start -- --help # pass --help to the app, not here
461
+
462
+ Options are passed through to the boot path:
463
+ --name <name> --service <svc> --variant <v> --tld <tld> --branch
464
+
465
+ Setup failures become hard errors pointing at `ask-local setup`;
466
+ non-interactive CI without a running proxy exits immediately.
467
+ HELP
468
+ return
469
+ end
470
+
471
+ # Fast path: every doctor check passes => skip setup entirely.
472
+ # This makes `start` as fast as `ask-local` on a ready machine.
473
+ if needs_workstation_setup?(ctx)
474
+ ensure_workstation!(ctx)
475
+ end
476
+ BootCommand.run_inferred(ctx, args)
477
+ end
478
+
479
+ def needs_workstation_setup?(ctx)
480
+ Ask::Local::Doctor.run(store: ctx.store).any? { |c| !c.ok }
481
+ end
482
+
483
+
484
+ # Quiet workstation setup for `start`: trust the CA, ensure a proxy
485
+ # on 443 (root service, sudo daemon fallback), and sync hosts.
486
+ # Each step is idempotent; only missing pieces run. Non-interactive
487
+ # CI without a proxy fails fast rather than prompting for sudo.
488
+ def ensure_workstation!(ctx)
489
+ # 1. CA
490
+ unless Ask::Local::Certs.trusted?(ctx.store.dir)
491
+ result = Ask::Local::Trust.trust
492
+ unless result[:trusted]
493
+ abort_setup("CA trust failed: #{result[:error]}",
494
+ "Run `ask-local setup` in a terminal (handles trust + service),",
495
+ "then re-run `ask-local start`.")
496
+ end
497
+ end
498
+
499
+ # 2. Proxy on 443
500
+ port = 443
501
+ tls = true
502
+ unless Ask::Local::ProxyControl.listening?(port) && ProxyControl.ours?(port, tls: tls)
503
+ if port < 1024 && !Ask::Local::ProxyControl.root? && !ctx.interactive?
504
+ abort_setup("Proxy is not running and port 443 needs root to bind.",
505
+ "Run this once in a terminal: ask-local setup")
506
+ end
507
+ ok =
508
+ if ProxyControl.root?
509
+ Ask::Local::CLI::SystemCommand.ensure_root_service(ctx)
510
+ elsif ctx.interactive?
511
+ begin
512
+ Ask::Local::ProxyControl.spawn_daemon(store: ctx.store, port: port, tls: tls, sudo: true)
513
+ wait_for_ours(ctx, port, tls: tls)
514
+ rescue Ask::Local::ProxyNotRunningError, SystemCallError => e
515
+ warn " daemon start failed: #{e.message.lines.first&.strip}"
516
+ false
517
+ end
518
+ else
519
+ false
520
+ end
521
+ unless ok
522
+ abort_setup("Proxy is not running and could not be started on port 443.",
523
+ "Run `ask-local setup` in a terminal (it handles trust + service + hosts),",
524
+ "then re-run `ask-local start`.")
525
+ end
526
+ end
527
+
528
+ # 3. Hosts (best-effort: only needed for Safari; warn, don't fail)
529
+ unless Hosts.sync(ctx.store.load_routes.map { |r| r["hostname"] })
530
+ warn "Warning: could not write /etc/hosts (try sudo ask-local hosts sync)."
531
+ end
532
+ end
266
533
  # ask-local kamal <variant> [--app myapp] [--domain preview.example.com]
267
534
  def kamal(_ctx, args)
268
535
  opts = {}
@@ -273,13 +540,15 @@ module Ask
273
540
  case a[i]
274
541
  when "--app" then opts[:app] = a.fetch(i + 1); i += 2
275
542
  when "--domain" then opts[:domain] = a.fetch(i + 1); i += 2
543
+ when "--tld" then opts[:tld] = a.fetch(i + 1); i += 2
276
544
  else rest << a[i]; i += 1
277
545
  end
278
546
  end
279
547
  variant = rest.first
280
- raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com]" unless variant
548
+ raise Error, "Usage: ask-local kamal <variant> [--app myapp] [--domain preview.example.com] [--tld <tld>]" unless variant
549
+ tld = opts[:tld] || ENV["ASK_LOCAL_TLD"]&.split(",")&.first || "localhost"
281
550
 
282
- app = opts[:app] || Resolver.resolve(Dir.pwd, use_branch: false).app
551
+ app = opts[:app] || Resolver.resolve(Dir.pwd).app
283
552
  domain = opts[:domain] || ENV["ASK_LOCAL_KAMAL_DOMAIN"] || "preview.example.com"
284
553
  slug = Sanitize.hostname_label(variant)
285
554
  puts "# Paste into deploy.yml proxy section for a preview of variant #{slug}:"
@@ -287,6 +556,7 @@ module Ask
287
556
  puts " ssl: true"
288
557
  puts " hosts:"
289
558
  puts " - #{app}-#{slug}.#{domain}"
559
+ puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
290
560
  end
291
561
  end
292
562
  end
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].freeze
14
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service kamal stop restart log status open setup start init].freeze
15
15
 
16
16
  def self.run(argv)
17
17
  new.run(argv)
@@ -44,6 +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 "setup" then SystemCommand.setup(ctx, args)
48
+ when "init" then SystemCommand.init(ctx, args)
49
+ when "start" then SystemCommand.start(ctx, args)
47
50
  when "kamal" then SystemCommand.kamal(ctx, args)
48
51
  when "stop"
49
52
  exit RoutesCommand.stop(ctx, args)
@@ -64,7 +67,9 @@ module Ask
64
67
  ask-local - Stable named .localhost URLs for Ruby development.
65
68
 
66
69
  Usage:
67
- ask-local Infer name, boot app -> https://<app>.localhost
70
+ ask-local start [name] [cmd...] One-setup-and-go: setup if needed, then boot -> https://<app>.localhost
71
+ ask-local setup One-shot workstation setup without booting (run once)
72
+ ask-local Bare form of `start` -> https://<app>.localhost
68
73
  ask-local run [cmd] Same, with explicit command
69
74
  ask-local <name> <cmd> Run with explicit name
70
75
  ask-local get <name> Print URL for a service
@@ -95,7 +100,6 @@ module Ask
95
100
  def inject_port_flags(command, port)
96
101
  BootCommand.inject_port_flags(command, port)
97
102
  end
98
-
99
103
  def procfile_command(process = nil)
100
104
  BootCommand.procfile_command(process)
101
105
  end