yamine 0.15.2 → 0.17.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.
@@ -861,18 +861,24 @@ module Yamine
861
861
  puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
862
862
  end
863
863
 
864
- # Per-worktree databases: list/create/drop, plus orphan cleanup.
865
- # `db list` shows every claimed database with its worktree dir.
866
- # `db create` resolves this worktree's name and creates it.
867
- # `db drop <name>` drops one database (refuses without --force
868
- # when its worktree still exists).
864
+ # Per-worktree databases: list/create/drop/describe.
865
+ # `db list` shows every claim and every database under it.
866
+ # `db create` asks the app what it has and provisions it — the
867
+ # suffixed set in a worktree, the base set in the main checkout
868
+ # (self-healing: run it after adding a database to the app).
869
+ # `db drop <name>` drops a claim's whole set — by claim key or
870
+ # by any actual database name (refuses without --force when its
871
+ # worktree still exists).
872
+ # `db describe` asks the app what it has and prints it — the
873
+ # probe behind create, exposed for debugging.
869
874
  def db(ctx, args)
870
875
  sub = args.first
871
876
  case sub
872
877
  when "list", nil then db_list(ctx)
873
878
  when "create" then db_create(ctx)
874
879
  when "drop" then db_drop(ctx, args[1..] || [])
875
- else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
880
+ when "describe" then db_describe(ctx)
881
+ else raise Error, "Usage: yamine db [list|create|drop <name> [--force]|describe]"
876
882
  end
877
883
  end
878
884
 
@@ -880,32 +886,64 @@ module Yamine
880
886
  map = Database.load_map(ctx.store.dir)
881
887
  if map.empty?
882
888
  puts "No worktree databases claimed yet."
883
- puts "Boot an app with a DATABASE_URL template to create one."
889
+ puts "Run `yamine worktree add <branch>` — or `yamine db create` — to claim one."
884
890
  return
885
891
  end
886
892
  map.each do |name, info|
887
893
  alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
888
894
  puts " #{name} -> #{info["dir"]}#{alive}"
895
+ names = info.is_a?(Hash) ? info["names"] : nil
896
+ next unless names.is_a?(Hash) && !names.empty?
897
+
898
+ names.each_value { |db| puts " db: #{db}" }
889
899
  end
890
900
  end
891
901
 
892
902
  def db_create(ctx)
893
- resolved = Resolver.resolve(Dir.pwd)
894
- name = Database.name_for(Dir.pwd, env: boot_env_name,
903
+ claim_key = Database.name_for(Dir.pwd, env: boot_env_name,
895
904
  state_dir: ctx.store.dir)
905
+ rows = Probe.rails_databases(Dir.pwd)
906
+ server = rows && Probe.server_backed(rows)
907
+
908
+ if server && !server.empty?
909
+ if Database.main_checkout?(Dir.pwd)
910
+ db_create_main(server)
911
+ else
912
+ names = BootCommand.provision_multidb(ctx, Dir.pwd, claim_key, server)
913
+ puts "Database set ready (#{names.size}):"
914
+ names.each_value { |db| puts " #{db}" }
915
+ end
916
+ return
917
+ end
918
+
896
919
  template = drop_template
897
920
  unless template && !template.strip.empty?
898
921
  $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml)."
899
922
  exit 1
900
923
  end
901
- if Database.ensure_exists(name, template)
902
- puts "Database #{name} ready."
924
+ if Database.ensure_exists(claim_key, template)
925
+ puts "Database #{claim_key} ready."
903
926
  else
904
- $stderr.puts "Error: could not create #{name}. Check the database server is running."
927
+ $stderr.puts "Error: could not create #{claim_key}. Check the database server is running."
905
928
  exit 1
906
929
  end
907
930
  end
908
931
 
932
+ # Main checkout: the databases ARE the app's own — no suffix, no
933
+ # .env files, no claim names. Just make sure they exist.
934
+ def db_create_main(rows)
935
+ missing = rows.reject { |r| Database.exists?(r["database"], r["url"]) }
936
+ failed = missing.filter_map do |r|
937
+ r["database"] unless Database.ensure_exists(r["database"], r["url"])
938
+ end
939
+ unless failed.empty?
940
+ $stderr.puts "Error: could not create #{failed.join(', ')}. Check the database server."
941
+ exit 1
942
+ end
943
+ puts "Base databases ready (#{rows.size}):"
944
+ rows.each { |r| puts " #{r["database"]}" }
945
+ end
946
+
909
947
  def db_drop(ctx, args)
910
948
  force = args.delete("--force")
911
949
  name = args.first
@@ -913,28 +951,69 @@ module Yamine
913
951
 
914
952
  map = Database.load_map(ctx.store.dir)
915
953
  info = map[name]
916
- if info && File.directory?(info["dir"]) && !force
954
+ unless info
955
+ # A name from inside a claim identifies the claim — the set
956
+ # is the unit; half a suffixed set is orphan territory.
957
+ key, found = map.find { |_k, i| i.is_a?(Hash) && i.dig("names")&.value?(name) }
958
+ name, info = key, found if info.nil? && found
959
+ end
960
+ raise Error, "no claim named #{name.inspect} here — `yamine db list` shows what exists" unless info
961
+
962
+ if File.directory?(info["dir"]) && !force
917
963
  $stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
918
964
  exit 1
919
965
  end
966
+
967
+ pairs = Database.claim_pairs(info) || begin
968
+ template = drop_template
969
+ unless template && !template.strip.empty?
970
+ $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
971
+ exit 1
972
+ end
973
+ [[name, template]]
974
+ end
920
975
  template = drop_template
921
- unless template && !template.strip.empty?
922
- $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in config/local.yml) — cannot reach the server to drop #{name}."
976
+ pairs = pairs.map { |db, tmpl| [db, (tmpl || template)] }
977
+
978
+ failed = false
979
+ pairs.each do |db, tmpl|
980
+ case Database.drop(db, tmpl)
981
+ when :dropped then puts " dropped #{db}"
982
+ when :missing then puts " #{db} did not exist — nothing to drop"
983
+ when :failed then failed = true
984
+ end
985
+ end
986
+ if failed
987
+ $stderr.puts "Error: could not drop part of #{name} — is the database server running? The claim is kept."
923
988
  exit 1
924
989
  end
925
- case Database.drop(name, template)
926
- when :dropped
927
- map.delete(name)
928
- Database.save_map(ctx.store.dir, map)
929
- puts "Dropped #{name}."
930
- when :missing
931
- map.delete(name)
932
- Database.save_map(ctx.store.dir, map)
933
- puts "#{name} did not exist — nothing to drop (claim removed)."
934
- when :failed
935
- $stderr.puts "Error: could not drop #{name} — is the database server running? The claim is kept."
990
+
991
+ map.delete(name)
992
+ Database.save_map(ctx.store.dir, map)
993
+ puts "Dropped #{name}."
994
+ end
995
+
996
+ # The probe exposed: what THIS app, right here, resolves to —
997
+ # database.yml + credentials and all, asked inside the app so no
998
+ # key or parser is needed outside it. Passwords masked; this
999
+ # prints to terminals and CI logs.
1000
+ def db_describe(_ctx)
1001
+ rows = Probe.rails_databases(Dir.pwd)
1002
+ unless rows
1003
+ $stderr.puts "Error: could not ask this app for its databases."
1004
+ $stderr.puts " Is it a Rails app with a working `bin/rails runner`? (probe timed out or exited non-zero)"
936
1005
  exit 1
937
1006
  end
1007
+ rows.each do |r|
1008
+ kind = %i[postgres mysql].include?(Database.adapter_for(r["url"])) ? "server" : "local"
1009
+ puts " #{r["name"]} (#{kind})"
1010
+ puts " database: #{r["database"]}"
1011
+ puts " url: #{mask_url(r["url"])}"
1012
+ end
1013
+ end
1014
+
1015
+ def mask_url(url)
1016
+ url.to_s.sub(%r{\A([a-z0-9+]+://[^:/@]+):[^@]*@}, '\1:***@')
938
1017
  end
939
1018
 
940
1019
  # Template for reaching the database server outside of boot:
@@ -25,9 +25,28 @@ module Yamine
25
25
  module_function
26
26
 
27
27
  # Per-checkout config that git does not carry: a fresh worktree
28
- # boots without these (missing secrets, no proxy config) — the
29
- # single largest follow-up cost of a new worktree.
28
+ # boots without these — missing secrets, no proxy config, and (for
29
+ # credentials apps) no decryption key, which stops Rails before it
30
+ # can even read database.yml. The single largest follow-up cost of
31
+ # a new worktree, now carried automatically.
30
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]
49
+ end
31
50
 
32
51
  def run(ctx, args)
33
52
  sub = args.first
@@ -52,7 +71,10 @@ module Yamine
52
71
  default_branch = Worktrees.default_branch(repo)
53
72
  Worktrees.list(repo).each do |entry|
54
73
  next if entry.bare
55
- puts " #{entry_line(entry, claim_for(map, entry.path), repo, default_branch)}"
74
+
75
+ claim = claim_for(map, entry.path)
76
+ puts " #{entry_line(entry, claim, repo, default_branch)}"
77
+ print_claim_databases(claim)
56
78
  printed = true
57
79
  end
58
80
  end
@@ -66,14 +88,17 @@ module Yamine
66
88
  def entry_line(entry, claim, repo, default_branch)
67
89
  name = entry.main? ? "main" : (entry.branch || "(detached)")
68
90
  line = "#{name} #{entry.path}"
69
- line += " db #{claim[0]}" if claim
91
+ if claim
92
+ names = claim[1].is_a?(Hash) ? claim[1]["names"] : nil
93
+ line += names && !names.empty? ? " db #{names.size} databases" : " db #{claim[0]}"
94
+ end
70
95
 
71
96
  flags = []
72
97
  flags << "main checkout" if entry.main?
73
98
  flags << "detached" if entry.detached && !entry.main?
74
99
  flags << "dir gone" unless entry.exists?
75
100
  if !entry.main? && entry.exists?
76
- flags << "dirty" if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES)
101
+ flags << "dirty" if Worktrees.dirty?(entry.path, ignore: local_config_files(entry.path))
77
102
  if entry.detached
78
103
  flags << "unmerged"
79
104
  else
@@ -85,6 +110,18 @@ module Yamine
85
110
  line
86
111
  end
87
112
 
113
+ # Every database a claim stands for, one per line under the row —
114
+ # five names inline would unreadable, and hiding them would
115
+ # re-create the "which database is this worktree even using"
116
+ # question the claim exists to answer.
117
+ def print_claim_databases(claim)
118
+ info = claim && claim[1]
119
+ names = info.is_a?(Hash) ? info["names"] : nil
120
+ return unless names.is_a?(Hash) && !names.empty?
121
+
122
+ names.each_value { |db| puts " db: #{db}" }
123
+ end
124
+
88
125
  # yamine worktree add <name> [--dir <path>] [--no-install]
89
126
  #
90
127
  # `name` is the branch (feature/login stays feature/login — the
@@ -135,13 +172,16 @@ module Yamine
135
172
 
136
173
  def copy_local_config(from_dir, to_dir)
137
174
  copied = []
138
- LOCAL_CONFIG_FILES.each do |rel|
175
+ local_config_files(from_dir).each do |rel|
139
176
  src = File.join(from_dir, rel)
140
177
  next unless File.file?(src)
141
178
 
142
179
  dst = File.join(to_dir, rel)
143
180
  FileUtils.mkdir_p(File.dirname(dst))
144
181
  FileUtils.cp(src, dst)
182
+ # Decryption keys stay as private in the worktree as they
183
+ # were in the checkout — FileUtils.cp does not carry modes.
184
+ File.chmod(0o600, dst) if rel.end_with?(".key")
145
185
  copied << rel
146
186
  end
147
187
  copied
@@ -158,27 +198,36 @@ module Yamine
158
198
  puts " bundle install did not finish — run it in #{dir} before booting" unless $?.success?
159
199
  end
160
200
 
161
- # Claim the per-worktree database and create it now (with the
201
+ # Claim this worktree's databases and create them now (with the
162
202
  # app's schema) so server problems surface at creation time, not
163
- # at first boot. Boot reuses an existing database idempotently, so
164
- # this is never wasted work. SQLite and template-less apps need
165
- # nothing; setup_database says so itself.
203
+ # at first boot. Rails apps are ASKED what they have (the probe
204
+ # resolves database.yml + credentials inside the app itself) and
205
+ # get the whole suffixed set — claim, .env files, databases,
206
+ # schema. Boot reuses an existing set idempotently, so this is
207
+ # never wasted work. SQLite and non-Rails apps fall through to
208
+ # the single-database path, which setup_database narrates.
166
209
  def prepare_database(ctx, dir)
167
210
  return nil unless Config.load(dir)
168
211
 
169
- db_name = Database.name_for(dir, env: BootCommand.rails_env,
212
+ claim_key = Database.name_for(dir, env: BootCommand.rails_env,
170
213
  state_dir: ctx.store.dir)
171
- Dir.chdir(dir) do
172
- BootCommand.setup_database(ctx, Resolver.resolve(dir), db_name)
214
+ rows = Probe.rails_databases(dir)
215
+ server = rows && Probe.server_backed(rows)
216
+ if server && !server.empty?
217
+ BootCommand.provision_multidb(ctx, dir, claim_key, server)
218
+ else
219
+ Dir.chdir(dir) do
220
+ BootCommand.setup_database(ctx, Resolver.resolve(dir), claim_key)
221
+ end
173
222
  end
174
- db_name
223
+ claim_key
175
224
  end
176
225
 
177
- def say_ready(ctx, dir, name, db_name)
226
+ def say_ready(ctx, dir, name, db_key)
178
227
  puts "Worktree ready:"
179
228
  puts " dir #{dir}"
180
229
  puts " branch #{name}"
181
- puts " db #{db_name}" if db_name
230
+ print_ready_databases(ctx, db_key)
182
231
  if (resolved = resolved_quietly(dir))
183
232
  Resolver.urls(resolved, port: ctx.proxy_port, tls: ctx.proxy_tls)
184
233
  .each { |u| puts " url #{u}" }
@@ -186,6 +235,18 @@ module Yamine
186
235
  puts " boot it: cd #{dir} && yamine start"
187
236
  end
188
237
 
238
+ def print_ready_databases(ctx, db_key)
239
+ return unless db_key
240
+
241
+ names = Database.load_map(ctx.store.dir).dig(db_key, "names")
242
+ if names.is_a?(Hash) && !names.empty?
243
+ puts " db #{names.size} databases:"
244
+ names.each_value { |db| puts " #{db}" }
245
+ else
246
+ puts " db #{db_key}"
247
+ end
248
+ end
249
+
189
250
  def resolved_quietly(dir)
190
251
  Resolver.resolve(dir)
191
252
  rescue StandardError
@@ -214,7 +275,7 @@ module Yamine
214
275
  return
215
276
  end
216
277
 
217
- if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES) && !opts[:force]
278
+ if Worktrees.dirty?(entry.path, ignore: local_config_files(entry.path)) && !opts[:force]
218
279
  $stderr.puts "Error: #{entry.path} has uncommitted changes."
219
280
  $stderr.puts " Commit or stash them, or re-run with --force to discard them."
220
281
  exit 1
@@ -309,7 +370,7 @@ module Yamine
309
370
  plan << Plan.new(:prune, entry)
310
371
  next
311
372
  end
312
- if Worktrees.dirty?(entry.path, ignore: LOCAL_CONFIG_FILES)
373
+ if Worktrees.dirty?(entry.path, ignore: local_config_files(entry.path))
313
374
  plan << Plan.new(:keep, entry.path, "uncommitted changes are never cleaned automatically")
314
375
  next
315
376
  end
@@ -328,29 +389,55 @@ module Yamine
328
389
  end
329
390
 
330
391
  # An admin entry whose directory vanished (rm -rf, a crashed
331
- # agent): drop the database it claimed, forget the claim. The git
332
- # entry itself is cleared by the trailing prune. A template with
333
- # no server behind it (sqlite) means there is nothing to drop and
334
- # no retry that could help — the claim is simply forgotten.
392
+ # agent): drop every database it claimed, forget the claim. The
393
+ # git entry itself is cleared by the trailing prune. The claim
394
+ # itself carries the names AND the server coordinates — an
395
+ # orphan's config is unreadable by definition, and the app must
396
+ # not have to boot (or even still bundle) for cleanup to work.
397
+ # A claim with no server behind it (sqlite) means there is
398
+ # nothing to drop and no retry that could help — the claim is
399
+ # simply forgotten. A LEGACY claim with no reachable template
400
+ # anywhere (credentials apps pre-multi-database never declared
401
+ # one) is also forgotten, with the database name said out loud:
402
+ # erroring forever would leave `clean` permanently red for a
403
+ # claim no future command can ever resolve.
335
404
  def drop_orphan(ctx, name, _dir)
405
+ info = Database.load_map(ctx.store.dir)[name]
406
+ pairs = Database.claim_pairs(info)
336
407
  template = orphan_template(ctx)
337
- unless template
338
- $stderr.puts "Error: no DATABASE_URL template found (ENV or env.clear in " \
339
- "config/local.yml) — cannot drop #{name}."
340
- return false
408
+
409
+ if pairs.nil?
410
+ unless template
411
+ warn " no DATABASE_URL template found (ENV or env.clear in config/local.yml) — " \
412
+ "cannot reach #{name}; claim forgotten."
413
+ warn " If a database named #{name} still exists on a server, drop it manually."
414
+ forget_claim(ctx, name)
415
+ return true
416
+ end
417
+ pairs = [[name, template]]
418
+ else
419
+ pairs = pairs.map { |db, tmpl| [db, (tmpl || template)] }
341
420
  end
342
- unless %i[postgres mysql].include?(Database.adapter_for(template))
421
+
422
+ first_url = pairs.first&.[](1)
423
+ unless %i[postgres mysql].include?(first_url && Database.adapter_for(first_url))
343
424
  puts " #{name} has no server database (sqlite/unknown adapter) — claim forgotten"
344
425
  forget_claim(ctx, name)
345
426
  return true
346
427
  end
347
- case Database.drop(name, template)
348
- when :dropped then puts " dropped #{name}"
349
- when :missing then puts " #{name} did not exist — nothing to drop"
350
- when :failed
351
- warn " could not drop #{name} — is the server running? Claim kept for a retry."
352
- return false
428
+
429
+ failed = false
430
+ pairs.each do |db, tmpl|
431
+ case Database.drop(db, tmpl)
432
+ when :dropped then puts " dropped #{db}"
433
+ when :missing then puts " #{db} did not exist — nothing to drop"
434
+ when :failed
435
+ warn " could not drop #{db} — is the server running? Claim kept for a retry."
436
+ failed = true
437
+ end
353
438
  end
439
+ return false if failed
440
+
354
441
  forget_claim(ctx, name)
355
442
  true
356
443
  end
@@ -419,7 +506,7 @@ module Yamine
419
506
  stop_routes(ctx, dir)
420
507
 
421
508
  if claim
422
- outcome = drop_database(repo, dir, claim[0])
509
+ outcome = drop_database(repo, dir, claim[0], claim[1])
423
510
  if outcome == :failed
424
511
  warn " kept #{dir} — database drop failed; fix the server and re-run"
425
512
  return false
@@ -476,22 +563,35 @@ module Yamine
476
563
  end
477
564
  end
478
565
 
479
- def drop_database(repo, dir, name)
480
- template = drop_template_for(repo, dir)
481
- adapter = template && Database.adapter_for(template)
482
- unless %i[postgres mysql].include?(adapter)
566
+ # Drop every database a claim stands for: the whole suffixed set
567
+ # for a multi claim, the one key-named database for a legacy
568
+ # single claim. Any :failed aborts the whole teardown (kept for
569
+ # a retry) — a half-dropped set with a removed worktree is
570
+ # orphan territory, and orphans are the bug this guards.
571
+ def drop_database(repo, dir, name, info = nil)
572
+ fallback = drop_template_for(repo, dir)
573
+ pairs = Database.claim_pairs(info) || [[name, fallback]]
574
+ pairs = pairs.map { |db, tmpl| [db, (tmpl || fallback)] }
575
+
576
+ first_url = pairs.first&.[](1)
577
+ unless %i[postgres mysql].include?(first_url && Database.adapter_for(first_url))
483
578
  puts " no server database to drop for #{name} " \
484
- "(#{template ? "sqlite/unknown adapter" : "no DATABASE_URL template"})"
579
+ "(#{first_url ? "sqlite/unknown adapter" : "no DATABASE_URL template"})"
485
580
  return :missing
486
581
  end
487
582
 
488
- outcome = Database.drop(name, template)
489
- case outcome
490
- when :dropped then puts " dropped #{name}"
491
- when :missing then puts " #{name} did not exist — nothing to drop"
492
- when :failed then puts " could not drop #{name} — is the database server running?"
583
+ outcomes = pairs.map do |db, tmpl|
584
+ outcome = Database.drop(db, tmpl)
585
+ case outcome
586
+ when :dropped then puts " dropped #{db}"
587
+ when :missing then puts " #{db} did not exist — nothing to drop"
588
+ when :failed then puts " could not drop #{db} — is the database server running?"
589
+ end
590
+ outcome
493
591
  end
494
- outcome
592
+ return :failed if outcomes.include?(:failed)
593
+
594
+ outcomes.include?(:dropped) ? :dropped : :missing
495
595
  end
496
596
 
497
597
  # Reach the database server from the worktree's own config (read
data/lib/yamine/cli.rb CHANGED
@@ -94,7 +94,7 @@ module Yamine
94
94
  yamine trust Add local CA to trust store
95
95
  yamine clean Remove state and hosts entries
96
96
  yamine prune Remove stale routes
97
- yamine db list|create|drop Per-worktree databases
97
+ yamine db list|create|drop|describe Per-worktree databases (multi-database aware)
98
98
  yamine worktree list|add|remove|clean Worktrees: create, inspect, full cleanup
99
99
  yamine skills install Install yamine skill into ~/.agents/skills/ (or --local)
100
100
  yamine proxy start|stop Control the proxy