ask-local 0.1.1 → 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.
@@ -2,124 +2,92 @@
2
2
 
3
3
  module Ask
4
4
  module Local
5
- # Resolve the effective {app, service, variant, tlds} for a directory.
6
- #
7
- # Precedence per axis: CLI flag > ENV > ask-local.json > inference.
8
- # Variant adds linked-worktree branch and opt-in current branch.
5
+ # Resolve hostnames from config/local.yml the ONLY source of truth.
6
+ # No inference, no Procfile fallback, no ENV for naming. If the file
7
+ # is missing, resolve raises ConfigError telling the user to run
8
+ # `ask-local init`.
9
9
  module Resolver
10
10
  module_function
11
11
 
12
- Result = Struct.new(:app, :service, :variant, :tlds, :sources, keyword_init: true)
12
+ Result = Struct.new(:app, :tld, :host, :processes, :secrets,
13
+ :sources, :variant, keyword_init: true)
13
14
 
14
- def resolve(dir = Dir.pwd, name: nil, service: nil, variant: nil,
15
- tlds: nil, use_branch: false)
16
- config, config_dir = find_config(dir)
17
- app_cfg = config ? config.app_config(dir) : {}
18
- config_source = config_dir ? "ask-local.json (#{relative_label(config_dir, dir)})" : "ask-local.json"
15
+ def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil)
16
+ config = Config.load(dir, variant: variant)
17
+ unless config
18
+ raise ConfigError, Config.missing_message(dir)
19
+ end
19
20
 
20
- app, app_source = first_present(
21
- [name, "flag"],
22
- [ENV["ASK_LOCAL_NAME"], "ASK_LOCAL_NAME"],
23
- [app_cfg["name"], config_source],
24
- [Inference.infer(dir), :infer]
25
- )
26
- app, app_source = app_source == :infer ? app : [app, app_source]
21
+ service = config.service
22
+ proxy = config.proxy_config
23
+ processes = config.processes
27
24
 
28
- service, service_source = first_present(
29
- [service, "flag"],
30
- [ENV["ASK_LOCAL_SERVICE"], "ASK_LOCAL_SERVICE"],
31
- [app_cfg["service"], config_source]
32
- )
25
+ variant_name = (variant || ENV["ASK_LOCAL_VARIANT"])&.strip
26
+ variant_name = nil if variant_name&.empty?
33
27
 
34
- variant_value, variant_source = if !variant.nil? || ENV["ASK_LOCAL_VARIANT"] || app_cfg["variant"]
35
- first_present(
36
- [variant, "flag"],
37
- [ENV["ASK_LOCAL_VARIANT"], "ASK_LOCAL_VARIANT"],
38
- [app_cfg["variant"], config_source]
39
- )
28
+ # host or tld: config proxy.host wins over proxy.tld; flags override.
29
+ if host || proxy["host"]
30
+ effective_host = (host || proxy["host"]).to_s.strip.downcase
31
+ effective_tld = nil
40
32
  else
41
- Variant.resolve(dir, use_branch: use_branch) || [nil, nil]
33
+ effective_tld = (tld || proxy["tld"] || Hostname::DEFAULT_TLD).to_s.strip.downcase
34
+ effective_tld = effective_tld.gsub(/\A\./, "")
35
+ unless Sanitize.valid_tld?(effective_tld)
36
+ raise ConfigError, "Invalid tld #{effective_tld.inspect} in #{config.path} proxy.tld"
37
+ end
38
+ effective_host = nil
42
39
  end
43
40
 
44
- tld_list = parse_tlds(tlds) || parse_tlds(ENV["ASK_LOCAL_TLD"]) ||
45
- Array(app_cfg["tlds"]) || [Hostname::DEFAULT_TLD]
46
- tld_list = [Hostname::DEFAULT_TLD] if tld_list.empty?
47
- tld_list.each do |tld|
48
- raise ConfigError, "Invalid TLD #{tld.inspect}" unless Sanitize.valid_tld?(tld.downcase)
49
- end
50
- tld_list = tld_list.map(&:downcase).uniq
41
+ sources = {
42
+ app: config.path.to_s,
43
+ tld: proxy["tld"] ? "#{config.path} proxy.tld" : "default (localhost)",
44
+ host: proxy["host"] ? "#{config.path} proxy.host" : nil,
45
+ variant: variant_name ? "config/local.#{variant_name}.yml" : nil
46
+ }
51
47
 
