yamine 0.4.0 → 0.5.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 +19 -0
- data/lib/yamine/cli/boot.rb +76 -4
- data/lib/yamine/cli/system.rb +128 -0
- data/lib/yamine/cli.rb +5 -1
- data/lib/yamine/database.rb +193 -0
- data/lib/yamine/runner.rb +8 -3
- data/lib/yamine/version.rb +1 -1
- data/lib/yamine.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c28579a9f53f3078412006a6f697d366cd7f0b2f897e7cf52025b742b2d5ba2
|
|
4
|
+
data.tar.gz: e7c826ffece34cf08ac88fcb31d1613418a9f3f5713badb08f6725937d906409
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e1a77d9dad1424f4cdbd0fa351224f626a858e986c6300063a70650dc7c02007f8ea8cbb83e2658a5f06e1a3ff8ba091986ce1480c28e510d9a096c287cc596d
|
|
7
|
+
data.tar.gz: 9194bc35c52acdb669a177bb758af0539c2983056c8e7e4dbd700d937ea850a2947708fef0315d8e2d6281b33850bb96a2de4f26abb0878a2b1b8d3db91cd777
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] — Unreleased
|
|
4
|
+
|
|
5
|
+
Per-worktree databases: every worktree directory gets its own database
|
|
6
|
+
so concurrent agents never share tables.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `Yamine::Database`: `<sanitized-dir-basename>_<env>` naming
|
|
11
|
+
(`/code/myapp-fix` → `myapp_fix_development`; main keeps the bare
|
|
12
|
+
name), 63-byte Postgres cap with hash-suffix truncation, collision
|
|
13
|
+
guard via state map (silent sharing would be the bug this kills).
|
|
14
|
+
- Boot resolves the DB once, creates it (`createdb`/`mysqladmin`,
|
|
15
|
+
never raises — warns and shares template on failure), runs the app's
|
|
16
|
+
schema-load (`db.schema_load` in config, Rails default, else nothing),
|
|
17
|
+
and injects `DATABASE_URL` into every process. SQLite left alone.
|
|
18
|
+
- `yamine db list|create|drop` and `yamine worktree list|clean`
|
|
19
|
+
(orphan reaping for deleted worktrees). Branch plays no part —
|
|
20
|
+
directories are stable, branches hop.
|
|
21
|
+
|
|
3
22
|
## [0.3.0] — Unreleased
|
|
4
23
|
|
|
5
24
|
Renamed from `ask-local` to **`yamine`** (يمين, "right hand" — the local
|
data/lib/yamine/cli/boot.rb
CHANGED
|
@@ -62,10 +62,21 @@ module Yamine
|
|
|
62
62
|
puts "yamine (#{service})"
|
|
63
63
|
puts "--"
|
|
64
64
|
|
|
65
|
+
# Per-worktree database: one database per directory so concurrent
|
|
66
|
+
# agents never share tables. Resolved once, injected into every
|
|
67
|
+
# process as DATABASE_URL, created on demand with the app's own
|
|
68
|
+
# schema-load command. SQLite needs nothing (relative paths
|
|
69
|
+
# already isolate); the branch plays no part (dirs are stable,
|
|
70
|
+
# branches hop).
|
|
71
|
+
db_name = Database.name_for(Dir.pwd, env: rails_env,
|
|
72
|
+
state_dir: ctx.store.dir)
|
|
73
|
+
db_url = setup_database(ctx, resolved, db_name)
|
|
74
|
+
puts " [db] #{db_name}" if db_url
|
|
75
|
+
|
|
65
76
|
processes.each do |proc_name, entry|
|
|
66
77
|
hostname = Resolver.hostname_for(resolved, proc_name)
|
|
67
78
|
if entry["proxy"] == false
|
|
68
|
-
boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
|
|
79
|
+
boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
|
|
69
80
|
next
|
|
70
81
|
end
|
|
71
82
|
next unless hostname
|
|
@@ -87,9 +98,11 @@ module Yamine
|
|
|
87
98
|
shell_cmd = ["sh", "-c", cmd]
|
|
88
99
|
# Always allow the proxied hostname in Rails dev (Rails ignores
|
|
89
100
|
# this env var when not a Rails app — safe for every framework).
|
|
101
|
+
# DATABASE_URL points at this worktree's own database; non-Ruby
|
|
102
|
+
# frameworks that honor it get isolation for free, others ignore it.
|
|
90
103
|
app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
|
|
91
104
|
dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
|
|
92
|
-
rails_dev_host: hostname)
|
|
105
|
+
rails_dev_host: hostname, database_url: db_url)
|
|
93
106
|
register_all(ctx, hostnames, app, force: opts[:force],
|
|
94
107
|
spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
|
|
95
108
|
routes_registered << { hostnames: hostnames, app: app }
|
|
@@ -114,7 +127,7 @@ module Yamine
|
|
|
114
127
|
|
|
115
128
|
# A background process (proxy: false) is spawned, logged, and
|
|
116
129
|
# supervised exactly like an HTTP one — it just gets no route.
|
|
117
|
-
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
|
|
130
|
+
def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
|
|
118
131
|
cmd = entry["cmd"].to_s
|
|
119
132
|
if cmd.match?(Yamine::Procfile::COMPOUND)
|
|
120
133
|
$stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
|
|
@@ -124,7 +137,8 @@ module Yamine
|
|
|
124
137
|
url = "background://#{resolved.app}/#{proc_name}"
|
|
125
138
|
app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
|
|
126
139
|
url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
|
|
127
|
-
force: opts[:force], rails_dev_host: nil, register: false
|
|
140
|
+
force: opts[:force], rails_dev_host: nil, register: false,
|
|
141
|
+
database_url: db_url)
|
|
128
142
|
children << { name: proc_name, pid: app.pid }
|
|
129
143
|
puts " [#{proc_name}] background (pid #{app.pid})"
|
|
130
144
|
end
|
|
@@ -166,6 +180,64 @@ module Yamine
|
|
|
166
180
|
env
|
|
167
181
|
end
|
|
168
182
|
|
|
183
|
+
# Rails env for database naming: RAILS_ENV when set, else development.
|
|
184
|
+
# Non-Rails apps ignore it (their DATABASE_URL template decides).
|
|
185
|
+
def rails_env
|
|
186
|
+
ENV.fetch("RAILS_ENV", "development")
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Resolve this worktree's database, create it if missing, and run
|
|
190
|
+
# the app's schema-load command on first creation. Returns the
|
|
191
|
+
# DATABASE_URL for injection, or nil when there's nothing to do
|
|
192
|
+
# (no template URL, sqlite, or the app opted out via db: false).
|
|
193
|
+
def setup_database(ctx, resolved, db_name)
|
|
194
|
+
return nil if resolved.processes["db"] == false
|
|
195
|
+
|
|
196
|
+
template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
|
|
197
|
+
return nil if template.nil? || template.strip.empty?
|
|
198
|
+
|
|
199
|
+
url = Database.url_for(db_name, template)
|
|
200
|
+
return nil unless url
|
|
201
|
+
|
|
202
|
+
if Database.ensure_exists(db_name, template)
|
|
203
|
+
puts " [db] created #{db_name}" unless database_exists?(db_name, template)
|
|
204
|
+
run_schema_load(resolved, url)
|
|
205
|
+
else
|
|
206
|
+
$stderr.puts " [db] WARNING: could not create #{db_name} — processes share the template database."
|
|
207
|
+
return nil
|
|
208
|
+
end
|
|
209
|
+
url
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def database_exists?(name, template)
|
|
213
|
+
# ensure_exists is idempotent; this second call is cheap (psql -lqt)
|
|
214
|
+
# and tells us whether to print "created" vs nothing.
|
|
215
|
+
Database.ensure_exists(name, template)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def database_template_from_config(resolved)
|
|
219
|
+
env = resolved.processes.values.map { |e| e["env"] || {} }
|
|
220
|
+
clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
|
|
221
|
+
clear["DATABASE_URL"]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Schema-load on first boot uses the app's own command when declared
|
|
225
|
+
# (db.schema_load in config/local.yml), else a Rails default when a
|
|
226
|
+
# Rails app is detected, else nothing (frameworks without a
|
|
227
|
+
# schema concept need no step).
|
|
228
|
+
def run_schema_load(resolved, url)
|
|
229
|
+
cmd = resolved.processes.dig("db", "schema_load") ||
|
|
230
|
+
("bin/rails db:schema:load" if rails_app?)
|
|
231
|
+
return unless cmd
|
|
232
|
+
|
|
233
|
+
system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
|
|
234
|
+
"sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def rails_app?
|
|
238
|
+
File.file?(File.join(Dir.pwd, "config", "application.rb"))
|
|
239
|
+
end
|
|
240
|
+
|
|
169
241
|
def supervise_tree(ctx, hostnames, pids)
|
|
170
242
|
loop do
|
|
171
243
|
sleep 0.5
|
data/lib/yamine/cli/system.rb
CHANGED
|
@@ -763,6 +763,134 @@ module Yamine
|
|
|
763
763
|
puts " - #{app}-#{slug}.#{domain}"
|
|
764
764
|
puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
|
|
765
765
|
end
|
|
766
|
+
|
|
767
|
+
# Per-worktree databases: list/create/drop, plus orphan cleanup.
|
|
768
|
+
# `db list` shows every claimed database with its worktree dir.
|
|
769
|
+
# `db create` resolves this worktree's name and creates it.
|
|
770
|
+
# `db drop <name>` drops one database (refuses without --force
|
|
771
|
+
# when its worktree still exists).
|
|
772
|
+
def db(ctx, args)
|
|
773
|
+
sub = args.first
|
|
774
|
+
case sub
|
|
775
|
+
when "list", nil then db_list(ctx)
|
|
776
|
+
when "create" then db_create(ctx)
|
|
777
|
+
when "drop" then db_drop(ctx, args[1..] || [])
|
|
778
|
+
else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def db_list(ctx)
|
|
783
|
+
map = Database.load_map(ctx.store.dir)
|
|
784
|
+
if map.empty?
|
|
785
|
+
puts "No worktree databases claimed yet."
|
|
786
|
+
puts "Boot an app with a DATABASE_URL template to create one."
|
|
787
|
+
return
|
|
788
|
+
end
|
|
789
|
+
map.each do |name, info|
|
|
790
|
+
alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
|
|
791
|
+
puts " #{name} -> #{info["dir"]}#{alive}"
|
|
792
|
+
end
|
|
793
|
+
end
|
|
794
|
+
|
|
795
|
+
def db_create(ctx)
|
|
796
|
+
resolved = Resolver.resolve(Dir.pwd)
|
|
797
|
+
name = Database.name_for(Dir.pwd, env: boot_env_name,
|
|
798
|
+
state_dir: ctx.store.dir)
|
|
799
|
+
template = ENV["DATABASE_URL"]
|
|
800
|
+
unless template && !template.strip.empty?
|
|
801
|
+
$stderr.puts "Error: set DATABASE_URL (template with host/user; database segment is replaced)."
|
|
802
|
+
exit 1
|
|
803
|
+
end
|
|
804
|
+
if Database.ensure_exists(name, template)
|
|
805
|
+
puts "Database #{name} ready."
|
|
806
|
+
else
|
|
807
|
+
$stderr.puts "Error: could not create #{name}. Check the database server is running."
|
|
808
|
+
exit 1
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
def db_drop(ctx, args)
|
|
813
|
+
force = args.delete("--force")
|
|
814
|
+
name = args.first
|
|
815
|
+
raise Error, "Usage: yamine db drop <name> [--force]" unless name
|
|
816
|
+
|
|
817
|
+
map = Database.load_map(ctx.store.dir)
|
|
818
|
+
info = map[name]
|
|
819
|
+
if info && File.directory?(info["dir"]) && !force
|
|
820
|
+
$stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
|
|
821
|
+
exit 1
|
|
822
|
+
end
|
|
823
|
+
if drop_database(ctx, name)
|
|
824
|
+
map.delete(name)
|
|
825
|
+
Database.save_map(ctx.store.dir, map)
|
|
826
|
+
puts "Dropped #{name}."
|
|
827
|
+
else
|
|
828
|
+
$stderr.puts "Error: could not drop #{name}."
|
|
829
|
+
exit 1
|
|
830
|
+
end
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def drop_database(_ctx, name)
|
|
834
|
+
template = ENV["DATABASE_URL"]
|
|
835
|
+
return false unless template && !template.strip.empty?
|
|
836
|
+
|
|
837
|
+
case Database.adapter_for(template)
|
|
838
|
+
when :postgres
|
|
839
|
+
uri = URI.parse(template)
|
|
840
|
+
_out, status = Open3.capture2("dropdb", name, env: Database.pg_env(uri))
|
|
841
|
+
status.success?
|
|
842
|
+
when :mysql
|
|
843
|
+
uri = URI.parse(template)
|
|
844
|
+
args = ["-h", uri.host || "127.0.0.1", "-u",
|
|
845
|
+
URI.decode_www_form_component(uri.user || "root")]
|
|
846
|
+
_out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
|
|
847
|
+
status.success?
|
|
848
|
+
else
|
|
849
|
+
false
|
|
850
|
+
end
|
|
851
|
+
rescue SystemCallError
|
|
852
|
+
false
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def boot_env_name
|
|
856
|
+
ENV.fetch("RAILS_ENV", "development")
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Worktree helpers: list claimed databases with liveness, clean
|
|
860
|
+
# drops databases whose worktree dirs are gone.
|
|
861
|
+
def worktree(ctx, args)
|
|
862
|
+
sub = args.first
|
|
863
|
+
case sub
|
|
864
|
+
when "list", nil
|
|
865
|
+
map = Database.load_map(ctx.store.dir)
|
|
866
|
+
if map.empty?
|
|
867
|
+
puts "No worktree databases claimed yet."
|
|
868
|
+
return
|
|
869
|
+
end
|
|
870
|
+
map.each do |name, info|
|
|
871
|
+
alive = File.directory?(info["dir"]) ? "alive" : "gone"
|
|
872
|
+
puts " #{name} #{info["dir"]} (#{alive})"
|
|
873
|
+
end
|
|
874
|
+
when "clean"
|
|
875
|
+
orphaned = Database.orphaned(ctx.store.dir)
|
|
876
|
+
if orphaned.empty?
|
|
877
|
+
puts "No orphaned worktree databases."
|
|
878
|
+
return
|
|
879
|
+
end
|
|
880
|
+
orphaned.each do |name, _info|
|
|
881
|
+
if drop_database(ctx, name)
|
|
882
|
+
puts "Dropped orphaned #{name}."
|
|
883
|
+
else
|
|
884
|
+
$stderr.puts "Could not drop #{name} — remove manually."
|
|
885
|
+
end
|
|
886
|
+
end
|
|
887
|
+
map = Database.load_map(ctx.store.dir)
|
|
888
|
+
orphaned.each_key { |name| map.delete(name) }
|
|
889
|
+
Database.save_map(ctx.store.dir, map)
|
|
890
|
+
else
|
|
891
|
+
raise Error, "Usage: yamine worktree [list|clean]"
|
|
892
|
+
end
|
|
893
|
+
end
|
|
766
894
|
end
|
|
767
895
|
end
|
|
768
896
|
end
|
data/lib/yamine/cli.rb
CHANGED
|
@@ -10,7 +10,7 @@ module Yamine
|
|
|
10
10
|
# In non-interactive environments (no TTY or CI=1) we fail early with
|
|
11
11
|
# a clear message instead of prompting (portless lesson).
|
|
12
12
|
class CLI
|
|
13
|
-
SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
|
|
13
|
+
SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init db worktree].freeze
|
|
14
14
|
|
|
15
15
|
def self.run(argv)
|
|
16
16
|
new.run(argv)
|
|
@@ -47,6 +47,8 @@ module Yamine
|
|
|
47
47
|
when "setup" then SystemCommand.setup(ctx, args)
|
|
48
48
|
when "init" then SystemCommand.init(ctx, args)
|
|
49
49
|
when "start" then SystemCommand.start(ctx, args)
|
|
50
|
+
when "db" then SystemCommand.db(ctx, args)
|
|
51
|
+
when "worktree" then SystemCommand.worktree(ctx, args)
|
|
50
52
|
when "kamal" then SystemCommand.kamal(ctx, args)
|
|
51
53
|
when "stop"
|
|
52
54
|
exit RoutesCommand.stop(ctx, args)
|
|
@@ -81,6 +83,8 @@ module Yamine
|
|
|
81
83
|
yamine trust Add local CA to trust store
|
|
82
84
|
yamine clean Remove state and hosts entries
|
|
83
85
|
yamine prune Remove stale routes
|
|
86
|
+
yamine db list|create|drop Per-worktree databases
|
|
87
|
+
yamine worktree list|clean Worktree databases + orphan cleanup
|
|
84
88
|
yamine proxy start|stop Control the proxy
|
|
85
89
|
yamine service install|status|uninstall OS startup service
|
|
86
90
|
yamine sudoers Print scoped passwordless-sudo rules for port 443
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "open3"
|
|
7
|
+
|
|
8
|
+
module Yamine
|
|
9
|
+
# Per-worktree databases. Every worktree directory gets its own database
|
|
10
|
+
# so concurrent agents never share tables: no migration races, no seed
|
|
11
|
+
# pollution, no schema drift between branches.
|
|
12
|
+
#
|
|
13
|
+
# Naming: <sanitized-dir-basename>_<env> — e.g. /Users/kaka/code/myapp-fix
|
|
14
|
+
# becomes myapp_fix_development. The branch a worktree has checked out is
|
|
15
|
+
# irrelevant and mutable, so it plays no part; the directory is stable.
|
|
16
|
+
# The main checkout keeps the bare <basename>_<env> name. Postgres
|
|
17
|
+
# identifiers cap at 63 bytes; overlong names truncate with a hash
|
|
18
|
+
# suffix (same rule as hostname labels).
|
|
19
|
+
#
|
|
20
|
+
# Collision guard: two directories can sanitize identically
|
|
21
|
+
# (myapp-fix vs myapp_fix). A state file records which directory
|
|
22
|
+
# claimed which name first; on collision the newcomer gets a short
|
|
23
|
+
# hash suffix. Silent sharing would be exactly the bug this exists to
|
|
24
|
+
# kill, so the check is mandatory, not best-effort.
|
|
25
|
+
#
|
|
26
|
+
# Framework-agnostic: yamine injects DATABASE_URL and never touches
|
|
27
|
+
# framework files. SQLite needs nothing (relative paths already isolate
|
|
28
|
+
# per worktree). Creation runs the app's own schema-load command from
|
|
29
|
+
# config/local.yml — never assumed.
|
|
30
|
+
module Database
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
MAX_IDENTIFIER_BYTES = 63
|
|
34
|
+
STATE_FILE = "databases.json"
|
|
35
|
+
|
|
36
|
+
# Database name for a worktree dir + env (development/test).
|
|
37
|
+
# Main checkout (no worktree marker) keeps the bare name.
|
|
38
|
+
def name_for(dir, env: "development", state_dir: nil)
|
|
39
|
+
base = sanitize(File.basename(File.expand_path(dir)))
|
|
40
|
+
name = "#{base}_#{env}"
|
|
41
|
+
name = truncate(name)
|
|
42
|
+
claimed = claim(name, dir, state_dir: state_dir)
|
|
43
|
+
claimed || "#{truncate("#{base}_#{short_hash(dir)}")}_#{env}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The main checkout is the one whose .git is a directory (linked
|
|
47
|
+
# worktrees have a .git FILE pointing into /worktrees/). Main keeps
|
|
48
|
+
# the bare name; only linked worktrees get suffixed names... in
|
|
49
|
+
# practice every dir gets the mapping, but main's mapping is itself.
|
|
50
|
+
def main_checkout?(dir)
|
|
51
|
+
File.directory?(File.join(File.expand_path(dir), ".git"))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def sanitize(name)
|
|
55
|
+
name.downcase.gsub(/[^a-z0-9_]/, "_").gsub(/_+/, "_").gsub(/\A_+|_+\z/, "")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def truncate(name)
|
|
59
|
+
return name if name.bytesize <= MAX_IDENTIFIER_BYTES
|
|
60
|
+
|
|
61
|
+
hash = Digest::SHA256.hexdigest(name)[0, 6]
|
|
62
|
+
prefix = name.byteslice(0, MAX_IDENTIFIER_BYTES - 7).gsub(/_+\z/, "")
|
|
63
|
+
"#{prefix}_#{hash}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def short_hash(dir)
|
|
67
|
+
Digest::SHA256.hexdigest(File.expand_path(dir))[0, 6]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Record dir -> name claim; returns the name if ours (or unclaimed),
|
|
71
|
+
# nil if another dir claimed it first (caller must suffix).
|
|
72
|
+
def claim(name, dir, state_dir: nil)
|
|
73
|
+
return name unless state_dir
|
|
74
|
+
|
|
75
|
+
dir = File.expand_path(dir)
|
|
76
|
+
map = load_map(state_dir)
|
|
77
|
+
if map.key?(name) && map[name]["dir"] != dir
|
|
78
|
+
return nil
|
|
79
|
+
end
|
|
80
|
+
map[name] = { "dir" => dir, "claimed_at" => Time.now.utc.iso8601 }
|
|
81
|
+
save_map(state_dir, map)
|
|
82
|
+
name
|
|
83
|
+
rescue SystemCallError
|
|
84
|
+
name
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def load_map(state_dir)
|
|
88
|
+
path = File.join(state_dir, STATE_FILE)
|
|
89
|
+
return {} unless File.file?(path)
|
|
90
|
+
|
|
91
|
+
JSON.parse(File.read(path))
|
|
92
|
+
rescue JSON::ParserError, SystemCallError
|
|
93
|
+
{}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def save_map(state_dir, map)
|
|
97
|
+
FileUtils.mkdir_p(state_dir)
|
|
98
|
+
File.write(File.join(state_dir, STATE_FILE), JSON.pretty_generate(map))
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Databases whose worktree dirs no longer exist (for `worktree clean`).
|
|
104
|
+
def orphaned(state_dir)
|
|
105
|
+
load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Adapter detection from DATABASE_URL scheme. Returns :postgres,
|
|
109
|
+
# :mysql, :sqlite, or :unknown. Only postgres/mysql get create +
|
|
110
|
+
# DATABASE_URL treatment; sqlite is left alone (relative paths
|
|
111
|
+
# already isolate per worktree).
|
|
112
|
+
def adapter_for(database_url)
|
|
113
|
+
str = database_url.to_s.strip
|
|
114
|
+
return :sqlite if str.empty?
|
|
115
|
+
|
|
116
|
+
scheme = str[/\A[a-z0-9+]+/i].to_s.downcase
|
|
117
|
+
case scheme
|
|
118
|
+
when "postgres", "postgresql" then :postgres
|
|
119
|
+
when "mysql", "mysql2" then :mysql
|
|
120
|
+
when "sqlite", "sqlite3" then :sqlite
|
|
121
|
+
else :unknown
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Build a DATABASE_URL for name from a template URL (host, port, user
|
|
126
|
+
# preserved; database segment replaced). Returns nil when the template
|
|
127
|
+
# is absent or sqlite.
|
|
128
|
+
def url_for(name, template_url)
|
|
129
|
+
return nil if template_url.nil? || template_url.strip.empty?
|
|
130
|
+
return nil if adapter_for(template_url) == :sqlite
|
|
131
|
+
|
|
132
|
+
uri = URI.parse(template_url)
|
|
133
|
+
uri.path = "/#{name}"
|
|
134
|
+
uri.to_s
|
|
135
|
+
rescue URI::InvalidURIError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Create the database if missing. Uses createdb/mysqladmin when
|
|
140
|
+
# available; returns true/false, never raises (boot reports, not dies).
|
|
141
|
+
def ensure_exists(name, database_url)
|
|
142
|
+
case adapter_for(database_url)
|
|
143
|
+
when :postgres then ensure_postgres(name, database_url)
|
|
144
|
+
when :mysql then ensure_mysql(name, database_url)
|
|
145
|
+
else false
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def ensure_postgres(name, database_url)
|
|
150
|
+
uri = URI.parse(database_url)
|
|
151
|
+
env = pg_env(uri)
|
|
152
|
+
_out, status = Open3.capture2(env, "psql", "-lqt")
|
|
153
|
+
return false unless status.success?
|
|
154
|
+
|
|
155
|
+
exists = Open3.capture2(env, "psql", "-lqt")[0].split("\n").any? do |line|
|
|
156
|
+
line.split("|").first.to_s.strip == name
|
|
157
|
+
end
|
|
158
|
+
return true if exists
|
|
159
|
+
|
|
160
|
+
_out, status = Open3.capture2(env, "createdb", name)
|
|
161
|
+
status.success?
|
|
162
|
+
rescue SystemCallError
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def ensure_mysql(name, database_url)
|
|
167
|
+
uri = URI.parse(database_url)
|
|
168
|
+
args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
|
|
169
|
+
"-u", URI.decode_www_form_component(uri.user || "root")]
|
|
170
|
+
args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
|
|
171
|
+
_out, status = Open3.capture2("mysqladmin", *args, "create", name)
|
|
172
|
+
status.success? || already_exists_mysql(args, name)
|
|
173
|
+
rescue SystemCallError
|
|
174
|
+
false
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def already_exists_mysql(args, name)
|
|
178
|
+
out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
|
|
179
|
+
status.success? && out.split("\n").include?(name)
|
|
180
|
+
rescue SystemCallError
|
|
181
|
+
false
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def pg_env(uri)
|
|
185
|
+
{
|
|
186
|
+
"PGHOST" => uri.host || "127.0.0.1",
|
|
187
|
+
"PGPORT" => (uri.port || 5432).to_s,
|
|
188
|
+
"PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
|
|
189
|
+
"PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
|
|
190
|
+
}.compact
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
data/lib/yamine/runner.rb
CHANGED
|
@@ -92,9 +92,10 @@ module Yamine
|
|
|
92
92
|
# Run an arbitrary command with PORT + YAMINE_URL. Returns App.
|
|
93
93
|
# register: false spawns without a route (background processes).
|
|
94
94
|
def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
|
|
95
|
-
rails_dev_host: nil, register: true)
|
|
95
|
+
rails_dev_host: nil, register: true, database_url: nil)
|
|
96
96
|
port ||= Ports.find_free
|
|
97
|
-
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host
|
|
97
|
+
env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
|
|
98
|
+
database_url: database_url)
|
|
98
99
|
pid = with_clean_env { spawn(env, *command, chdir: dir) }
|
|
99
100
|
Process.detach(pid)
|
|
100
101
|
target = "127.0.0.1:#{port}"
|
|
@@ -106,7 +107,7 @@ module Yamine
|
|
|
106
107
|
target: target, kind: "tcp", command: command)
|
|
107
108
|
end
|
|
108
109
|
|
|
109
|
-
def child_env(dir, url:, port:, rails_dev_host: nil)
|
|
110
|
+
def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
|
|
110
111
|
env = { "YAMINE_URL" => url }
|
|
111
112
|
env["PORT"] = port.to_s if port
|
|
112
113
|
env["HOST"] = "127.0.0.1"
|
|
@@ -116,6 +117,10 @@ module Yamine
|
|
|
116
117
|
# proxied hostname so Rails apps boot behind yamine with zero
|
|
117
118
|
# config — this replaces the yamine-rails hosts patch.
|
|
118
119
|
env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
|
|
120
|
+
# Per-worktree database: every process in this worktree shares one
|
|
121
|
+
# isolated database. Frameworks that honor DATABASE_URL (Rails,
|
|
122
|
+
# Django, most Node) get isolation for free; others ignore it.
|
|
123
|
+
env["DATABASE_URL"] = database_url if database_url
|
|
119
124
|
env
|
|
120
125
|
end
|
|
121
126
|
|
data/lib/yamine/version.rb
CHANGED
data/lib/yamine.rb
CHANGED
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.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -91,6 +91,7 @@ files:
|
|
|
91
91
|
- lib/yamine/cli/system.rb
|
|
92
92
|
- lib/yamine/command.rb
|
|
93
93
|
- lib/yamine/config.rb
|
|
94
|
+
- lib/yamine/database.rb
|
|
94
95
|
- lib/yamine/doctor.rb
|
|
95
96
|
- lib/yamine/errors.rb
|
|
96
97
|
- lib/yamine/framework.rb
|