yamine 0.16.0 → 0.18.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: 66abd82532420b13b18597a4d71b51485f06b09c14d5b0d4c3e777d8056e7145
4
- data.tar.gz: be3bcb4b88ba0eeb444ed33d10c14c217cf145073f2f8cce78e62e4dc138bffd
3
+ metadata.gz: 43b8c059b0635ce4272b806fd2b8f70ae42ed6d591ed87536de9c86ed0419864
4
+ data.tar.gz: c34fb3a40d5c16442accee4dcbdb32b1b2b5013de5c3a823bade745b6ac65e88
5
5
  SHA512:
6
- metadata.gz: e03ba4c4510f03424190fdf58f0c5f6db6183425b75c31628df99724bf592edc068ab13cd6bf7abd28b04c577025b4ec913eb87d892b78524aab7aee35e83dbd
7
- data.tar.gz: 9eaa287f5ee45e231cd82f29c8dbf0bf0f3e197cc825dfeccccb4f39ff02c826167d32516c23fef7c2a39804d662a69b417e082b1b9d2e6f1b562ce63597a95e
6
+ metadata.gz: 70495a26f226c3b0dac853c5a4ce1694207f4a9bbb73af92b44b37cc17ff3c13430af7440252102b8acd40f22ee7efdae5dfec26155678a5839e30b10c5c0d1a
7
+ data.tar.gz: e450abc81d00dad86bf03e540304ad43683e99329a60eb24ddea0122760048c88729e7fe33c9b93056a1840ec871e488ea458d1afdbd655b0dccd477369b8cb5
data/CHANGELOG.md CHANGED
@@ -2,6 +2,73 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.18.0] — 2026-09-23
6
+
7
+ ### Changed
8
+
9
+ - **Only environment-scoped env files — never plain `.env`.**
10
+ `worktree add` writes `.env.development` + `.env.test`; the plain
11
+ `.env` it also wrote in 0.17.0 is gone. Plain `.env` is the file
12
+ production-style tooling reads by name — kamal, docker
13
+ `--env-file`, and dotenv itself loads `.env` in *every* environment
14
+ — so a worktree's copy could leak development database URLs into a
15
+ production boot that happened to run from that directory.
16
+ `.env.development` / `.env.test` are invisible to a production boot
17
+ by construction. A yamine-authored `.env` from 0.17.0 is cleaned up
18
+ on sight (header-marked); a foreign `.env` — app-committed or
19
+ hand-written — is never touched.
20
+ - **Env-file writes upsert instead of overwrite.** yamine's keys are
21
+ (re)written; every other line in the file survives, so a committed
22
+ env file's config or a developer's copied-in API keys are no longer
23
+ destroyed by `yamine db create`. The yamine header stays
24
+ idempotent across re-runs.
25
+
26
+
27
+ ## [0.17.0] — 2026-09-23
28
+
29
+ ### Added
30
+
31
+ - **Per-worktree `.env` files — hand-run isolation without touching the
32
+ app's config.** `worktree add` writes `.env` and `.env.development`
33
+ (the whole development set: `DATABASE_URL` + one `NAME_DATABASE_URL`
34
+ per configuration) and `.env.test` (`PRIMARY_DATABASE_URL` — the key
35
+ Rails checks *before* `DATABASE_URL` for a flat test config, so the
36
+ test URL wins no matter what order a loader reads the files in).
37
+ Any dotenv loader picks them up — hand-run `rails console`,
38
+ `rails test`, and `db:migrate` land on the worktree's databases with
39
+ zero yamine-specific code in `database.yml`. Mode 0600, git-excluded
40
+ automatically, removed with the worktree.
41
+
42
+ ### Changed
43
+
44
+ - **`.yamine-db-suffix` (0.16.0) is replaced by the `.env` files above.**
45
+ The database.yml suffix hook is no longer needed — apps keep a plain
46
+ boring config. Legacy marker files are deleted on sight.
47
+ - **The conformance check now diagnoses precisely.** `worktree add`
48
+ re-probes after writing `.env` and distinguishes the two ways an app
49
+ can fail to isolate hand-run commands: no dotenv loader loaded the
50
+ file (fix: one Gemfile line), vs `database.yml` supplies `url:` keys,
51
+ which Rails gives *precedence over the entire environment* —
52
+ `merge_db_environment_variables` skips URL-shaped configs, so neither
53
+ injected env nor `.env` can ever redirect them (fix: component form —
54
+ `database:`, `host:`, `username:` — for development/test).
55
+ - **Worktrees copy only the credential keys their own environments
56
+ need**: `config/master.key`, `config/credentials/development.key`,
57
+ `config/credentials/test.key`. Production and staging keys no longer
58
+ travel into throwaway directories.
59
+
60
+ ### Fixed
61
+
62
+ - **`worktree add` could purge the MAIN checkout's test database.**
63
+ The per-worktree test schema preparation ran under
64
+ `RAILS_ENV=development`, where the flat test config resolves to the
65
+ BASE name (the environment-override merge only applies to the current
66
+ environment's configs) — so `db:test:prepare` purged and reloaded
67
+ *main's* `myrr_markdown_test` during a worktree add, observed live.
68
+ It now runs under `RAILS_ENV=test`, where dotenv loads `.env.test`
69
+ and the override lands on the worktree's own test database; a
70
+ regression test pins the environment.
71
+
5
72
  ## [0.16.0] — 2026-09-23
6
73
 
7
74
  ### Added
data/README.md CHANGED
@@ -171,7 +171,7 @@ yamine worktree clean # tear down everything already merged
171
171
 
172
172
  `add` lands the worktree beside the repo, copies the gitignored
173
173
  per-checkout config (`config/local.yml`, `config/local.secrets`,
174
- `config/master.key`, `config/credentials/*.key`) the branch needs, runs
174
+ `config/master.key`, and the development/test credential keys) the branch needs, runs
175
175
  `bundle install`, asks the app what databases it has, and provisions