52
48
  Result.new(
53
- app: Sanitize.hostname_label(app),
54
- service: service && Sanitize.hostname_label(service),
55
- variant: variant_value && Sanitize.hostname_label(variant_value),
56
- tlds: tld_list,
57
- sources: { app: app_source, service: service_source, variant: variant_source }
49
+ app: Sanitize.hostname_label(service),
50
+ tld: effective_tld,
51
+ host: effective_host,
52
+ processes: processes,
53
+ secrets: config.secrets,
54
+ sources: sources,
55
+ variant: variant_name
58
56
  )
59
57
  end
60
58
 
61
- def hostnames(result)
62
- Hostname.build(app: result.app, service: result.service,
63
- variant: result.variant, tlds: result.tlds)
64
- end
59
+ # Hostname for one process: primary (first proxy:true) is bare;
60
+ # others are proc.app.tld; proxy:false have none.
61
+ def hostname_for(result, proc_name)
62
+ entry = result.processes[proc_name]
63
+ return nil unless entry
64
+ return nil if entry["proxy"] == false
65
65
 
66
- # Walk up for the nearest ask-local.json; a root with an "apps" map
67
- # covers subdirectories (monorepo). A nearer config without a match
68
- # does not block a farther one with an apps entry.
69
- def find_config(dir)
70
- current = File.expand_path(dir)
71
- fallback = nil
72
- loop do
73
- begin
74
- loaded = Config.load(current)
75
- if loaded
76
- if loaded.data["apps"].is_a?(Hash)
77
- return [loaded, current]
78
- else
79
- fallback ||= [loaded, current]
80
- end
81
- end
82
- rescue ConfigError
83
- nil
84
- end
85
- parent = File.dirname(current)
86
- break if parent == current
66
+ base = result.host ? result.host : "#{result.app}.#{result.tld}"
67
+ proc_name.to_s == primary_proc(result) ? base : "#{proc_name}.#{base}"
68
+ end
87
69
 
88
- current = parent
89
- end
90
- fallback || [nil, nil]
70
+ def primary_proc(result)
71
+ result.processes.find { |_, v| v["proxy"] != false }&.first
91
72
  end
92
73
 
93
- def relative_label(config_dir, dir)
94
- return "." if File.expand_path(config_dir) == File.expand_path(dir)
74
+ # All HTTP hostnames (for route registration / doctor).
75
+ def hostnames(result)
76
+ result.processes.filter_map { |name, entry|
77
+ next if entry["proxy"] == false
95
78
 
96
- require "pathname"
97
- Pathname.new(File.expand_path(dir))
98
- .relative_path_from(Pathname.new(File.expand_path(config_dir))).to_s
99
- rescue ArgumentError
100
- "."
79
+ hostname_for(result, name)
80
+ }
101
81
  end
102
82
 
103
- # NOTE: no `private` keyword here — it would cancel
104
- # `module_function` mode (see Variant for details).
105
- def first_present(*pairs)
106
- pairs.each do |value, source|
107
- if source == :infer
108
- inferred, from = value
109
- return [inferred, from] unless inferred.nil? || inferred.to_s.empty?
110
- elsif !value.nil? && !value.to_s.strip.empty?
111
- return [value.to_s.strip, source]
112
- end
113
- end
114
- [nil, nil]
83
+ def url_for(result, proc_name, port:, tls:)
84
+ host = hostname_for(result, proc_name)
85
+ host && Hostname.url(host, port: port, tls: tls)
115
86
  end
116
87
 
117
- def parse_tlds(value)
118
- return nil if value.nil?
119
- return value.map(&:to_s) if value.is_a?(Array)
120
-
121
- parts = value.to_s.split(",").map(&:strip).reject(&:empty?)
122
- parts.empty? ? nil : parts
88
+ # Effective URL list for display (status): primary bare, others prefixed.
89
+ def urls(result, port:, tls:)
90
+ hostnames(result).map { |h| Hostname.url(h, port: port, tls: tls) }
123
91
  end
124
92
  end
125
93
  end
@@ -91,9 +91,10 @@ module Ask
91
91
  end
92
92
 
93
93
  # Run an arbitrary command with PORT + ASK_LOCAL_URL. Returns App.
94
- def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false)
94
+ def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
95
+ rails_dev_host: nil)
95
96
  port ||= Ports.find_free
96
- env = child_env(dir, url: url, port: port)
97
+ env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host)
97
98
  pid = with_clean_env { spawn(env, *command, chdir: dir) }
98
99
  Process.detach(pid)
99
100
  target = "127.0.0.1:#{port}"
@@ -103,12 +104,16 @@ module Ask
103
104
  target: target, kind: "tcp", command: command)
104
105
  end
105
106
 
106
- def child_env(dir, url:, port:)
107
+ def child_env(dir, url:, port:, rails_dev_host: nil)
107
108
  env = { "ASK_LOCAL_URL" => url }
108
109
  env["PORT"] = port.to_s if port
109
110
  env["HOST"] = "127.0.0.1"
110
111
  ca = File.join(Certs.state_dir, "ca.pem")
111
112
  env["NODE_EXTRA_CA_CERTS"] = ca if File.file?(ca)
113
+ # Rails blocks unknown Host headers in development. Allow the
114
+ # proxied hostname so Rails apps boot behind ask-local with zero
115
+ # config — this replaces the ask-local-rails hosts patch.
116
+ env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
112
117
  env
113
118
  end
114
119
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Local
5
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,89 +1,105 @@
1
1
  ---
2
2
  name: ask-local
3
- description: Run Ruby apps through ask-local for stable named .localhost URLs (e.g. https://myapp.localhost instead of http://localhost:3000). Use when booting dev servers (Rails, Rack, Roda, Sinatra, Jekyll, Procfile apps), wiring frontend to API, configuring OAuth callbacks or webhooks, debugging port conflicts, or working in git worktrees.
3
+ description: Run Ruby apps through ask-local for stable named .localhost URLs (e.g. https://myapp.localhost instead of http://localhost:3000). Use when booting dev servers (Rails, Rack, Roda, Sinatra, Jekyll), wiring frontend to API, configuring OAuth callbacks or webhooks, debugging port conflicts, or working in git worktrees.
4
4
  ---
5
5
 
6
6
  # Local Dev with ask-local
7
7
 
8
8
  Never invent ports. Never parse them from logs. Every app has a stable URL.
9
9
 
10
- First time on a machine, run `ask-local start` in any app dir — it does
11
- the one-shot CA trust, port 443, and hosts sync if anything is missing,
12
- then boots. Prefer `ask-local setup` for
13
- workstation setup without booting. Every later `ask-local` or
14
- hosts, verify). If any command fails with a privileged-port error, do
15
- not work around it with `-p` — run `ask-local setup` instead. A `:port`
16
- suffix in a URL means someone explicitly opted into it.
17
-
18
- ## Booting apps
19
-
20
- ```bash
21
- ask-local # infer name, boot -> https://<app>.localhost
22
- ask-local --service api # -> https://api.myapp.localhost
23
- ask-local run -- bin/dev # Procfile apps, PORT injected
24
- ask-local run --proc worker # boot a specific Procfile process
10
+ ## One file, one command
11
+
12
+ Every app declares `config/local.yml` — the single source of truth:
13
+
14
+ ```yaml
15
+ service: myapp
16
+ proxy:
17
+ tld: localhost
18
+ processes:
19
+ web:
20
+ cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
21
+ proxy: true # gets https://myapp.localhost
22
+ worker:
23
+ cmd: bundle exec sidekiq
24
+ proxy: false # background, supervised, no URL
25
+ env:
26
+ clear:
27
+ RAILS_ENV: development
25
28
  ```
26
29
 
27
- Managed apps are supervised by the proxy daemon: they idle-stop after 15
28
- minutes (`ASK_LOCAL_IDLE_TIMEOUT`), stop when `tmp/restart.txt` is
29
- touched, and boot again on the next request.
30
-
31
- ## Lifecycle
30
+ If the file is missing, ask-local prints `Run ask-local init`. Generate it
31
+ with `ask-local init` (migrates an existing Procfile). Rails apps need
32
+ no extra gem the proxied hostname is allowed automatically via
33
+ `RAILS_DEVELOPMENT_HOSTS`.
32
34
 
33
35
  ```bash
34
- ask-local stop # stop this app's backend + routes
35
- ask-local restart # touch tmp/restart.txt (supervised reboot)
36
- ask-local log [n] # tail this app's backend log
36
+ ask-local start # setup if needed, then boot every process
37
+ ask-local # same as start
38
+ ask-local stop # stop this app's backend + routes
39
+ ask-local status # show service, processes, and URLs
40
+ ask-local log [-f] # tail the web process log
37
41
  ```
38
42
 
39
- The runner injects `ASK_LOCAL_URL`, `PORT`, and `HOST=127.0.0.1` into the
40
- child. In Rails, read it via `Ask::Local::Rails.url` never hardcode
41
- `localhost:3000`.
43
+ `$PORT` and `ASK_LOCAL_URL` are injected per process; HTTP processes get
44
+ stable URLs, background ones are supervised without routes. A process
45
+ that exits cleans up the whole tree.
42
46
 
43
47
  ## Cross-service wiring
44
48
 
45
49
  ```bash
46
50
  ask-local get backend # -> https://backend.localhost
51
+ ask-local get backend --variant demo
47
52
  ```
48
53
 
49
54
  Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
50
55
  targets. Do not guess ports.
51
56
 
52
- ## Variants (worktrees, branches, demos)
57
+ ## Variants are files
53
58
 
54
- Hostnames compose as `{variant}.{service}.{app}.{tld}`. Linked worktrees
55
- get a branch prefix automatically (`fix-ui.myapp.localhost`); main keeps
56
- the bare name. Override with `--variant` or `ASK_LOCAL_VARIANT`.
59
+ A variant is a file overlay, Kamal-style: `config/local.<variant>.yml`
60
+ deep-merges over `config/local.yml`. Select it with `ASK_LOCAL_VARIANT`
61
+ or `--variant`. Worktrees get a branch prefix automatically.
62
+
63
+ ```bash
64
+ ASK_LOCAL_VARIANT=fix-ui ask-local # boots with config/local.fix-ui.yml merged
65
+ ```
66
+
67
+ ## First time on a machine
68
+
69
+ Run `ask-local start` — it does the one-shot CA trust, port 443, and
70
+ hosts sync if anything is missing, then boots. Prefer `ask-local setup`
71
+ for workstation setup without booting. If any command fails with a
72
+ privileged-port error, do not work around it with `-p` — run
73
+ `ask-local setup` instead. A `:port` suffix in a URL means someone
74
+ explicitly opted into it.
57
75
 
58
76
  ## OAuth and webhooks
59
77
 
60
- Build callback URLs from `ASK_LOCAL_URL`:
78
+ Build callback URLs from `ASK_LOCAL_URL` (injected into every HTTP
79
+ process):
61
80
 
62
81
  ```ruby
63
82
  callback = "#{Ask::Local::Rails.url}/auth/google/callback"
64
83
  ```
65
84
 
66
85
  Strict providers (Google, Apple) reject `.localhost`. Serve the app on a
67
- domain you own instead — no code change:
86
+ domain you own instead — no code change, just config:
68
87
 
69
- ```bash
70
- ask-local --tld local.example.com # -> https://myapp.local.example.com
88
+ ```yaml
89
+ proxy:
90
+ host: myapp.local.example.com # instead of tld: localhost
71
91
  ```
72
92
 
73
93
  ## Troubleshooting
74
94
 
75
95
  ```bash
76
96
  ask-local doctor # read-only: proxy, routes, DNS, CA trust
77
- ask-local list # active routes
97
+ ask-local list --json # routes as stable JSON
78
98
  ask-local prune # clear stale routes from crashed sessions
79
99
  ```
80
100
 
81
- Prefer `list --json`, `status --json`, and `doctor --json` when parsing
82
- output programmatically — stable keys, no prose scraping.
83
-
84
101
  If a hostname does not resolve: `ask-local hosts sync`. If the browser
85
- warns about TLS: `ask-local trust`. Never run dev servers on bare ports
86
- alongside ask-local — they bypass routing and reintroduce conflicts.
102
+ warns about TLS: `ask-local trust`.
87
103
 
88
104
  ## When NOT to use ask-local
89
105
 
data/lib/ask-local.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "ask/local/resolver"
22
22
  require_relative "ask/local/trust"
23
23
  require_relative "ask/local/doctor"
24
24
  require_relative "ask/local/cli/context"
25
+ require_relative "ask/local/procfile"
25
26
  require_relative "ask/local/cli/boot"
26
27
  require_relative "ask/local/cli/routes"
27
28
  require_relative "ask/local/cli/system"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-local
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -99,6 +99,7 @@ files:
99
99
  - lib/ask/local/log.rb
100
100
  - lib/ask/local/ownership.rb
101
101
  - lib/ask/local/ports.rb
102
+ - lib/ask/local/procfile.rb
102
103
  - lib/ask/local/proxy.rb
103
104
  - lib/ask/local/proxy_control.rb
104
105
  - lib/ask/local/resolver.rb