yamine 0.11.1 → 0.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e48f66da1b02dd9ea589c1a30225d6b1f9dc46cd1abd92ffad90c27422b6d4ca
4
- data.tar.gz: '0209cb0ce1c976c5f6a934ba0a74283f3b8064e2fc9aed493208c74077dd5a62'
3
+ metadata.gz: 6f7c7127a262069469056cf55f94b5785de8e1890bc0814d84453159e1a01096
4
+ data.tar.gz: 0001bfbad73772c3caa7e615384a0c70dfa82732987174f55cbfbffac703dd15
5
5
  SHA512:
6
- metadata.gz: 2626fc9696988148d9637ffd1ce14adc7d7d656b03d61854296ff3b6c818fd82ff840fce5904395ac8d2b54c027c9f7e63f47f0f077609f896542af06c282d28
7
- data.tar.gz: 110d265a913ee76a3ff40328c40cfdc0475e195c3e1518a490294dd2bb7bca0536edbaeedba7bc37815625aa3975880efdb1b1a66252b3c2a9ed16d6a2b5abd1
6
+ metadata.gz: 702d9b80482ac8f541f16ab5946b8916b0c23750774e398eabe85cd940a5d39306bdc188e010065e17ecf91081feb79b94d021a24c71186978ba8d1e4ede7dba
7
+ data.tar.gz: f0f12d6cc5b32bcc46804ef3009a72ab4fac500f792b6d6ea39f05324857031b4cb0f06fe131ef291a61f2faf1d1fd87eb85520d6698b63a6a5b6dde394e637d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,97 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0] — 2026-09-11
4
+
5
+ ### Changed
6
+
7
+ - **The subdomain fallback is now opt-in: `proxy.subdomains`.** A route
8
+ used to answer its own subdomains unconditionally, so *any* label under
9
+ a live app silently resolved to that app. The expensive case is a
10
+ worktree: once `<branch>.myapp.localhost` became the worktree URL, the
11
+ same hostname with that worktree **stopped** answered as the main
12
+ checkout — HTTP 200, a real app, the wrong code, and nothing to
13
+ distinguish it from the right answer. You had to notice the branch
14
+ was running to know you were looking at someone else's build.
15
+
16
+ Unregistered hostnames now 404 instead, and the page names the parent
17
+ app and its directory: "`myapp.localhost` is running in `/code/myapp`.
18
+ If `my-branch.myapp.localhost` is a worktree or branch, start it there
19
+ (`yamine start`)." The failure states its own fix.
20
+
21
+ Opting in is one line per app that genuinely wants it:
22
+
23
+ ```yaml
24
+ proxy:
25
+ subdomains: true # this app answers *.myapp.localhost
26
+ ```
27
+
28
+ `yamine alias <name> <port> --wildcard` is the same opt-in for a
29
+ single ad-hoc route. `yamine status` now reports which mode an app is
30
+ in, since it changes what an unregistered label resolves to.
31
+
32
+ Nothing depended on the ambient behavior: no sibling app config used
33
+ subdomains, and the only artifact was one test, now the opt-in test.
34
+ Route files written before this need no migration — the flag is absent
35
+ unless set, and absent means exact hostname only.
36
+
37
+ ### Fixed
38
+
39
+ - **`proxy: false` on a process named `web` no longer fails validation.**
40
+ The EXAMPLE can only name one class per key (`true` → TrueClass), so a
41
+ literal `false` was compared as falseclass and rejected with the
42
+ self-contradictory "expected a boolean, got falseclass". Booleans are
43
+ now checked as booleans.
44
+
45
+ ## [0.12.0] — 2026-09-11
46
+
47
+ ### Fixed
48
+
49
+ - **Worktrees get their own URL, as the README always claimed.**
50
+ `Variant.resolve`, the worktree detector, `Hostname.compose`'s variant
51
+ axis and their unit tests were all present, correct, and never
52
+ assembled: `Resolver.resolve` never called `Variant.resolve`, and
53
+ `hostname_for` built `{service}.{app}.{tld}` by hand, bypassing the
54
+ composer. So every worktree of a project answered on the *same*
55
+ hostname as its main checkout, and the ownership gate refused to boot
56
+ the second one — while telling the user to "work in your own worktree
57
+ (each branch gets its own URL)", advice that could not work.
58
+
59
+ A linked worktree is now reachable at `<branch>.<app>.<tld>`
60
+ (`work/next` → `work-next.myapp.localhost`) and boots beside its main
61
+ checkout, which keeps the bare name. Cross-checked by serving two
62
+ branches at once: distinct ports, distinct asset digests, no shared
63
+ route.
64
+
65
+ Found while trying to run a second checkout of a Rails app for UI work,
66
+ which is exactly the case the feature existed for.
67
+
68
+ ### Changed
69
+
70
+ - **The variant axis is split in two: `overlay` and `variant`.** They
71
+ were one setting, which meant that as soon as a worktree supplied its
72
+ own variant, `config/local.<branch>.yml` became an implied file lookup
73
+ — so a branch named like a file on disk would silently change the
74
+ config. Now only an explicit `--variant` / `YAMINE_VARIANT` sets the
75
+ overlay to merge; a worktree branch supplies the hostname label and
76
+ nothing else. `status` prints both lines, so the two can no longer be
77
+ mistaken for each other.
78
+
79
+ - **A branch label is the whole branch, not its last path segment.**
80
+ `feature/login` and `bugfix/login` are different worktrees and used to
81
+ collapse to the same `login` hostname. Overlong branches still resolve
82
+ through the existing hash-suffix truncation.
83
+
84
+ - A worktree on a detached HEAD falls back to its directory name (the
85
+ same identity the per-worktree database uses) instead of returning no
86
+ prefix and quietly colliding with the main checkout.
87
+
88
+ - `yamine status` now honors `--variant` / `--tld`, and accepts
89
+ `--branch`, which the README documented but `parse_flags` rejected as
90
+ an unknown flag.
91
+
92
+ - `proxy.host` is never rewritten by a variant: an explicitly written
93
+ hostname is left exactly as written.
94
+
3
95
  ## [0.11.1] — 2026-09-11
4
96
 
5
97
  ### Fixed
@@ -34,10 +126,10 @@
34
126
 
35
127
  - **Ruby, git, and curl now trust the local CA — so an app can call
36
128
  another app at `https://<name>.localhost` with no configuration.** The
37
- gap surfaced when anywaye (a Rails app) tried to reach anymark at
38
- `https://anymark-directory.localhost`: `certificate verify failed`. The
39
- CA was trusted everywhere a human looks (Safari, Chrome, curl) and
40
- nowhere a child process looks — only Node was covered, via
129
+ gap surfaced when anywaye (a Rails app) tried to reach a second
130
+ checkout of anymark at its `.localhost` URL: `certificate verify
131
+ failed`. The CA was trusted everywhere a human looks (Safari, Chrome,
132
+ curl) and nowhere a child process looks — only Node was covered, via
41
133
  `NODE_EXTRA_CA_CERTS`.
42
134
 
43
135
  The naive fix is wrong in a way that shows up much later. `SSL_CERT_FILE`
data/README.md CHANGED
@@ -91,6 +91,7 @@ of truth for service name, proxy TLD/host, processes, and env:
91
91
  service: myapp
92
92
  proxy:
93
93
  tld: localhost
94
+ subdomains: false # opt in to answering *.myapp.localhost
94
95
  processes:
95
96
  web:
96
97
  cmd: bundle exec puma -b tcp://127.0.0.1:$PORT config.ru
@@ -117,7 +118,8 @@ whole tree, and cleans up when one exits.
117
118
 
118
119
  Variants are file overlays: `config/local.<variant>.yml` deep-merges on
119
120
  top of `config/local.yml`, selected by `YAMINE_VARIANT` (Kamal's
120
- destination pattern).
121
+ destination pattern). Naming a variant also prefixes the hostname — see
122
+ below for how that differs from a worktree's automatic prefix.
121
123
 
122
124
  `.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
123
125
  no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
@@ -135,10 +137,24 @@ no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
135
137
  | variant | `fix-ui.myapp` | `--variant`, `YAMINE_VARIANT`, linked worktree branch |
136
138
  | tld | `myapp.preview.example.com` | `--tld` (default `localhost`) |
137
139
 
140
+ `proxy.host` is the one exception: an explicitly written full hostname
141
+ bypasses composition entirely, variant included.
142
+
138
143
  Linked git worktrees get a branch prefix automatically
139
- (`fix-ui.myapp.localhost`); the main checkout keeps the bare name.
140
- Pass `--branch` (or `YAMINE_BRANCH=1`) to prefix by current branch
141
- outside worktrees. `main`/`master`/detached HEAD never prefix.
144
+ (`ui-onboarding.myapp.localhost`); the main checkout keeps the bare name,
145
+ so a worktree and its main checkout run side by side. The label is the
146
+ whole branch (`feature/login` → `feature-login.myapp.localhost`), so two
147
+ branches that share a last segment never share a hostname. A detached
148
+ HEAD has no branch to name it and falls back to the worktree's directory
149
+ — the same identity its per-worktree database uses. `main`/`master`
150
+ never prefix. Pass `--branch` (or `YAMINE_BRANCH=1`) to prefix by the
151
+ current branch outside worktrees.
152
+
153
+ A variant is a hostname label, not a config file. Only an explicit
154
+ `--variant` / `YAMINE_VARIANT` goes looking for
155
+ `config/local.<name>.yml` to merge; a worktree branch never does, so a
156
+ branch named like a file on disk cannot change your config by accident.
157
+ `yamine status` prints both lines so the two are never confused.
142
158
 
143
159
  ```bash
144
160
  yamine # -> https://myapp.localhost
@@ -147,6 +163,26 @@ yamine --variant demo # -> https://demo.myapp.localhos
147
163
  yamine --tld preview.example.com # your own domain (OAuth parity)
148
164
  ```
149
165
 
166
+ ## Subdomains are opt-in
167
+
168
+ A route answers its exact hostname. `*.myapp.localhost` reaches
169
+ `myapp.localhost` only if that app asked for it:
170
+
171
+ ```yaml
172
+ proxy:
173
+ subdomains: true # this app answers its own subdomains
174
+ ```
175
+
176
+ ```bash
177
+ yamine alias tenant1 4001 --wildcard # one route, its subdomains
178
+ ```
179
+
180
+ Off is the useful default. An unregistered label under a live app is far
181
+ more likely to be a worktree whose stack is stopped than a tenant, and
182
+ handing that label to the parent app means HTTP 200 with the wrong code.
183
+ Instead the request 404s and names the parent app, its directory, and how
184
+ to start it. `yamine status` reports which mode an app is in.
185
+
150
186
  ## Commands
151
187
 
152
188
  ```bash
@@ -78,16 +78,37 @@ yamine get backend --variant demo
78
78
  Use `get` output for frontend-to-API URLs, Cable URLs, and webhook
79
79
  targets. Do not guess ports.
80
80
 
81
- ## Variants are files
81
+ ## Variants are files; worktrees are not
82
82
 
83
83
  A variant is a file overlay, Kamal-style: `config/local.<variant>.yml`
84
- deep-merges over `config/local.yml`. Select it with `YAMINE_VARIANT`
85
- or `--variant`. Worktrees get a branch prefix automatically.
84
+ deep-merges over `config/local.yml`, selected with `YAMINE_VARIANT` or
85
+ `--variant`. Naming one also prefixes the hostname.
86
+
87
+ A git worktree needs none of that: it gets a branch prefix automatically
88
+ (`ui-onboarding.myapp.localhost`) and boots alongside its main checkout,
89
+ which keeps the bare name. The whole branch is the label, so
90
+ `feature/login` and `bugfix/login` stay distinct. A detached worktree
91
+ falls back to its directory name.
86
92
 
87
93
  ```bash
88
94
  YAMINE_VARIANT=fix-ui yamine # boots with config/local.fix-ui.yml merged
89
95
  ```
90
96
 
97
+ Only an explicit variant ever looks for an overlay file — a branch name
98
+ that happens to match one on disk is ignored. `yamine status` shows
99
+ `variant:` and `overlay:` separately.
100
+
101
+ ## Subdomains are opt-in
102
+
103
+ A route answers its exact hostname. `*.myapp.localhost` reaches the app
104
+ only if it asked (`proxy.subdomains: true` in `config/local.yml`, or
105
+ `yamine alias <name> <port> --wildcard` for one route).
106
+
107
+ So a worktree whose stack is not running gets a 404 that names the parent
108
+ app, its directory, and `yamine start` — not the parent app's code. If
109
+ you hit a `.localhost` URL that loads but looks wrong, check you started
110
+ the worktree you think you did; `yamine status` in it prints the URL.
111
+
91
112
  ## First time on a machine
92
113
 
93
114
  Run `yamine start` — it does the one-shot CA trust, port 443, and
@@ -16,8 +16,9 @@ module Yamine
16
16
  # boots every process, supervises the tree, cleans up on exit.
17
17
  def run_inferred(ctx, args)
18
18
  variant = ENV["YAMINE_VARIANT"]
19
- opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json])
20
- resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
19
+ opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json branch])
20
+ resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld],
21
+ use_branch: opts[:branch])
21
22
  # Ownership gate before any side effects: no proxy spawn, no
22
23
  # port allocation when we'd refuse anyway.
23
24
  check_worktree_ownership!(ctx, resolved, force: opts[:force])
@@ -242,6 +243,7 @@ module Yamine
242
243
  url: item[:url], dir: Dir.pwd, command: item[:command],
243
244
  port: item[:port], force: opts[:force],
244
245
  rails_dev_host: item[:hostname], database_url: db_url,
246
+ subdomains: resolved.subdomains,
245
247
  extra_env: build_env(resolved, item[:entry], proc_name: item[:name]))
246
248
  routes_registered << { hostnames: item[:hostnames], app: app }
247
249
 
@@ -280,7 +282,8 @@ module Yamine
280
282
  if failed.empty?
281
283
  apps.each do |name, slot|
282
284
  runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
283
- spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name })
285
+ spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name },
286
+ subdomains: resolved.subdomains)
284
287
  routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
285
288
  say opts, " -> #{slot[:item][:url]}"
286
289
  end
@@ -653,10 +656,10 @@ module Yamine
653
656
  end
654
657
  end
655
658
 
656
- def resolve!(ctx, variant: nil, tld: nil)
659
+ def resolve!(ctx, variant: nil, tld: nil, use_branch: false)
657
660
  # The resolver calls Config.load, which raises ConfigError if
658
661
  # config/local.yml is missing — exactly what we want.
659
- Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
662
+ Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld, use_branch: use_branch)
660
663
  end
661
664
 
662
665
  # json: keeps stdout free for the machine-readable stream — this runs
@@ -147,14 +147,20 @@ module Yamine
147
147
  puts "Removed alias #{hostname}."
148
148
  return
149
149
  end
150
- name, port_or_url = args
151
- raise Error, "Usage: yamine alias <name> <port|url>" unless name && port_or_url
150
+ name, port_or_url = args.reject { |a| a.start_with?("--") }
151
+ raise Error, "Usage: yamine alias <name> <port|url> [--wildcard]" unless name && port_or_url
152
152
 
153
153
  hostname = alias_hostname(name)
154
154
  target = port_or_url.match?(/\A\d+\z/) ? "127.0.0.1:#{port_or_url}" : port_or_url
155
155
  force = args.include?("--force")
156
- ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force)
157
- puts "#{hostname} -> #{target}"
156
+ # --wildcard is the per-route escape hatch for the tenant case:
157
+ # this alias answers its own subdomains. Config `proxy.subdomains`
158
+ # is the same opt-in for a whole app at boot.
159
+ wildcard = args.include?("--wildcard")
160
+ ctx.store.add_route(hostname, target, 0, kind: "tcp", force: force,
161
+ subdomains: wildcard)
162
+ suffix = wildcard ? " (and its subdomains)" : ""
163
+ puts "#{hostname} -> #{target}#{suffix}"
158
164
  end
159
165
 
160
166
  # A name containing dots is treated as a full hostname (any TLD);
@@ -175,6 +181,9 @@ module Yamine
175
181
  # agent's cleanup). --force overrides and names the previous owner.
176
182
  def stop(ctx, args, out: $stdout)
177
183
  force = args.delete("--force")
184
+ # In a worktree this resolves that worktree's own hostname (the
185
+ # branch is the variant), so `stop` here never reaches across
186
+ # into the main checkout's running app.
178
187
  resolved = Resolver.resolve(Dir.pwd)
179
188
  hostnames = Resolver.hostnames(resolved)
180
189
  mine = Agent.name
@@ -261,9 +270,14 @@ module Yamine
261
270
  # `yamine` would boot here and why. Answers "why did I get
262
271
  # this URL" without booting anything. --json emits stable keys
263
272
  # for agents instead of prose.
264
- def status(_ctx, args)
273
+ def status(ctx, args)
265
274
  json = args.delete("--json")
266
- resolved = Resolver.resolve(Dir.pwd)
275
+ # The flags exist so `status --variant x` answers the same
276
+ # question boot would; in a worktree the branch supplies the
277
+ # variant anyway, so plain `status` is already the right answer.
278
+ opts = ctx.parse_flags(args, %i[variant tld branch])
279
+ resolved = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld],
280
+ use_branch: opts[:branch])
267
281
  hostnames = Resolver.hostnames(resolved)
268
282
  urls = hostnames.map do |h|
269
283
  Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
@@ -273,6 +287,8 @@ module Yamine
273
287
  tld: resolved.tld, tld_source: resolved.sources[:tld],
274
288
  host: resolved.host, host_source: resolved.sources[:host],
275
289
  variant: resolved.variant, variant_source: resolved.sources[:variant],
290
+ overlay: resolved.overlay, overlay_source: resolved.sources[:overlay],
291
+ subdomains: resolved.subdomains,
276
292
  urls: urls,
277
293
  processes: resolved.processes.keys,
278
294
  framework: Framework.detect(Dir.pwd).to_s
@@ -288,7 +304,14 @@ module Yamine
288
304
  else
289
305
  puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
290
306
  end
291
- puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no overlay file, flag, or env"})"
307
+ puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no flag, env, or worktree branch"})"
308
+ # The overlay is a different thing from the variant and only an
309
+ # explicit --variant / YAMINE_VARIANT ever sets it, so say so
310
+ # rather than letting the two look like one setting.
311
+ puts "overlay: #{payload[:overlay] ? "#{payload[:overlay]} (merged)" : "(none)"}"
312
+ # Off by default; say when it is on, because it changes what an
313
+ # unregistered label under this app resolves to.
314
+ puts "subdomains: #{payload[:subdomains] ? "own subdomains resolve here" : "exact hostname only"}"
292
315
  puts "processes: #{payload[:processes].join(", ")}"
293
316
  puts "urls:"
294
317
  urls.each { |u| puts " #{u}" }
data/lib/yamine/config.rb CHANGED
@@ -51,7 +51,12 @@ module Yamine
51
51
  "service" => "myapp",
52
52
  "proxy" => {
53
53
  "tld" => "localhost",
54
- "host" => "myapp.local.example.com"
54
+ "host" => "myapp.local.example.com",
55
+ # Opt this app into answering its own subdomains. Off by default:
56
+ # an unregistered label under a live app is far more likely to be
57
+ # a stopped worktree than a tenant, and resolving it to the wrong
58
+ # app is worse than not resolving it.
59
+ "subdomains" => false
55
60
  },
56
61
  # db: false opts out of per-worktree databases entirely (exotic
57
62
  # setups: manual establish_connection, shared staging DB, ...).
@@ -178,6 +183,14 @@ module Yamine
178
183
  data[key]
179
184
  end
180
185
 
186
+ # Whether this app answers its own subdomains. Off unless asked: the
187
+ # ambient fallback used to hand any unregistered label to whichever
188
+ # app owned the parent name, which reads as the right app running the
189
+ # wrong code — the worktree case that motivated the opt-in.
190
+ def subdomains?
191
+ proxy_config["subdomains"] == true
192
+ end
193
+
181
194
  private
182
195
 
183
196
  def load_secrets
@@ -274,6 +287,9 @@ module Yamine
274
287
  if value["tld"] && !Sanitize.valid_tld?(value["tld"].downcase)
275
288
  raise ConfigError, "#{context}: invalid tld #{value["tld"].inspect}"
276
289
  end
290
+ if value.key?("subdomains") && ![true, false].include?(value["subdomains"])
291
+ raise ConfigError, "#{context}: subdomains must be a boolean"
292
+ end
277
293
  end
278
294
 
279
295
  def validate_db(value, context)
@@ -364,13 +380,28 @@ module Yamine
364
380
  elsif example_value.is_a?(Array) && value.is_a?(Array)
365
381
  validate_array_of!(value, example_value.first.class) unless example_value.empty?
366
382
  elsif !example_value.nil?
367
- expected = type_description(example_value.class)
368
- unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
369
- raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
383
+ # Booleans are one type, but the example can only name one class
384
+ # (TrueClass for `true`), so a literal `false` compared as
385
+ # falseclass and was rejected: `proxy: false` on a process
386
+ # raised "expected a boolean, got falseclass". Ask the value
387
+ # what it is instead of the example.
388
+ if boolean?(example_value)
389
+ unless boolean?(value)
390
+ raise ConfigError, "#{current_context}: expected a boolean, got #{value.class.name.downcase}"
391
+ end
392
+ else
393
+ expected = type_description(example_value.class)
394
+ unless value.is_a?(example_value.class) || (example_value.is_a?(String) && value.is_a?(String))
395
+ raise ConfigError, "#{current_context}: expected #{expected}, got #{value.class.name.downcase}"
396
+ end
370
397
  end
371
398
  end
372
399
  end
373
400
 
401
+ def boolean?(value)
402
+ value == true || value == false
403
+ end
404
+
374
405
  def validate_array_of!(array, type)
375
406
  array.each_with_index do |value, index|
376
407
  with_context(index) do
data/lib/yamine/proxy.rb CHANGED
@@ -106,6 +106,17 @@ module Yamine
106
106
  end
107
107
 
108
108
  # Pure request-routing core, tested without sockets.
109
+ #
110
+ # Exact hostname first, then an opted-in wildcard. The wildcard is
111
+ # deliberately not ambient: a route only answers its own subdomains
112
+ # when it registered with `subdomains: true` (config
113
+ # `proxy.subdomains`, or `yamine alias --wildcard`).
114
+ #
115
+ # It used to be unconditional, which meant every unregistered label
116
+ # under any live app silently resolved to that app. The expensive
117
+ # case is a worktree: `<branch>.myapp.localhost` answered as the main
118
+ # checkout — a wrong-but-working app, indistinguishable from the
119
+ # right one. An unregistered hostname now 404s, and says so.
109
120
  def route(authority, routes)
110
121
  host = Hostname.strip_port(authority)
111
122
  return nil if host.empty? || host.bytesize > MAX_HOSTNAME_BYTES
@@ -113,8 +124,7 @@ module Yamine
113
124
  exact = routes.find { |r| r["hostname"] == host }
114
125
  return exact if exact
115
126
 
116
- wildcard = routes.find { |r| host.end_with?(".#{r["hostname"]}") }
117
- wildcard
127
+ routes.find { |r| r["subdomains"] && host.end_with?(".#{r["hostname"]}") }
118
128
  end
119
129
 
120
130
  def check_hops(headers)
@@ -452,9 +462,23 @@ module Yamine
452
462
  return respond(sock, 404, "<h1>Not Found</h1>")
453
463
  end
454
464
 
465
+ # A label in front of a live app is almost always a worktree or a
466
+ # branch whose stack is not running. Name the parent it would fall
467
+ # under and where it lives, so "the app loaded but it's the wrong
468
+ # code" becomes "that worktree isn't running" without a search.
469
+ parent = routes.find { |r| bare.end_with?(".#{r["hostname"]}") }
470
+ hint = if parent
471
+ dir = parent.dig("spec", "dir")
472
+ where = dir ? " in #{escape(dir)}" : ""
473
+ "<p><strong>#{escape(parent["hostname"])}</strong> is running#{where}.</p>" \
474
+ "<p>If #{escape(bare)} is a worktree or branch, start it there " \
475
+ "(<code>yamine start</code>), or open " \
476
+ "<strong>#{escape(parent["hostname"])}</strong> instead.</p>"
477
+ end
478
+
455
479
  items = routes.map { |r| "<li>#{escape(r["hostname"])}</li>" }.join
456
480
  body = "<h1>No app registered for #{escape(bare)}</h1>" \
457
- "<ul>#{items}</ul>"
481
+ "#{hint}<ul>#{items}</ul>"
458
482
  respond(sock, 404, body)
459
483
  end
460
484
 
@@ -9,10 +9,26 @@ module Yamine
9
9
  module_function
10
10
 
11
11
  Result = Struct.new(:app, :tld, :host, :processes, :secrets,
12
- :sources, :variant, :db, :env, keyword_init: true)
12
+ :sources, :variant, :overlay, :subdomains, :db, :env, keyword_init: true)
13
13
 
