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.
@@ -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.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,81 +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
- ## Booting apps
11
-
12
- ```bash
13
- ask-local # infer name, boot -> https://<app>.localhost
14
- ask-local --service api # -> https://api.myapp.localhost
15
- ask-local run -- bin/dev # Procfile apps, PORT injected
16
- 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
17
28
  ```
18
29
 
19
- Managed apps are supervised by the proxy daemon: they idle-stop after 15
20
- minutes (`ASK_LOCAL_IDLE_TIMEOUT`), stop when `tmp/restart.txt` is
21
- touched, and boot again on the next request.
22
-
23
- ## 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`.
24
34
 
25
35
  ```bash
26
- ask-local stop # stop this app's backend + routes
27
- ask-local restart # touch tmp/restart.txt (supervised reboot)
28
- 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
29
41
  ```
30
42
 
31
- The runner injects `ASK_LOCAL_URL`, `PORT`, and `HOST=127.0.0.1` into the
32
- child. In Rails, read it via `Ask::Local::Rails.url` never hardcode
33
- `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.
34
46
 
35
47
  ## Cross-service wiring
36
48
 
37
49
  ```bash
38
50
  ask-local get backend # -> https://backend.localhost
51
+ ask-local get backend --variant demo
39
52
  ```
40
53
 
41
54
  Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
42
55
  targets. Do not guess ports.
43
56
 
44
- ## Variants (worktrees, branches, demos)
57
+ ## Variants are files
58
+
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
45
68
 
46
- Hostnames compose as `{variant}.{service}.{app}.{tld}`. Linked worktrees
47
- get a branch prefix automatically (`fix-ui.myapp.localhost`); main keeps
48
- the bare name. Override with `--variant` or `ASK_LOCAL_VARIANT`.
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.
49
75
 
50
76
  ## OAuth and webhooks
51
77
 
52
- Build callback URLs from `ASK_LOCAL_URL`:
78
+ Build callback URLs from `ASK_LOCAL_URL` (injected into every HTTP
79
+ process):
53
80
 
54
81
  ```ruby
55
82
  callback = "#{Ask::Local::Rails.url}/auth/google/callback"
56
83
  ```
57
84
 
58
85
  Strict providers (Google, Apple) reject `.localhost`. Serve the app on a
59
- domain you own instead — no code change:
86
+ domain you own instead — no code change, just config:
60
87
 
61
- ```bash
62
- 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
63
91
  ```
64
92
 
65
93
  ## Troubleshooting
66
94
 
67
95
  ```bash
68
96
  ask-local doctor # read-only: proxy, routes, DNS, CA trust
69
- ask-local list # active routes
97
+ ask-local list --json # routes as stable JSON
70
98
  ask-local prune # clear stale routes from crashed sessions
71
99
  ```
72
100
 
73
- Prefer `list --json`, `status --json`, and `doctor --json` when parsing
74
- output programmatically — stable keys, no prose scraping.
75
-
76
101
  If a hostname does not resolve: `ask-local hosts sync`. If the browser
77
- warns about TLS: `ask-local trust`. Never run dev servers on bare ports
78
- alongside ask-local — they bypass routing and reintroduce conflicts.
102
+ warns about TLS: `ask-local trust`.
79
103
 
80
104
  ## When NOT to use ask-local
81
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.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -73,6 +73,7 @@ email:
73
73
  - kaka@myrrlabs.com
74
74
  executables:
75
75
  - ask-local
76
+ - askl
76
77
  extensions: []
77
78
  extra_rdoc_files: []
78
79
  files:
@@ -80,6 +81,7 @@ files:
80
81
  - LICENSE
81
82
  - README.md
82
83
  - bin/ask-local
84
+ - bin/askl
83
85
  - lib/ask-local.rb
84
86
  - lib/ask/local/certs.rb
85
87
  - lib/ask/local/cli.rb
@@ -97,6 +99,7 @@ files:
97
99
  - lib/ask/local/log.rb
98
100
  - lib/ask/local/ownership.rb
99
101
  - lib/ask/local/ports.rb
102
+ - lib/ask/local/procfile.rb
100
103
  - lib/ask/local/proxy.rb
101
104
  - lib/ask/local/proxy_control.rb
102
105
  - lib/ask/local/resolver.rb