176
176
  the whole set with schema — the next step is just `yamine start` in
177
177
  it.
@@ -200,12 +200,12 @@ guessed. `worktree add` boots `bin/rails runner` once inside the app so
200
200
  database.yml and credentials resolve exactly as the app would resolve
201
201
  them (yamine never parses config or touches a key), suffixes every
202
202
  database name with a collision-guarded per-worktree token, creates and
203
- schema-loads them, and records names plus server coordinates in the
204
- claim. Boot injects `DATABASE_URL` and one `NAME_DATABASE_URL` per
205
- configuration (Rails' own convention), so supervised processes are
206
- isolated even in an app that never heard of yamine. `remove`/`clean`
207
- drop the entire set as a unit — including from an orphaned claim whose
208
- directory is already gone, without the app booting.
203
+ schema-loads them, prepares the test database, and records names plus
204
+ server coordinates in the claim. Boot injects `DATABASE_URL` and one
205
+ `NAME_DATABASE_URL` per configuration (Rails' own convention).
206
+ `remove`/`clean` drop the entire set as a unit — including from an
207
+ orphaned claim whose directory is already gone, without the app
208
+ booting.
209
209
 
210
210
  ```bash
211
211
  yamine db describe # what THIS checkout resolves to (passwords masked)
@@ -213,54 +213,78 @@ yamine db list # every claim, every database under it
213
213
  yamine db create # re-probe + provision (run after the app grows a database)
214
214
  ```
215
215
 
216
- **Hand-run commands.** Env injection only reaches processes yamine
217
- spawns; a `rails console`, `rails test`, or `db:migrate` you run by
218
- hand reads the environment you gave it. For those, opt the app in with
219
- the suffix hook — a one-time addition at the top of
220
- `config/database.yml`:
221
-
222
- ```erb
223
- <%
224
- yamine_suffix = begin
225
- f = Rails.root.join(".yamine-db-suffix")
226
- f.exist? ? f.read.strip : ""
227
- rescue StandardError
228
- ""
229
- end
230
- yamine_db_url = lambda do |url|
231
- next url if yamine_suffix.empty? || url.nil? || url.to_s.empty?
232
- require "uri"
233
- begin
234
- uri = URI.parse(url.to_s)
235
- uri.path = "#{uri.path}_#{yamine_suffix}" if uri.path && uri.path != "/"
236
- uri.to_s
237
- rescue URI::InvalidURIError
238
- url
239
- end
240
- end
241
- %>
242
- ```
243
-
244
- Then wrap each database URL (and a bare test database name) with it:
245
-
246
- ```yaml
247
- development:
248
- primary:
249
- url: <%= yamine_db_url.call(Rails.application.credentials.dig(:database, :primary, :url)) %>
250
- test:
251
- database: <%= ENV.fetch("TEST_DATABASE_NAME") { "myapp_test#{yamine_suffix.empty? ? "" : "_#{yamine_suffix}"}" } %>
252
- ```
253
-
254
- `worktree add` writes the `.yamine-db-suffix` token (git-excluded
255
- automatically) and then *proves* the hook works — you'll see
256
- `database.yml reads .yamine-db-suffix — hand-run commands are isolated
257
- too`. Without the hook, supervised boots are still fully isolated via
258
- env; `add` says exactly what the hand-run gap is. The test database is
259
- part of the claim too: created and schema-prepared on first add (so
260
- `rails test` runs as-is), dropped at teardown — never left behind.
216
+ **Two one-time app requirements, both boring:**
217
+
218
+ 1. *Component-form development/test config.* Environment overrides —
219
+ injected `DATABASE_URL`/`NAME_DATABASE_URL` and the per-worktree
220
+ `.env` — only apply to component keys (`database:`, `host:`). A
221
+ `url:` key (the usual credentials-driven style) takes precedence
222
+ over the *entire* environment: Rails skips URL-shaped configs when
223
+ merging environment variables, so nothing injected or loaded can
224
+ redirect them. Development and test should read like a plain Rails
225
+ file:
226
+
227
+ ```yaml
228
+ default: &default
229
+ adapter: postgresql
230
+ encoding: unicode
231
+ host: <%= ENV.fetch("DB_HOST", "localhost") %>
232
+ username: postgres
233
+
234
+ development:
235
+ primary:
236
+ <<: *default
237
+ database: myapp_development
238
+ cache:
239
+ <<: *default
240
+ database: myapp_development_cache
241
+ migrations_paths: db/cache_migrate
242
+
243
+ staging:
244
+ primary: &primary_staging
245
+ <<: *default
246
+ url: <%= Rails.application.credentials.dig(:database, :primary, :url) %>
247
+ ```
248
+
249
+ Staging/production keep doing whatever they do — yamine only ever
250
+ redirects development and test.
251
+
252
+ 2. *A dotenv loader* — `gem "dotenv-rails", groups: [:development, :test]`
253
+ (any dotenv loader works). That is what reads the files
254
+ `worktree add` writes into the worktree:
255
+
256
+ - `.env.development` — the whole development set (`DATABASE_URL`
257
+ plus one `NAME_DATABASE_URL` per configuration),
258
+ - `.env.test` — the test URL under `PRIMARY_DATABASE_URL`, the key
259
+ Rails checks *before* `DATABASE_URL` for a flat test config, so it
260
+ wins regardless of the order a loader reads the files in.
261
+
262
+ Only these environment-scoped names are ever written — **never
263
+ plain `.env`**, the file production tooling reads by name (kamal,
264
+ docker `--env-file`, and dotenv itself loads `.env` in *every*
265
+ environment), so a production boot can never see a worktree's
266
+ database URLs. Mode 0600, git-excluded automatically, removed with
267
+ the worktree. Existing keys in those files are **upserted, never
268
+ clobbered** — your own entries (API keys, a committed env file's
269
+ config) survive `yamine db create`.
270
+ A hand-run `rails console`, `rails test`, or `db:migrate` in the
271
+ worktree therefore lands on the worktree's own databases — with
272
+ **zero yamine-specific code in `database.yml`, ever**.
273
+
274
+ `worktree add` proves both after writing the files: you will see
275
+ `.env loaded — hand-run commands are isolated too`. A warning instead
276
+ names which requirement is missing — no loader ran, or the config is
277
+ `url:`-shaped — and the exact fix. Supervised boots are isolated via
278
+ injected env either way (component form permitting).
279
+
280
+ Credential keys travel too: `config/master.key`,
281
+ `config/credentials/development.key`, and
282
+ `config/credentials/test.key` are copied at mode 0600. Production and
283
+ staging keys stay in the main checkout where they belong.
284
+
285
+ The main checkout never gets these env files or a suffix: its databases
286
+ are its databases, untouched.
261
287
 
262
- The main checkout has no marker file, so nothing changes there: its
263
- databases are its databases.
264
288
 
265
289
  ## Subdomains are opt-in
266
290
 
@@ -109,33 +109,41 @@ yamine worktree clean [--dry-run]
109
109
 
110
110
  `add` creates the git worktree beside the repo, copies the gitignored
111
111
  per-checkout config (`config/local.yml`, `config/local.secrets`,
112
- credential keys), runs `bundle install`, **asks the app what databases
113
- it has** (one `bin/rails runner` probe — database.yml and credentials
114
- resolve inside the app; yamine never parses them), and provisions the
115
- whole set with schema — every database of a multi-database app gets a
116
- per-worktree suffix, the test database included and schema-prepared, so
117
- `rails test` runs as-is. Then boot with `yamine start` inside it.
118
- `remove` and `clean` drop the entire set as a unit — including from an
119
- orphaned claim whose directory is gone, without booting the app — and
120
- `clean` never touches uncommitted work; unmerged branches survive
121
- everything except `remove --force`. Prefer `clean --dry-run` first, and
122
- `clean` over `rm -rf` — a removed worktree leaves no routes, databases,
123
- or stale hosts entries behind.
112
+ `config/master.key`, and the development/test credential keys — never
113
+ production/staging keys), runs `bundle install`, **asks the app what
114
+ databases it has** (one `bin/rails runner` probe — database.yml and
115
+ credentials resolve inside the app; yamine never parses them), and
116
+ provisions the whole set with schema — every database of a
117
+ multi-database app gets a per-worktree suffix, the test database
118
+ included and schema-prepared, so `rails test` runs as-is. It writes
119
+ `.env.development` (the development set) and `.env.test`
120
+ (`PRIMARY_DATABASE_URL` — the key Rails checks first for a flat test
121
+ config) into the worktree, git-excluded automatically — never plain `.env`
122
+ (production tooling reads that name), and existing keys in those files
123
+ are upserted, not clobbered. Then boot with
124
+ `yamine start` inside it. `remove` and `clean` drop the entire set as
125
+ a unit — including from an orphaned claim whose directory is gone,
126
+ without booting the app — and `clean` never touches uncommitted work;
127
+ unmerged branches survive everything except `remove --force`. Prefer
128
+ `clean --dry-run` first, and `clean` over `rm -rf` — a removed
129
+ worktree leaves no routes, databases, or stale hosts entries behind.
124
130
 
125
131
  `yamine db describe` shows what the current checkout resolves to
126
132
  (passwords masked); `yamine db list` shows every database under every
127
133
  claim; `yamine db create` re-probes and self-heals (run it in each
128
134
  worktree after the app grows a database).
129
135
 
130
- Supervised boots are isolated via injected `DATABASE_URL` /
131
- `NAME_DATABASE_URL` env vars. **Hand-run commands are not** — unless
132
- the app carries the `.yamine-db-suffix` hook from the yamine README at
133
- the top of `config/database.yml`. `worktree add` writes that token
134
- file and prints `database.yml reads .yamine-db-suffix` when the hook
135
- is in place; if instead you see a warning that database.yml does not
136
- read it, hand-run `rails console` / `rails test` / `db:migrate` in
137
- that worktree would use the main checkout's databases — add the hook
138
- from the README, then `yamine db create`.
136
+ Isolation reaches two places: supervised boots via injected
137
+ `DATABASE_URL` / `NAME_DATABASE_URL` env vars, and hand-run commands
138
+ (`rails console`, `rails test`, `db:migrate`) via these env files —
139
+ which require a dotenv loader in the app (`gem "dotenv-rails",
140
+ groups: [:development, :test]`) and **component-form development/test
141
+ config** (`database:` keys, never `url:` — a `url:` key takes
142
+ precedence over the entire environment, so nothing injected or loaded
143
+ can redirect it; staging/production URLs are fine, yamine never
144
+ touches those envs). `worktree add` verifies both and prints
145
+ `.env loaded — hand-run commands are isolated too`; its warning names
146
+ exactly which requirement is missing and the fix.
139
147
 
140
148
  ## Subdomains are opt-in
141
149
 
@@ -586,26 +586,25 @@ module Yamine
586
586
  # Worktree-add (and `yamine db create` in a worktree): turn a
587
587
  # probe of the app's real databases into a provisioned,
588
588
  # self-describing claim. Names come from the app, the suffix
589
- # from the directory; claim + marker are written BEFORE any
589
+ # from the directory; claim + .env files are written BEFORE any
590
590
  # database is created, so a failure mid-way leaves a state the
591
591
  # next `yamine db create` or boot can finish from — never a
592
592
  # half-named set with no record of it.
593
593
  #
594
594
  # Returns the final names; raises Error only for conditions the
595
- # user must fix (name budget, server down, app broken by its own
596
- # suffix hook) — those keep the worktree and the claim.
595
+ # user must fix (name budget, server down, .env breaking the
596
+ # app's boot) — those keep the worktree and the claim.
597
597
  def provision_multidb(ctx, dir, claim_key, rows)
598
598
  env_name = rails_env
599
- # A marker from a previous run means the probe answered with
600
- # names the app already suffixed — strip that suffix before
601
- # fitting, or this pass would double it and yamine would
602
- # provision a set the app never connects to.
603
- marker = Database.read_marker(dir)
604
- if marker && !marker.empty?
605
- rows = strip_marker_suffix(rows, marker)
606
- end
599
+ # Re-provisioning a worktree whose .env exists: the app (via
600
+ # dotenv) resolves SUFFIXED names — strip the claim's stored
601
+ # suffix before fitting, or this pass would double it and
602
+ # yamine would provision a set the app never connects to.
603
+ prior = Database.load_map(ctx.store.dir)[claim_key]
604
+ prior_suffix = prior.is_a?(Hash) ? prior["suffix"] : nil
605
+ rows = strip_suffix(rows, prior_suffix)
607
606
  # The test environment's database joins the claim: worktree
608
- # tests run against it (the same marker suffixes it), and
607
+ # tests run against it (.env.test carries its URL), and
609
608
  # teardown must drop it — a leaked test database per worktree
610
609
  # is exactly the leftover this exists to prevent. Test configs
611
610
  # are flat (implicitly named "primary"), so the claim key is
@@ -613,7 +612,7 @@ module Yamine
613
612
  # A test env the app cannot answer (missing test credentials,
614
613
  # say) degrades to a dev-only claim; `yamine db create` heals.
615
614
  test_rows = Probe.rails_databases(dir, env: "test")
616
- test_rows = strip_marker_suffix(test_rows, marker) if test_rows && marker && !marker.empty?
615
+ test_rows = strip_suffix(test_rows, prior_suffix)
617
616
  test_pairs = (test_rows && Probe.server_backed(test_rows) || [])
618
617
  .map { |r| [env_namespaced_key(r["name"], "test"), r] }
619
618
 
@@ -638,8 +637,14 @@ module Yamine
638
637
  "claimed_at" => Time.now.utc.iso8601,
639
638
  "suffix" => fitted, "names" => names, "bases" => bases)
640
639
  Database.save_map(ctx.store.dir, map)
641
- Database.write_marker(dir, fitted)
642
- Database.exclude_marker(dir)
640
+ # The environment files: what hand-run commands read. Written
641
+ # before creation so the verify probe below can see them.
642
+ dev_env = db_env_for(
643
+ names.reject { |cfg, _| cfg == "test" || cfg.start_with?("test_") }, bases)
644
+ test_url = names["test"] &&
645
+ Database.url_for(names["test"], base_for(bases, "test"))
646
+ Database.write_env_files(dir, dev_env, test_url: test_url)
647
+ Database.exclude_files(dir, Database::ENV_FILES)
643
648
 
644
649
  # An empty test database is worse than none: Rails won't
645
650
  # auto-load schema into it (verified — first `rails test`
@@ -659,8 +664,14 @@ module Yamine
659
664
  end
660
665
 
661
666
  if test_needs_schema
667
+ # RAILS_ENV=test, not development: dotenv must load .env.test
668
+ # (PRIMARY_DATABASE_URL = this worktree's test URL) AND the
669
+ # environment-override merge only applies to the CURRENT
670
+ # env's configs — under development the flat test config
671
+ # would resolve to the base name and purge+load the MAIN
672
+ # checkout's test database instead of this worktree's.
662
673
  prepared = Dir.chdir(dir) do
663
- system({ "RAILS_ENV" => rails_env }, "sh", "-c", "bin/rails db:test:prepare",
674
+ system({ "RAILS_ENV" => "test" }, "sh", "-c", "bin/rails db:test:prepare",
664
675
  out: File::NULL, err: File::NULL)
665
676
  end
666
677
  if prepared
@@ -670,16 +681,16 @@ module Yamine
670
681
  end
671
682
  end
672
683
 
673
- verify_marker_conformance(dir, names)
684
+ verify_env_loading(dir, names)
674
685
  names
675
686
  end
676
687
 
677
- def strip_marker_suffix(rows, marker)
678
- return rows if marker.nil? || marker.empty?
688
+ def strip_suffix(rows, suffix)
689
+ return rows if rows.nil? || suffix.nil? || suffix.empty?
679
690
 
680
691
  rows.map do |r|
681
- if r["database"].end_with?("_#{marker}")
682
- r.merge("database" => r["database"].delete_suffix("_#{marker}"))
692
+ if r["database"].end_with?("_#{suffix}")
693
+ r.merge("database" => r["database"].delete_suffix("_#{suffix}"))
683
694
  else
684
695
  r
685
696
  end
@@ -692,20 +703,21 @@ module Yamine
692
703
  cfg_name == "primary" ? env : "#{env}_#{cfg_name}"
693
704
  end
694
705
 
695
- # The proof, not the promise: re-probe now that the marker file
696
- # is in place. Matching names mean database.yml reads it —
697
- # hand-run console/test/migrate commands are isolated too.
698
- # Mismatch is not fatal (supervised boots still isolate via
699
- # env), but it is the one gap worth shouting about at the exact
700
- # moment of creation. A probe that cannot run at all after
701
- # writing the marker IS fatal: the app no longer boots, and the
702
- # marker's own authorship is the likely reason.
703
- def verify_marker_conformance(dir, names)
706
+ # The proof, not the promise: re-probe now that .env exists.
707
+ # Matching names mean something inside the app loads .env
708
+ # (dotenv-rails or any dotenv loader) — hand-run console/test/
709
+ # migrate commands are isolated too. Mismatch is not fatal
710
+ # (supervised boots isolate via injected env regardless), but
711
+ # it is the one gap worth shouting about at the exact moment of
712
+ # creation. A probe that cannot run at all after writing .env
713
+ # IS fatal: the app no longer boots, and .env's own authorship
714
+ # is the likely reason.
715
+ def verify_env_loading(dir, names)
704
716
  rows = Probe.rails_databases(dir)
705
717
  unless rows
706
718
  raise Error,
707
- "the app no longer boots after writing #{Database::MARKER_FILE} — " \
708
- "check the suffix hook at the top of config/database.yml"
719
+ "the app no longer boots after writing .env — " \
720
+ "check the generated .env files in #{dir}"
709
721
  end
710
722
 
711
723
  actual = rows.to_h { |r| [r["name"], r["database"]] }
@@ -714,20 +726,37 @@ module Yamine
714
726
  # for them either way; skip, don't flag.
715
727
  mismatched = names.reject { |cfg, want| !actual.key?(cfg) || actual[cfg] == want }
716
728
  if mismatched.empty?
717
- puts " database.yml reads #{Database::MARKER_FILE} — hand-run commands are isolated too"
729
+ puts " .env loaded — hand-run commands are isolated too"
718
730
  else
719
731
  cfg, want = mismatched.first
720
- warn " WARNING: config/database.yml does not read #{Database::MARKER_FILE} " \
721
- "(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
722
- warn " Supervised boots are still isolated via env, but `rails console`, " \
723
- "`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
724
- warn " Add the suffix hook from the yamine README to database.yml, then re-run `yamine db create`."
732
+ env_url = rows.first["env_database_url"]
733
+ if env_url && !env_url.empty?
734
+ # The loader DID run — DATABASE_URL reached the app — yet
735
+ # resolution ignored it. database.yml supplies `url:` keys
736
+ # (credentials-style), and for those Rails gives the FILE
737
+ # precedence: merge_db_environment_variables skips configs
738
+ # that are already URL-shaped. No environment channel,
739
+ # spawned or loaded, can redirect this app in that form.
740
+ warn " WARNING: #{cfg} resolves to #{actual[cfg].inspect} even though " \
741
+ "DATABASE_URL says #{env_url.split("/").last.inspect}."
742
+ warn " database.yml's `url:` keys take precedence over the environment in this configuration —"
743
+ warn " neither injected env nor .env can redirect them. Switch development/test to"
744
+ warn " component form (database:, host:, username:) so DATABASE_URL and .env apply —"
745
+ warn " see the yamine README's multi-database section."
746
+ else
747
+ warn " WARNING: this app does not load .env " \
748
+ "(#{cfg} resolves to #{actual[cfg].inspect}, expected #{want.inspect})."
749
+ warn " Supervised boots are still isolated via injected env, but `rails console`, " \
750
+ "`rails test`, and `db:migrate` run by hand in this worktree would use the main checkout's databases."
751
+ warn " Fix once: add `gem \"dotenv-rails\", groups: [:development, :test]` to the Gemfile — " \
752
+ "any dotenv loader works."
753
+ end
725
754
  end
726
755
  rescue StandardError => e
727
756
  raise Error, e.message if e.is_a?(Error)
728
757
 
729
758
  raise Error,
730
- "the app no longer boots after writing #{Database::MARKER_FILE}: #{e.message}"
759
+ "the app no longer boots after writing .env: #{e.message}"
731
760
  end
732
761
 
733
762
  # Top-level `db: false` opts out of per-worktree databases.
@@ -930,7 +930,7 @@ module Yamine
930
930
  end
931
931
 
932
932
  # Main checkout: the databases ARE the app's own — no suffix, no
933
- # marker, no claim names. Just make sure they exist.
933
+ # .env files, no claim names. Just make sure they exist.
934
934
  def db_create_main(rows)
935
935
  missing = rows.reject { |r| Database.exists?(r["database"], r["url"]) }
936
936
  failed = missing.filter_map do |r|
@@ -29,19 +29,23 @@ module Yamine
29
29
  # credentials apps) no decryption key, which stops Rails before it
30
30
  # can even read database.yml. The single largest follow-up cost of
31
31
  # a new worktree, now carried automatically.
32
- LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets config/master.key].freeze
33
- # Rails 7.1+ per-env credentials: development.key & co are
34
- # gitignored exactly like master.key and equally fatal without.
35
- CREDENTIALS_KEY_GLOB = "config/credentials/*.key"
36
-
37
- # The static list plus whatever key files this checkout has, plus
38
- # the worktree marker: everything yamine writes or copies that
39
- # must never read as uncommitted work.
40
- def local_config_files(dir)
41
- keys = Dir.glob(File.join(dir, CREDENTIALS_KEY_GLOB)).map do |path|
42
- path.sub(%r{\A#{Regexp.escape(dir.chomp("/"))}/?}, "")
43
- end
44
- LOCAL_CONFIG_FILES + keys + [Database::MARKER_FILE]
32
+ LOCAL_CONFIG_FILES = %w[config/local.yml config/local.secrets].freeze
33
+ # Only the credential keys the worktree's own environments need:
34
+ # master (multi-env credentials), development (boots + probe),
35
+ # test (test runs). Production and staging keys have no business
36
+ # sitting in a throwaway worktree directory.
37
+ CREDENTIAL_KEY_FILES = %w[
38
+ config/master.key
39
+ config/credentials/development.key
40
+ config/credentials/test.key
41
+ ].freeze
42
+
43
+ # Everything yamine copies or writes that must never read as
44
+ # uncommitted work: the per-checkout config, the three allowed
45
+ # keys, and the generated environment files.
46
+ def local_config_files(_dir)
47
+ LOCAL_CONFIG_FILES + CREDENTIAL_KEY_FILES + Database::ENV_FILES +
48
+ [Database::LEGACY_MARKER_FILE]
45
49
  end
46
50
 
47
51
  def run(ctx, args)
@@ -198,7 +202,7 @@ module Yamine
198
202
  # app's schema) so server problems surface at creation time, not
199
203
  # at first boot. Rails apps are ASKED what they have (the probe
200
204
  # resolves database.yml + credentials inside the app itself) and
201
- # get the whole suffixed set — claim, marker file, databases,
205
+ # get the whole suffixed set — claim, .env files, databases,
202
206
  # schema. Boot reuses an existing set idempotently, so this is
203
207
  # never wasted work. SQLite and non-Rails apps fall through to
204
208
  # the single-database path, which setup_database narrates.
@@ -32,12 +32,29 @@ module Yamine
32
32
 
33
33
  MAX_IDENTIFIER_BYTES = 63
34
34
  STATE_FILE = "databases.json"
35
- # Written into a worktree at add time; the app's database.yml
36
- # reads it and suffixes every database URL, so hand-run commands
37
- # (console, test, db:migrate) land on the worktree's own
38
- # databases too — env injection only ever reaches processes
39
- # yamine spawns.
40
- MARKER_FILE = ".yamine-db-suffix"
35
+ # The per-worktree environment files yamine writes — ONLY
36
+ # environment-scoped names, never plain `.env`. Production-style
37
+ # tooling reads `.env` by name (kamal, docker --env-file, and
38
+ # dotenv itself loads `.env` in *every* environment), so a
39
+ # worktree's plain `.env` could leak dev database URLs into a
40
+ # production boot that happened to run from this directory.
41
+ # `.env.development` / `.env.test` are invisible to a production
42
+ # boot by construction — dotenv only ever reads
43
+ # `.env.#{Rails.env}` for the current environment.
44
+ #
45
+ # `.env.test` carries PRIMARY_DATABASE_URL — the key Rails checks
46
+ # *before* DATABASE_URL for a flat test config, so the test URL
47
+ # wins no matter which order a loader reads the files in. Hand-run
48
+ # commands (`rails console`, `rails test`, `db:migrate`) pick
49
+ # these up; env injection still covers processes yamine spawns.
50
+ ENV_FILES = %w[.env.development .env.test].freeze
51
+ # 0.16.0's marker file — removed in favor of env files; deleted on
52
+ # sight so upgraded worktrees don't accumulate dead files.
53
+ LEGACY_MARKER_FILE = ".yamine-db-suffix"
54
+ # 0.17.0 wrote plain `.env` too; this header marks it as ours to
55
+ # delete. A `.env` WITHOUT the header belongs to the app or the
56
+ # developer and is never touched.
57
+ LEGACY_ENV_HEADER = "written by yamine"
41
58
 
42
59
  # Database name for a worktree dir + env (development/test).
43
60
  # Main checkout (no worktree marker) keeps the bare name.
@@ -171,28 +188,82 @@ module Yamine
171
188
  name
172
189
  end
173
190
 
174
- def write_marker(dir, suffix)
175
- File.write(File.join(dir, MARKER_FILE), "#{suffix}\n")
191
+ # Write the worktree's environment files (see ENV_FILES for the
192
+ # why of the names). Upsert semantics: our keys are (re)written,
193
+ # every other line in the file survives — a committed env file's
194
+ # non-database config, or a developer's copied-in API keys, must
195
+ # not be destroyed by `yamine db create`. Private mode; the
196
+ # 0.16.0 marker and a yamine-authored 0.17.0 `.env` are removed
197
+ # on the way past.
198
+ def write_env_files(dir, dev_env, test_url: nil)
199
+ FileUtils.rm_f(File.join(dir, LEGACY_MARKER_FILE))
200
+ remove_legacy_worktree_env(dir)
201
+ return if dev_env.nil? && test_url.nil?
202
+
203
+ unless dev_env.nil? || dev_env.empty?
204
+ merge_env_file(File.join(dir, ".env.development"),
205
+ dev_env.reject { |k, _v| k == "PRIMARY_DATABASE_URL" })
206
+ end
207
+ return if test_url.nil? || test_url.to_s.empty?
208
+
209
+ merge_env_file(File.join(dir, ".env.test"),
210
+ { "PRIMARY_DATABASE_URL" => test_url })
211
+ end
212
+
213
+ # 0.17.0 wrote plain `.env` — remove it ONLY when yamine authored
214
+ # it; an app-committed or developer-written `.env` is theirs.
215
+ def remove_legacy_worktree_env(dir)
216
+ path = File.join(dir, ".env")
217
+ return unless File.file?(path)
218
+ return unless File.read(path).include?(LEGACY_ENV_HEADER)
219
+
220
+ FileUtils.rm_f(path)
221
+ end
222
+
223
+ # Upsert our keys into a dotenv file, preserving every other line.
224
+ # Our header is placed once (idempotent across re-runs).
225
+ def merge_env_file(path, env)
226
+ existing = File.file?(path) ? File.read(path).lines : []
227
+ key_res = env.keys.map { |k| /\A\s*#{Regexp.escape(k)}\s*=/ }
228
+ kept = existing.reject { |line| key_res.any? { |re| line.match?(re) } }
229
+ unless kept.any? { |line| line.include?(LEGACY_ENV_HEADER) }
230
+ kept = yamine_env_header.lines + kept
231
+ end
232
+ body = kept.join
233
+ body += "\n" unless body.empty? || body.end_with?("\n")
234
+ body += env.map { |k, v| "#{k}='#{dotenv_escape(v.to_s)}'\n" }.join
235
+ File.write(path, body)
236
+ File.chmod(0o600, path)
176
237
  end
177
238
 
178
- def read_marker(dir)
179
- path = File.join(dir, MARKER_FILE)
180
- File.file?(path) ? File.read(path).strip : nil
239
+ def yamine_env_header
240
+ "# Per-worktree databases, written by yamine (worktree add).\n" \
241
+ "# Environment-scoped (.env.development/.env.test) so a production\n" \
242
+ "# boot never reads these; other keys in this file are preserved.\n"
181
243
  end
182
244
 
183
- # Keep the marker out of `git status` without touching the
184
- # committed .gitignore: git's exclude file lives in the shared
185
- # git dir, so one entry covers every worktree of the repo.
186
- def exclude_marker(dir)
245
+ # dotenv format: single-quoted values (literal — no interpolation
246
+ # of a password's `$` or `#`), with the two escapes the dotenv
247
+ # grammar allows inside them.
248
+ def dotenv_escape(value)
249
+ value.gsub("\\") { "\\\\" }.gsub("'") { "\\'" }
250
+ end
251
+
252
+ # Keep files out of `git status` without touching the committed
253
+ # .gitignore: git's exclude file lives in the shared git dir, so
254
+ # entries cover every worktree of the repo. Idempotent per line.
255
+ def exclude_files(dir, names)
187
256
  out, status = Open3.capture2("git", "-C", dir, "rev-parse", "--git-path", "info/exclude")
188
257
  return unless status.success?
189
258
 
190
259
  path = out.strip
191
260
  path = File.expand_path(path, dir) unless path.start_with?("/")
192
- return if File.file?(path) && File.read(path).lines.any? { |l| l.strip == MARKER_FILE }
193
-
194
261
  FileUtils.mkdir_p(File.dirname(path))
195
- File.open(path, "a") { |f| f.puts MARKER_FILE }
262
+ existing = File.file?(path) ? File.read(path).lines.map(&:strip) : []
263
+ additions = Array(names).reject { |n| existing.include?(n) }
264
+ return if additions.empty?
265
+
266
+ File.open(path, "a") { |f| additions.each { |n| f.puts n } }
196
267
  rescue SystemCallError, IOError
197
268
  nil
198
269
  end
data/lib/yamine/probe.rb CHANGED
@@ -65,7 +65,11 @@ module Yamine
65
65
  url = "#{h[:adapter]}:#{db}"
66
66
  end
67
67
  end
68
- { "name" => c.name, "database" => db, "url" => url }
68
+ { "name" => c.name, "database" => db, "url" => url,
69
+ # What DATABASE_URL held INSIDE the app — the discriminator
70
+ # between "no .env loader ran" (nil) and "loader ran but
71
+ # database.yml's url: keys took precedence over it".
72
+ "env_database_url" => ENV["DATABASE_URL"] }
69
73
  end
70
74
  puts "YAMINE_DBS=#{JSON.generate(rows)}"
71
75
  RUBY
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.16.0"
4
+ VERSION = "0.18.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.16.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto