yamine 0.11.1 → 0.12.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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +24 -4
- data/lib/ask/skills/yamine/SKILL.md +13 -3
- data/lib/yamine/cli/boot.rb +5 -4
- data/lib/yamine/cli/routes.rb +16 -3
- data/lib/yamine/resolver.rb +38 -9
- data/lib/yamine/variant.rb +22 -5
- data/lib/yamine/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a9a67fc55d6a6514571970b0a4ee40a682878c28b4a2bdf3a140a7a509715c8
|
|
4
|
+
data.tar.gz: e3332534d4123efbc4fea82c06c0af753866d4ca48db335860e921121baf2e12
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7306ce065ecb6ff40ee10465f3fffc5108d6786a596c2783194459ec143e42649026f7b14ba170e3bbc9561d14738a613b0da8654ed59a335ba2e54e818a6345
|
|
7
|
+
data.tar.gz: fb1603c3b379c8067ce51a9aec0baf51a669aec3a9a74002493d9d750e7d224ebb9c8feeb46d786a84f1c0f035d590960e4943feb56e0fb9da7c3c8da1b1b186
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.12.0] — 2026-09-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Worktrees get their own URL, as the README always claimed.**
|
|
8
|
+
`Variant.resolve`, the worktree detector, `Hostname.compose`'s variant
|
|
9
|
+
axis and their unit tests were all present, correct, and never
|
|
10
|
+
assembled: `Resolver.resolve` never called `Variant.resolve`, and
|
|
11
|
+
`hostname_for` built `{service}.{app}.{tld}` by hand, bypassing the
|
|
12
|
+
composer. So every worktree of a project answered on the *same*
|
|
13
|
+
hostname as its main checkout, and the ownership gate refused to boot
|
|
14
|
+
the second one — while telling the user to "work in your own worktree
|
|
15
|
+
(each branch gets its own URL)", advice that could not work.
|
|
16
|
+
|
|
17
|
+
A linked worktree is now reachable at `<branch>.<app>.<tld>`
|
|
18
|
+
(`work/next` → `work-next.myapp.localhost`) and boots beside its main
|
|
19
|
+
checkout, which keeps the bare name. Cross-checked by serving two
|
|
20
|
+
branches at once: distinct ports, distinct asset digests, no shared
|
|
21
|
+
route.
|
|
22
|
+
|
|
23
|
+
Found while trying to run a second checkout of a Rails app for UI work,
|
|
24
|
+
which is exactly the case the feature existed for.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **The variant axis is split in two: `overlay` and `variant`.** They
|
|
29
|
+
were one setting, which meant that as soon as a worktree supplied its
|
|
30
|
+
own variant, `config/local.<branch>.yml` became an implied file lookup
|
|
31
|
+
— so a branch named like a file on disk would silently change the
|
|
32
|
+
config. Now only an explicit `--variant` / `YAMINE_VARIANT` sets the
|
|
33
|
+
overlay to merge; a worktree branch supplies the hostname label and
|
|
34
|
+
nothing else. `status` prints both lines, so the two can no longer be
|
|
35
|
+
mistaken for each other.
|
|
36
|
+
|
|
37
|
+
- **A branch label is the whole branch, not its last path segment.**
|
|
38
|
+
`feature/login` and `bugfix/login` are different worktrees and used to
|
|
39
|
+
collapse to the same `login` hostname. Overlong branches still resolve
|
|
40
|
+
through the existing hash-suffix truncation.
|
|
41
|
+
|
|
42
|
+
- A worktree on a detached HEAD falls back to its directory name (the
|
|
43
|
+
same identity the per-worktree database uses) instead of returning no
|
|
44
|
+
prefix and quietly colliding with the main checkout.
|
|
45
|
+
|
|
46
|
+
- `yamine status` now honors `--variant` / `--tld`, and accepts
|
|
47
|
+
`--branch`, which the README documented but `parse_flags` rejected as
|
|
48
|
+
an unknown flag.
|
|
49
|
+
|
|
50
|
+
- `proxy.host` is never rewritten by a variant: an explicitly written
|
|
51
|
+
hostname is left exactly as written.
|
|
52
|
+
|
|
3
53
|
## [0.11.1] — 2026-09-11
|
|
4
54
|
|
|
5
55
|
### Fixed
|
data/README.md
CHANGED
|
@@ -117,7 +117,8 @@ whole tree, and cleans up when one exits.
|
|
|
117
117
|
|
|
118
118
|
Variants are file overlays: `config/local.<variant>.yml` deep-merges on
|
|
119
119
|
top of `config/local.yml`, selected by `YAMINE_VARIANT` (Kamal's
|
|
120
|
-
destination pattern).
|
|
120
|
+
destination pattern). Naming a variant also prefixes the hostname — see
|
|
121
|
+
below for how that differs from a worktree's automatic prefix.
|
|
121
122
|
|
|
122
123
|
`.localhost` resolves to loopback natively in Chrome, Firefox, and Edge —
|
|
123
124
|
no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
|
|
@@ -135,10 +136,24 @@ no DNS server, no `/etc/resolver`. Safari may need `yamine hosts sync`.
|
|
|
135
136
|
| variant | `fix-ui.myapp` | `--variant`, `YAMINE_VARIANT`, linked worktree branch |
|
|
136
137
|
| tld | `myapp.preview.example.com` | `--tld` (default `localhost`) |
|
|
137
138
|
|
|
139
|
+
`proxy.host` is the one exception: an explicitly written full hostname
|
|
140
|
+
bypasses composition entirely, variant included.
|
|
141
|
+
|
|
138
142
|
Linked git worktrees get a branch prefix automatically
|
|
139
|
-
(`
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
(`ui-onboarding.myapp.localhost`); the main checkout keeps the bare name,
|
|
144
|
+
so a worktree and its main checkout run side by side. The label is the
|
|
145
|
+
whole branch (`feature/login` → `feature-login.myapp.localhost`), so two
|
|
146
|
+
branches that share a last segment never share a hostname. A detached
|
|
147
|
+
HEAD has no branch to name it and falls back to the worktree's directory
|
|
148
|
+
— the same identity its per-worktree database uses. `main`/`master`
|
|
149
|
+
never prefix. Pass `--branch` (or `YAMINE_BRANCH=1`) to prefix by the
|
|
150
|
+
current branch outside worktrees.
|
|
151
|
+
|
|
152
|
+
A variant is a hostname label, not a config file. Only an explicit
|
|
153
|
+
`--variant` / `YAMINE_VARIANT` goes looking for
|
|
154
|
+
`config/local.<name>.yml` to merge; a worktree branch never does, so a
|
|
155
|
+
branch named like a file on disk cannot change your config by accident.
|
|
156
|
+
`yamine status` prints both lines so the two are never confused.
|
|
142
157
|
|
|
143
158
|
```bash
|
|
144
159
|
yamine # -> https://myapp.localhost
|
|
@@ -147,6 +162,11 @@ yamine --variant demo # -> https://demo.myapp.localhos
|
|
|
147
162
|
yamine --tld preview.example.com # your own domain (OAuth parity)
|
|
148
163
|
```
|
|
149
164
|
|
|
165
|
+
Because a registered route answers `*.` subdomains of itself, any
|
|
166
|
+
`<label>.myapp.localhost` reaches the main checkout until a worktree
|
|
167
|
+
registers that exact name — at which point the exact route wins and the
|
|
168
|
+
worktree takes over.
|
|
169
|
+
|
|
150
170
|
## Commands
|
|
151
171
|
|
|
152
172
|
```bash
|
|
@@ -78,16 +78,26 @@ 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
|
|
85
|
-
|
|
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
|
+
|
|
91
101
|
## First time on a machine
|
|
92
102
|
|
|
93
103
|
Run `yamine start` — it does the one-shot CA trust, port 443, and
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -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])
|
|
@@ -653,10 +654,10 @@ module Yamine
|
|
|
653
654
|
end
|
|
654
655
|
end
|
|
655
656
|
|
|
656
|
-
def resolve!(ctx, variant: nil, tld: nil)
|
|
657
|
+
def resolve!(ctx, variant: nil, tld: nil, use_branch: false)
|
|
657
658
|
# The resolver calls Config.load, which raises ConfigError if
|
|
658
659
|
# config/local.yml is missing — exactly what we want.
|
|
659
|
-
Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld)
|
|
660
|
+
Yamine::Resolver.resolve(Dir.pwd, variant: variant, tld: tld, use_branch: use_branch)
|
|
660
661
|
end
|
|
661
662
|
|
|
662
663
|
# json: keeps stdout free for the machine-readable stream — this runs
|
data/lib/yamine/cli/routes.rb
CHANGED
|
@@ -175,6 +175,9 @@ module Yamine
|
|
|
175
175
|
# agent's cleanup). --force overrides and names the previous owner.
|
|
176
176
|
def stop(ctx, args, out: $stdout)
|
|
177
177
|
force = args.delete("--force")
|
|
178
|
+
# In a worktree this resolves that worktree's own hostname (the
|
|
179
|
+
# branch is the variant), so `stop` here never reaches across
|
|
180
|
+
# into the main checkout's running app.
|
|
178
181
|
resolved = Resolver.resolve(Dir.pwd)
|
|
179
182
|
hostnames = Resolver.hostnames(resolved)
|
|
180
183
|
mine = Agent.name
|
|
@@ -261,9 +264,14 @@ module Yamine
|
|
|
261
264
|
# `yamine` would boot here and why. Answers "why did I get
|
|
262
265
|
# this URL" without booting anything. --json emits stable keys
|
|
263
266
|
# for agents instead of prose.
|
|
264
|
-
def status(
|
|
267
|
+
def status(ctx, args)
|
|
265
268
|
json = args.delete("--json")
|
|
266
|
-
|
|
269
|
+
# The flags exist so `status --variant x` answers the same
|
|
270
|
+
# question boot would; in a worktree the branch supplies the
|
|
271
|
+
# variant anyway, so plain `status` is already the right answer.
|
|
272
|
+
opts = ctx.parse_flags(args, %i[variant tld branch])
|
|
273
|
+
resolved = Resolver.resolve(Dir.pwd, variant: opts[:variant], tld: opts[:tld],
|
|
274
|
+
use_branch: opts[:branch])
|
|
267
275
|
hostnames = Resolver.hostnames(resolved)
|
|
268
276
|
urls = hostnames.map do |h|
|
|
269
277
|
Hostname.url(h, port: ProxyControl.default_port(true), tls: true)
|
|
@@ -273,6 +281,7 @@ module Yamine
|
|
|
273
281
|
tld: resolved.tld, tld_source: resolved.sources[:tld],
|
|
274
282
|
host: resolved.host, host_source: resolved.sources[:host],
|
|
275
283
|
variant: resolved.variant, variant_source: resolved.sources[:variant],
|
|
284
|
+
overlay: resolved.overlay, overlay_source: resolved.sources[:overlay],
|
|
276
285
|
urls: urls,
|
|
277
286
|
processes: resolved.processes.keys,
|
|
278
287
|
framework: Framework.detect(Dir.pwd).to_s
|
|
@@ -288,7 +297,11 @@ module Yamine
|
|
|
288
297
|
else
|
|
289
298
|
puts "tld: #{payload[:tld]} (from #{payload[:tld_source]})"
|
|
290
299
|
end
|
|
291
|
-
puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no
|
|
300
|
+
puts "variant: #{payload[:variant] || "(none)"} (from #{payload[:variant_source] || "no flag, env, or worktree branch"})"
|
|
301
|
+
# The overlay is a different thing from the variant and only an
|
|
302
|
+
# explicit --variant / YAMINE_VARIANT ever sets it, so say so
|
|
303
|
+
# rather than letting the two look like one setting.
|
|
304
|
+
puts "overlay: #{payload[:overlay] ? "#{payload[:overlay]} (merged)" : "(none)"}"
|
|
292
305
|
puts "processes: #{payload[:processes].join(", ")}"
|
|
293
306
|
puts "urls:"
|
|
294
307
|
urls.each { |u| puts " #{u}" }
|
data/lib/yamine/resolver.rb
CHANGED
|
@@ -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, :db, :env, keyword_init: true)
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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:
|
|
59
|
+
variant: variant_source,
|
|
60
|
+
overlay: overlay ? "config/local.#{overlay}.yml" : nil
|
|
45
61
|
}
|
|
46
62
|
|
|
47
63
|
Result.new(
|
|
@@ -52,20 +68,33 @@ module Yamine
|
|
|
52
68
|
secrets: config.secrets,
|
|
53
69
|
sources: sources,
|
|
54
70
|
variant: variant_name,
|
|
71
|
+
overlay: overlay,
|
|
55
72
|
db: config.data["db"],
|
|
56
73
|
env: config.env_config
|
|
57
74
|
)
|
|
58
75
|
end
|
|
59
76
|
|
|
60
77
|
# Hostname for one process: primary (first proxy:true) is bare;
|
|
61
|
-
# others are proc.app.tld; proxy:false have none.
|
|
78
|
+
# others are proc.app.tld; proxy:false have none. A variant — the
|
|
79
|
+
# explicit name or a worktree's branch — leads every one of them.
|
|
62
80
|
def hostname_for(result, proc_name)
|
|
63
81
|
entry = result.processes[proc_name]
|
|
64
82
|
return nil unless entry
|
|
65
83
|
return nil if entry["proxy"] == false
|
|
66
84
|
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
# An explicit proxy.host is a full hostname, so composition does
|
|
86
|
+
# not apply — and a variant must never silently rewrite a host
|
|
87
|
+
# the user wrote out in full.
|
|
88
|
+
if result.host
|
|
89
|
+
return proc_name.to_s == primary_proc(result) ? result.host : "#{proc_name}.#{result.host}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
Hostname.compose(
|
|
93
|
+
app: result.app,
|
|
94
|
+
tld: result.tld,
|
|
95
|
+
service: (proc_name.to_s == primary_proc(result) ? nil : proc_name.to_s),
|
|
96
|
+
variant: result.variant
|
|
97
|
+
)
|
|
69
98
|
end
|
|
70
99
|
|
|
71
100
|
def primary_proc(result)
|
data/lib/yamine/variant.rb
CHANGED
|
@@ -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
|
-
|
|
59
|
-
label
|
|
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
|
-
|
|
102
|
-
|
|
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
|
data/lib/yamine/version.rb
CHANGED