14
- def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil)
15
- config = Config.load(dir, variant: variant)
14
+ # Two axes, and keeping them apart is the whole point:
15
+ #
16
+ # overlay — an explicit `config/local.<name>.yml` to deep-merge.
17
+ # Only --variant / YAMINE_VARIANT ever set it, so a
18
+ # branch name can never select config by surprise.
19
+ # variant — the leading hostname label, from that same explicit
20
+ # name OR a linked worktree's branch. It is a name, not
21
+ # a file: a worktree is reachable at
22
+ # <branch>.<app>.localhost without inventing config.
23
+ #
24
+ # They share a value when the user asks for one explicitly. Only
25
+ # then does a variant mean both "merge this file" and "prefix this
26
+ # hostname", which is the documented `--variant` behavior.
27
+ def resolve(dir = Dir.pwd, variant: nil, tld: nil, host: nil, use_branch: false)
28
+ overlay = (variant || ENV["YAMINE_VARIANT"])&.strip
29
+ overlay = nil if overlay&.empty?
30
+
31
+ config = Config.load(dir, variant: overlay)
16
32
  unless config
17
33
  raise ConfigError, Config.missing_message(dir)
18
34
  end
@@ -21,8 +37,7 @@ module Yamine
21
37
  proxy = config.proxy_config
22
38
  processes = config.processes
23
39
 
24
- variant_name = (variant || ENV["YAMINE_VARIANT"])&.strip
25
- variant_name = nil if variant_name&.empty?
40
+ variant_name, variant_source = Variant.resolve(dir, explicit: variant, use_branch: use_branch)
26
41
 
27
42
  # host or tld: config proxy.host wins over proxy.tld; flags override.
28
43
  if host || proxy["host"]
@@ -41,7 +56,8 @@ module Yamine
41
56
  app: config.path.to_s,
42
57
  tld: proxy["tld"] ? "#{config.path} proxy.tld" : "default (localhost)",
43
58
  host: proxy["host"] ? "#{config.path} proxy.host" : nil,
44
- variant: variant_name ? "config/local.#{variant_name}.yml" : nil
59
+ variant: variant_source,
60
+ overlay: overlay ? "config/local.#{overlay}.yml" : nil
45
61
  }
46
62
 
47
63
  Result.new(
@@ -52,20 +68,34 @@ module Yamine
52
68
  secrets: config.secrets,
53
69
  sources: sources,
54
70
  variant: variant_name,
71
+ overlay: overlay,
72
+ subdomains: config.subdomains?,
55
73
  db: config.data["db"],
56
74
  env: config.env_config
57
75
  )
58
76
  end
59
77
 
60
78
  # Hostname for one process: primary (first proxy:true) is bare;
61
- # others are proc.app.tld; proxy:false have none.
79
+ # others are proc.app.tld; proxy:false have none. A variant — the
80
+ # explicit name or a worktree's branch — leads every one of them.
62
81
  def hostname_for(result, proc_name)
63
82
  entry = result.processes[proc_name]
64
83
  return nil unless entry
65
84
  return nil if entry["proxy"] == false
66
85
 
67
- base = result.host ? result.host : "#{result.app}.#{result.tld}"
68
- proc_name.to_s == primary_proc(result) ? base : "#{proc_name}.#{base}"
86
+ # An explicit proxy.host is a full hostname, so composition does
87
+ # not apply — and a variant must never silently rewrite a host
88
+ # the user wrote out in full.
89
+ if result.host
90
+ return proc_name.to_s == primary_proc(result) ? result.host : "#{proc_name}.#{result.host}"
91
+ end
92
+
93
+ Hostname.compose(
94
+ app: result.app,
95
+ tld: result.tld,
96
+ service: (proc_name.to_s == primary_proc(result) ? nil : proc_name.to_s),
97
+ variant: result.variant
98
+ )
69
99
  end
70
100
 
71
101
  def primary_proc(result)
@@ -76,7 +76,12 @@ module Yamine
76
76
  # `agent` records the owner (defaults to Agent.name); foreign-owned
77
77
  # live routes can only be taken with force: true, and the conflict
78
78
  # error names the other agent plus its worktree dir.
79
- def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil)
79
+ #
80
+ # `subdomains` opts this route into answering its own subdomains.
81
+ # Absent (the default, and what every pre-existing route file has)
82
+ # means exact hostname only — see Proxy#route for why.
83
+ def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil,
84
+ subdomains: false)
80
85
  agent ||= Agent.name
81
86
  killed = nil
82
87
  with_lock do
@@ -101,6 +106,7 @@ module Yamine
101
106
  entry = { "hostname" => hostname, "target" => target, "kind" => kind,
102
107
  "pid" => pid, "agent" => agent }
103
108
  entry["spec"] = spec if spec
109
+ entry["subdomains"] = true if subdomains
104
110
  routes << entry
105
111
  save_routes(routes)
106
112
  end
data/lib/yamine/runner.rb CHANGED
@@ -99,7 +99,7 @@ module Yamine
99
99
  # win over it, because those describe the boot rather than the app.
100
100
  def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
101
101
  rails_dev_host: nil, register: true, database_url: nil, spec: nil,
102
- extra_env: nil)
102
+ extra_env: nil, subdomains: false)
103
103
  port ||= Ports.find_free
104
104
  env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
105
105
  database_url: database_url, extra_env: extra_env)
@@ -111,7 +111,7 @@ module Yamine
111
111
  begin
112
112
  spec ||= { "dir" => File.expand_path(dir), "proc" => name }
113
113
  @store.add_route(hostname, target, Process.pid, kind: "tcp",
114
- force: force, spec: spec)
114
+ force: force, spec: spec, subdomains: subdomains)
115
115
  write_backend_pid(hostname, pid)
116
116
  rescue StandardError
117
117
  # Registration refused (quota, conflict): the backend is
@@ -140,9 +140,9 @@ module Yamine
140
140
  end
141
141
 
142
142
  # Register an already-spawned backend: route + sidecar, together.
143
- def adopt(hostname, app, force: false, spec: nil)
143
+ def adopt(hostname, app, force: false, spec: nil, subdomains: false)
144
144
  @store.add_route(hostname, app.target, Process.pid, kind: app.kind,
145
- force: force, spec: spec)
145
+ force: force, spec: spec, subdomains: subdomains)
146
146
  write_backend_pid(hostname, app.pid)
147
147
  nil
148
148
  end
@@ -51,12 +51,27 @@ module Yamine
51
51
  label.empty? ? nil : [label, source]
52
52
  end
53
53
 
54
+ # The whole branch, not just its last path segment. `feature/login`
55
+ # and `bugfix/login` are different worktrees and must not end up
56
+ # sharing a hostname; taking the last segment alone collapsed them
57
+ # both to `login`.
54
58
  def branch_to_prefix(branch)
55
59
  return nil if branch.nil? || branch.empty?
56
60
  return nil if branch == "HEAD" || DEFAULT_BRANCHES.include?(branch)
57
61
 
58
- last = branch.split("/").last.to_s
59
- label = Sanitize.hostname_label(last)
62
+ label = Sanitize.hostname_label(branch)
63
+ label.empty? ? nil : label
64
+ end
65
+
66
+ # A detached HEAD has no branch to name it, so the worktree's
67
+ # directory does — the same identity the per-worktree database is
68
+ # keyed on. Without this, a detached worktree would fall back to the
69
+ # bare app name and answer on the main checkout's hostname.
70
+ def worktree_dir_prefix(cwd)
71
+ top, status = git(cwd, "rev-parse", "--show-toplevel")
72
+ return nil unless status.success?
73
+
74
+ label = Sanitize.hostname_label(File.basename(top.strip))
60
75
  label.empty? ? nil : label
61
76
  end
62
77
 
@@ -81,7 +96,7 @@ module Yamine
81
96
  branch, s3 = git(cwd, "rev-parse", "--abbrev-ref", "HEAD")
82
97
  return nil unless s3.success?
83
98
 
84
- branch_to_prefix(branch.strip)
99
+ branch_to_prefix(branch.strip) || worktree_dir_prefix(cwd)
85
100
  rescue SystemCallError
86
101
  filesystem_worktree_prefix(cwd)
87
102
  end
@@ -98,8 +113,10 @@ module Yamine
98
113
  if match && match[1].match?(%r{[/\\]worktrees[/\\][^/\\]+\z})
99
114
  head = File.join(File.expand_path(match[1], dir.to_s), "HEAD")
100
115
  branch = read_branch_from_head(head)
101
- prefix = branch_to_prefix(branch.to_s)
102
- return prefix ? [prefix, "git worktree"].first : nil
116
+ # Same rule as the git path: branch when there is one, the
117
+ # worktree directory otherwise (detached HEAD).
118
+ prefix = branch_to_prefix(branch.to_s) || Sanitize.hostname_label(dir.basename.to_s)
119
+ return prefix.empty? ? nil : prefix
103
120
  end
104
121
  return nil
105
122
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.11.1"
4
+ VERSION = "0.13.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto