yamine 0.4.0 → 0.6.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: de59fb684553be7e33ba1a9ea4688bb075a3147ae6225cdc634818c869900163
4
- data.tar.gz: 920ac5409e8e0803ed3e3d8ba01663ea03f1543db30e4921585ef6bfdf0d33be
3
+ metadata.gz: 1fef68ea493be93af6dfb3271b446b793536a373d11b030f62e54c0d90438e88
4
+ data.tar.gz: 69c64d3bd1523b05ca6277f76329e20415879f16c95cd0640e30c9adf77f69cf
5
5
  SHA512:
6
- metadata.gz: 8f0cb59209724dd41946c4e0e82c6e2aa01ac9e49575bf461610ed525d98d50fb1566260cf83f7a0b50e19cffd4ab9d6658e372d1c094529e12276e256541161
7
- data.tar.gz: 8bb784a546f91a27c5a9b00584fbe14560022d655acd861d5bfdba13db8f187b70913fd129b46b73948068d17c1affca556fd0eb4a67690074d74dbcfdb96214
6
+ metadata.gz: 2864def84f4afefe2dd79adda834a2381964e964da864f99c8740321c19559669ad48874cce4485222c0a09d5a285b343d99d2fdea5f1029ee54f8dc8cd86a76
7
+ data.tar.gz: fe58b77da7a315e3f6aa3fd59f7c153fe8a0d342ffdbbdf923e4199e8ccba02b039c2cde777a7c9d1855d127563ef3f021171b308c1696f5f7446b9ac068430e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] — Unreleased
4
+
5
+ `yamine start --wait` + boot readiness protocol. The remaining gap from
6
+ “one-storey building” onwards: boot had no definitive answer — agents
7
+ polled, guessed from logs, and raced half-booted routes. Now every phase
8
+ (deps, db, schema, per-process health) has a real event and `--wait`
9
+ delivers one call with a pass/fail payload. Credentials-only DB URLs
10
+ warn loudly instead of sharing silently.
11
+
12
+ ### Added
13
+
14
+ - `Yamine::Readiness`: phased events (`phase`, `action`, `status`,
15
+ `duration_ms`, `detail`), `check_deps` pre-flight, `wait_healthy`
16
+ (healthcheck path or TCP accept), concurrent `wait_all` (one thread
17
+ per process, dead-pid short-circuit so a crash fails fast), per-phase
18
+ timeouts (deps 30s, db 15s, schema 90s, process 45s or per-healthcheck
19
+ timeout). `Yamine::WaitPayload` success/failure payloads for `--json`.
20
+ - `yamine start --wait [--json]`: spawns every process, polls until
21
+ healthy, registers routes only on success, kills everything (including
22
+ background children) and exits 1 with the failed phase + that process's
23
+ own log tail on failure — no half-booted routes. `--wait` is kept as a no-op alias;
24
+ `--no-wait` keeps the old sequential fire-and-forget path.
25
+ `--json` is only meaningful with wait (now default).
26
+ - Per-process `healthcheck: { path: /up, timeout: 30 }` in
27
+ `config/local.yml`, respected by `--wait`. Absent means TCP accept.
28
+ - Boot phases now visible: deps pre-flight, db (`exists?`/`ensure_exists`
29
+ split so provenance doesn't need a second probe), schema, per-process.
30
+ `Runner#spawn_http`/`adopt`/`spawned?` support the concurrent path;
31
+ `boot_run` child output is logged so failure payloads can tail it.
32
+
33
+ ### Fixed
34
+
35
+ - Top-level `db: false` opts out of per-worktree databases (exotic
36
+ setups: manual `establish_connection`, shared staging DB, …). Validated
37
+ as boolean-or-mapping; `x-` extensions still ignored. `db.schema_load`
38
+ moves to top-level `db` (per-process ghost `db` entry was a category
39
+ error). Credentials-stored `DATABASE_URL`s now work when the template is
40
+ declared in `env.clear` (password via `local.secrets`); when no template
41
+ exists but the app looks database-backed (`database.yml` adapter or
42
+ `pg`/`mysql2`/`trilogy` in `Gemfile`) boot warns loudly with the fix
43
+ instead of silently sharing one database. Framework-agnostic: injection
44
+ is still `DATABASE_URL`, no `ask-auth` dep, no Rails load at boot.
45
+
46
+ ## [0.5.0] — Unreleased
47
+
48
+ Per-worktree databases: every worktree directory gets its own database
49
+ so concurrent agents never share tables.
50
+
51
+ ### Added
52
+
53
+ - `Yamine::Database`: `<sanitized-dir-basename>_<env>` naming
54
+ (`/code/myapp-fix` → `myapp_fix_development`; main keeps the bare
55
+ name), 63-byte Postgres cap with hash-suffix truncation, collision
56
+ guard via state map (silent sharing would be the bug this kills).
57
+ - Boot resolves the DB once, creates it (`createdb`/`mysqladmin`,
58
+ never raises — warns and shares template on failure), runs the app's
59
+ schema-load (`db.schema_load` in config, Rails default, else nothing),
60
+ and injects `DATABASE_URL` into every process. SQLite left alone.
61
+ - `yamine db list|create|drop` and `yamine worktree list|clean`
62
+ (orphan reaping for deleted worktrees). Branch plays no part —
63
+ directories are stable, branches hop.
64
+
3
65
  ## [0.3.0] — Unreleased
4
66
 
5
67
  Renamed from `ask-local` to **`yamine`** (يمين, "right hand" — the local
data/README.md CHANGED
@@ -91,6 +91,12 @@ processes:
91
91
  proxy: false
92
92
  ```
93
93
 
94
+ Optional top-level `db: false` opts out of per-worktree databases
95
+ (exotic setups — manual `establish_connection`, shared staging DB, …);
96
+ `db.schema_load` overrides the schema-load command. Per-process
97
+ `healthcheck: { path: /up, timeout: 30 }` declares what `--wait` polls
98
+ (TCP accept when absent).
99
+
94
100
  `yamine init` creates the file (migrating an existing Procfile);
95
101
  Rails apps need no extra gem — yamine injects `RAILS_DEVELOPMENT_HOSTS`
96
102
  so the proxied hostname is allowed automatically. `yamine`
@@ -133,19 +139,21 @@ yamine --tld preview.example.com # your own domain (OAuth parity)
133
139
  ## Commands
134
140
 
135
141
  ```bash
136
- yamine # infer name, boot app
142
+ yamine # boot app (waits until healthy, then supervises)
143
+ yamine start --no-wait # fire-and-forget (register routes immediately)
144
+ yamine start --json # machine-readable wait result (--wait default)
137
145
  yamine run -- <cmd> # run explicit command through proxy
138
- yamine <name> <cmd> # explicit app name
139
146
  yamine get <name> # print URL for cross-service wiring
140
147
  yamine alias <name> <port> # static route (e.g. Docker)
141
148
  yamine list [--json] # show active routes (+ backend liveness)
142
149
  yamine status [--json] # show effective naming context here
143
150
  yamine doctor [--json] # machine-readable health checks
144
151
  yamine open [name] # open the app URL in a browser
145
- yamine doctor # read-only health check (state, proxy, routes, DNS, CA)
146
152
  yamine trust # add local CA to system trust store
147
153
  yamine clean # remove state and hosts entries
148
154
  yamine prune # remove stale routes
155
+ yamine db list|create|drop # per-worktree databases
156
+ yamine worktree list|clean # worktree databases + orphan cleanup
149
157
  yamine stop # stop this app's backend + routes
150
158
  yamine restart # touch tmp/restart.txt (managed apps reboot)
151
159
  yamine log [-f] [n] # tail (or follow) this app's backend log
@@ -186,12 +194,18 @@ connection (verified end-to-end: RFC 6455 handshake + frame echo).
186
194
 
187
195
  ## Machine-readable output
188
196
 
189
- `list`, `status`, and `doctor` accept `--json` with stable keys for
197
+ `list`, `status`, `doctor`, and `yamine start --wait` accept `--json` with stable keys for
190
198
  agents and scripts (`DevUrl` in ask-ruby-harness consumes the same data
191
199
  in-process). Hostnames that fall outside the configured TLDs get a bare
192
200
  404 naming nothing — route names never leak to foreign hosts
193
201
  (DNS-rebinding boundary).
194
202
 
203
+ `yamine start` (default `--wait`) exits 0 only once every HTTP route is healthy
204
+ (healthcheck path when declared, TCP accept otherwise). On failure it
205
+ exits 1 with the failed process, its phase, and the tail of its own log
206
+ — no guessing, no polling, no half-booted routes. `--no-wait` keeps the
207
+ old fire-and-forget path.
208
+
195
209
  ## Log rotation
196
210
 
197
211
  `proxy.log` and per-app backend logs rotate at 5MB
@@ -33,11 +33,13 @@ no extra gem — the proxied hostname is allowed automatically via
33
33
  `RAILS_DEVELOPMENT_HOSTS`.
34
34
 
35
35
  ```bash
36
- yamine start # setup if needed, then boot every process
37
- yamine # same as start
38
- yamine stop # stop this app's backend + routes
39
- yamine status # show service, processes, and URLs
40
- yamine log [-f] # tail the web process log
36
+ yamine start # setup if needed, then boot every process (waits until healthy)
37
+ yamine start --no-wait # fire-and-forget (register routes immediately)
38
+ yamine start --json # machine-readable wait result (--wait default)
39
+ yamine # same as start
40
+ yamine stop # stop this app's backend + routes
41
+ yamine status # show service, processes, and URLs
42
+ yamine log [-f] # tail the web process log
41
43
  ```
42
44
 
43
45
  `$PORT` and `YAMINE_URL` are injected per process; HTTP processes get
@@ -96,6 +98,7 @@ proxy:
96
98
  yamine doctor # read-only: proxy, routes, DNS, CA trust
97
99
  yamine list --json # routes as stable JSON
98
100
  yamine prune # clear stale routes from crashed sessions
101
+ yamine start --json # boot readiness payload (pass/fail + log tail)
99
102
  ```
100
103
 
101
104
  If a hostname does not resolve: `yamine hosts sync`. If the browser
@@ -16,7 +16,7 @@ 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])
19
+ opts = ctx.parse_flags(args, %i[variant tld force app_port wait no_wait json])
20
20
  resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
21
21
  # Ownership gate before any side effects: no proxy spawn, no
22
22
  # port allocation when we'd refuse anyway.
@@ -43,6 +43,7 @@ module Yamine
43
43
  tld = resolved.tld
44
44
  host = resolved.host
45
45
  processes = resolved.processes
46
+ events = opts[:events]
46
47
 
47
48
  if processes.empty?
48
49
  $stderr.puts "Error: no processes in config/local.yml. Add at least one."
@@ -55,6 +56,20 @@ module Yamine
55
56
  exit 1
56
57
  end
57
58
 
59
+ # Pre-flight: verify runtime deps BEFORE spawning anything.
60
+ # A missing bundle fails here in seconds with the fix, instead
61
+ # of a 60s Puma crash-loop ending in "did not boot".
62
+ deps = Readiness.phase(:deps, "check", out: events) do
63
+ ok, fix = Readiness.check_deps(Dir.pwd)
64
+ raise fix unless ok
65
+
66
+ "dependencies satisfied"
67
+ end
68
+ unless deps.status == "ok"
69
+ $stderr.puts "Error: #{deps.detail}"
70
+ exit 1
71
+ end
72
+
58
73
  runner = Runner.new(store: ctx.store)
59
74
  children = []
60
75
  routes_registered = []
@@ -62,59 +77,182 @@ module Yamine
62
77
  puts "yamine (#{service})"
63
78
  puts "--"
64
79
 
80
+ # Per-worktree database: one database per directory so concurrent
81
+ # agents never share tables. Resolved once, injected into every
82
+ # process as DATABASE_URL, created on demand with the app's own
83
+ # schema-load command. SQLite needs nothing (relative paths
84
+ # already isolate); the branch plays no part (dirs are stable,
85
+ # branches hop).
86
+ db_name = Database.name_for(Dir.pwd, env: rails_env,
87
+ state_dir: ctx.store.dir)
88
+ db_created, db_url = setup_database(ctx, resolved, db_name, events: events)
89
+ puts " [db] #{db_name}#{db_created ? " (created)" : ""}" if db_url
90
+
91
+ with_wait = !opts[:no_wait]
92
+ spawn_plan = collect_spawns(ctx, runner, resolved, opts, db_url, children)
93
+
94
+ if with_wait
95
+ boot_concurrent(ctx, runner, resolved, opts, spawn_plan, children,
96
+ routes_registered, db_url, db_name, db_created)
97
+ else
98
+ boot_sequential(ctx, runner, resolved, opts, spawn_plan, children,
99
+ routes_registered, db_url, events: events)
100
+ end
101
+
102
+ background = processes.select { |_, v| v["proxy"] == false }
103
+ unless background.empty?
104
+ puts " [background] #{background.keys.join(', ')}"
105
+ end
106
+
107
+ puts
108
+ ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
109
+
110
+ # Supervisor: exit when ANY child dies (loud cleanup).
111
+ all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
112
+ all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
113
+ trap_cleanup(ctx, all_hostnames, all_pids)
114
+ supervise_tree(ctx, all_hostnames, all_pids)
115
+ end
116
+
117
+ # One pass over config: background processes spawn immediately
118
+ # (no route to wait on), HTTP processes become a spawn plan the
119
+ # boot mode executes. Compound-line refusal happens here so both
120
+ # paths share one rule.
121
+ def collect_spawns(ctx, runner, resolved, opts, db_url, children)
122
+ plan = []
123
+ processes = resolved.processes
65
124
  processes.each do |proc_name, entry|
66
125
  hostname = Resolver.hostname_for(resolved, proc_name)
67
126
  if entry["proxy"] == false
68
- boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
127
+ boot_background(ctx, runner, resolved, proc_name, entry, opts,
128
+ children, db_url)
69
129
  next
70
130
  end
71
131
  next unless hostname
72
132
 
73
133
  url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
74
- hostnames = [hostname]
75
- puts " [#{proc_name}] #{url}"
76
-
77
- # Each process cmd runs through the shell so $PORT (and other
78
- # env refs) expand — same trust boundary as a Procfile line
79
- # (repo code, not user input). Compound lines are refused.
80
134
  cmd = entry["cmd"].to_s
81
135
  if cmd.match?(Yamine::Procfile::COMPOUND)
82
136
  $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
83
137
  next
84
138
  end
85
-
86
139
  port = opts[:app_port] || Ports.find_free
87
- shell_cmd = ["sh", "-c", cmd]
140
+ plan << { name: proc_name, entry: entry, hostname: hostname,
141
+ hostnames: [hostname], url: url, port: port,
142
+ command: ["sh", "-c", cmd] }
143
+ end
144
+ plan
145
+ end
146
+
147
+ # Default: sequential boot, route registered per process as before.
148
+ def boot_sequential(ctx, runner, resolved, opts, plan, children,
149
+ routes_registered, db_url, events: nil)
150
+ plan.each do |item|
151
+ puts " [#{item[:name]}] #{item[:url]}"
88
152
  # Always allow the proxied hostname in Rails dev (Rails ignores
89
153
  # this env var when not a Rails app — safe for every framework).
90
- app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
91
- dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
92
- rails_dev_host: hostname)
93
- register_all(ctx, hostnames, app, force: opts[:force],
94
- spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
95
- routes_registered << { hostnames: hostnames, app: app }
96
-
97
- puts " -> #{url}"
154
+ # DATABASE_URL points at this worktree's own database; non-Ruby
155
+ # frameworks that honor it get isolation for free, others ignore it.
156
+ # boot_run registers the route + backend sidecar itself
157
+ # (register: true default); no separate adopt step needed.
158
+ app = runner.boot_run(name: item[:name], hostname: item[:hostname],
159
+ url: item[:url], dir: Dir.pwd, command: item[:command],
160
+ port: item[:port], force: opts[:force],
161
+ rails_dev_host: item[:hostname], database_url: db_url)
162
+ routes_registered << { hostnames: item[:hostnames], app: app }
163
+
164
+ puts " -> #{item[:url]}"
98
165
  end
166
+ children
167
+ end
99
168
 
100
- background = processes.select { |_, v| v["proxy"] == false }
101
- unless background.empty?
102
- puts " [background] #{background.keys.join(', ')}"
169
+ # --wait: spawn everything first, then poll every port/path until
170
+ # healthy. No route exists until its backend answers, so half-boots
171
+ # never leak into the proxy. A backend that dies before its route
172
+ # registers is cleaned up like any other failed process.
173
+ def boot_concurrent(ctx, runner, resolved, opts, plan, children,
174
+ routes_registered, db_url, db_name, db_created)
175
+ apps = {}
176
+ plan.each do |item|
177
+ puts " [#{item[:name]}] #{item[:url]}"
178
+ apps[item[:name]] = {
179
+ item: item,
180
+ app: runner.spawn_http(name: item[:name], hostname: item[:hostname],
181
+ url: item[:url], dir: Dir.pwd, command: item[:command],
182
+ port: item[:port], rails_dev_host: item[:hostname],
183
+ database_url: db_url, force: opts[:force])
184
+ }
103
185
  end
186
+ wait_result = Readiness.wait_all(apps, tls: ctx.proxy_tls, out: nil)
187
+ failed = wait_result.select { |r| r[:status] != "ok" }
188
+
189
+ if failed.empty?
190
+ apps.each do |name, slot|
191
+ runner.adopt(slot[:item][:hostname], slot[:app], force: opts[:force],
192
+ spec: { "dir" => File.expand_path(Dir.pwd), "proc" => name })
193
+ routes_registered << { hostnames: slot[:item][:hostnames], app: slot[:app] }
194
+ puts " -> #{slot[:item][:url]}"
195
+ end
196
+ end
197
+ finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
198
+ children, db_name, db_url, db_created, json: opts[:json])
199
+ end
104
200
 
105
- puts
106
- ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
201
+ # --wait epilogue: on success the summary payload (URLs + per-process
202
+ # health) goes to stdout and boot continues into supervision. On
203
+ # failure every spawned child is killed and removed, the failure
204
+ # payload (failed phase + log tail) prints, and start exits 1 —
205
+ # no half-booted routes left behind.
206
+ def finish_wait(ctx, resolved, apps, wait_result, failed, routes_registered,
207
+ children, db_name, db_url, db_created, json: false)
208
+ urls = apps.transform_values { |slot| slot[:item][:url] }
209
+ if failed.empty?
210
+ puts "ready: #{urls.map { |n, u| "#{n}=#{u}" }.join(" ")}"
211
+ payload = Yamine::WaitPayload.success(resolved, wait_result, urls: urls,
212
+ db_name: db_name, db_url: db_url, created: db_created) if json
213
+ puts JSON.generate(payload) if json
214
+ return
215
+ end
107
216
 
108
- # Supervisor: exit when ANY child dies (loud cleanup).
109
- all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
110
- all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
111
- trap_cleanup(ctx, all_hostnames, all_pids)
112
- supervise_tree(ctx, all_hostnames, all_pids)
217
+ apps.each_value { |slot| stop_spawned(slot[:app]) }
218
+ children.each { |c| stop_spawned_pid(c[:pid]) }
219
+ first = failed.first
220
+ payload = Yamine::WaitPayload.failure(resolved, wait_result, failed: first,
221
+ log_tail: tail_for(failed.first, apps))
222
+ if json
223
+ puts JSON.generate(payload)
224
+ else
225
+ $stderr.puts "Error: #{first[:name]} failed (#{first[:phase]}): #{first[:detail]}"
226
+ $stderr.puts payload[:log_tail].to_s.lines.last(10).join if payload[:log_tail]
227
+ $stderr.puts "Full log: #{payload[:log_path]}" if payload[:log_path]
228
+ end
229
+ cleanup_routes(ctx, apps.values.map { |slot| slot[:item][:hostname] })
230
+ exit 1
231
+ end
232
+
233
+ def tail_for(failure, apps)
234
+ name = failure[:name]
235
+ slot = apps[name]
236
+ path = slot ? File.expand_path(File.join(Dir.pwd, "log", "yamine-#{name}.log")) : nil
237
+ lines = path && File.file?(path) ? File.readlines(path).last(20).join : "(no log file)"
238
+ { path: path, tail: lines }
239
+ rescue SystemCallError
240
+ { path: path, tail: "(unreadable log)" }
241
+ end
242
+
243
+ def stop_spawned(app)
244
+ stop_spawned_pid(app.pid)
245
+ end
246
+
247
+ def stop_spawned_pid(pid)
248
+ Process.kill("TERM", pid)
249
+ rescue SystemCallError
250
+ nil
113
251
  end
114
252
 
115
253
  # A background process (proxy: false) is spawned, logged, and
116
254
  # supervised exactly like an HTTP one — it just gets no route.
117
- def boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
255
+ def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
118
256
  cmd = entry["cmd"].to_s
119
257
  if cmd.match?(Yamine::Procfile::COMPOUND)
120
258
  $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
@@ -124,7 +262,8 @@ module Yamine
124
262
  url = "background://#{resolved.app}/#{proc_name}"
125
263
  app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
126
264
  url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
127
- force: opts[:force], rails_dev_host: nil, register: false)
265
+ force: opts[:force], rails_dev_host: nil, register: false,
266
+ database_url: db_url)
128
267
  children << { name: proc_name, pid: app.pid }
129
268
  puts " [#{proc_name}] background (pid #{app.pid})"
130
269
  end
@@ -166,6 +305,138 @@ module Yamine
166
305
  env
167
306
  end
168
307
 
308
+ # Rails env for database naming: RAILS_ENV when set, else development.
309
+ # Non-Rails apps ignore it (their DATABASE_URL template decides).
310
+ def rails_env
311
+ ENV.fetch("RAILS_ENV", "development")
312
+ end
313
+
314
+ # Resolve this worktree's database, create it if missing, and run
315
+ # the app's schema-load command on first creation. Returns
316
+ # [created, url]: created is true only when this boot created the
317
+ # database (so --wait can report provenance without a second
318
+ # probe), url is nil when there is nothing to inject.
319
+ #
320
+ # Template sources: ENV DATABASE_URL, then config env.clear
321
+ # DATABASE_URL (top-level env, falling back to per-process env
322
+ # for configs written before top-level env existed). Injected
323
+ # DATABASE_URL overrides database.yml/credentials at connection
324
+ # time (ActiveRecord merges env over file config), so credentials
325
+ # apps work — they just need the template declared in config.
326
+ def setup_database(ctx, resolved, db_name, events: nil)
327
+ return [false, nil] if db_opted_out?(resolved)
328
+
329
+ template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
330
+ if template.nil? || template.strip.empty?
331
+ warn_missing_template(resolved)
332
+ return [false, nil]
333
+ end
334
+
335
+ url = Database.url_for(db_name, template)
336
+ return [false, nil] unless url
337
+
338
+ db_event = Readiness.phase(:db, "ensure #{db_name}", out: events) do
339
+ existed = Database.exists?(db_name, template)
340
+ raise "database server unreachable — is postgres/mysql running?" unless Database.ensure_exists(db_name, template)
341
+
342
+ existed ? "already exists" : "created #{db_name}"
343
+ end
344
+ unless db_event.status == "ok"
345
+ $stderr.puts " [db] WARNING: #{db_event.detail} — processes share the template database."
346
+ return [false, nil]
347
+ end
348
+ created = db_event.detail.to_s.start_with?("created")
349
+
350
+ schema_event = Readiness.phase(:schema, "load #{db_name}", out: events) do
351
+ next "skipped (already exists)" unless created
352
+
353
+ run_schema_load(resolved, url)
354
+ end
355
+ unless schema_event.status == "ok"
356
+ $stderr.puts " [db] WARNING: schema load #{schema_event.status}: #{schema_event.detail}."
357
+ end
358
+ [created, url]
359
+ end
360
+
361
+ # Top-level `db: false` opts out of per-worktree databases.
362
+ # (A `db` process entry still boots as a process — only the
363
+ # explicit top-level key opts out.)
364
+ def db_opted_out?(resolved)
365
+ resolved.db == false
366
+ end
367
+
368
+ # The dangerous case made loud: the app looks database-backed
369
+ # (server adapter in database.yml, or pg/mysql2 in the Gemfile)
370
+ # but no template was declared, so every worktree would silently
371
+ # share one database. Name the fix; do not guess.
372
+ def warn_missing_template(resolved)
373
+ return unless database_backed?
374
+
375
+ $stderr.puts " [db] WARNING: app looks database-backed but no DATABASE_URL template found."
376
+ $stderr.puts " Each worktree would share one database. Declare the template in config/local.yml:"
377
+ $stderr.puts " env:"
378
+ $stderr.puts " clear:"
379
+ $stderr.puts " DATABASE_URL: postgres://user@localhost:5432/myapp_development"
380
+ $stderr.puts " (password via config/local.secrets + env.secret), or opt out with top-level `db: false`."
381
+ end
382
+
383
+ # Heuristics, deliberately conservative: only warn when there is
384
+ # positive evidence of a server database. SQLite-only and
385
+ # database-less apps stay silent.
386
+ def database_backed?
387
+ database_yml_server? || gemfile_server_adapter?
388
+ end
389
+
390
+ def database_yml_server?(path = File.join(Dir.pwd, "config", "database.yml"))
391
+ return false unless File.file?(path)
392
+
393
+ content = File.read(path)
394
+ content.match?(/adapter:\s*(postgresql|postgres|postgis|mysql2?|trilogy)/i)
395
+ rescue SystemCallError
396
+ false
397
+ end
398
+
399
+ def gemfile_server_adapter?(path = File.join(Dir.pwd, "Gemfile"))
400
+ return false unless File.file?(path)
401
+
402
+ content = File.read(path)
403
+ content.match?(/gem\s+["'](pg|mysql2|trilogy)["']/)
404
+ rescue SystemCallError
405
+ false
406
+ end
407
+
408
+ def database_template_from_config(resolved)
409
+ top = resolved.env.is_a?(Hash) ? resolved.env["clear"] : nil
410
+ from_top = top.is_a?(Hash) ? top["DATABASE_URL"] : nil
411
+ return from_top if from_top && !from_top.to_s.strip.empty?
412
+
413
+ env = resolved.processes.values.map { |e| e["env"] || {} }
414
+ clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
415
+ clear["DATABASE_URL"]
416
+ end
417
+
418
+ # Schema-load on first boot uses the app's own command when declared
419
+ # (top-level db.schema_load in config/local.yml), else a Rails
420
+ # default when a Rails app is detected, else nothing (frameworks
421
+ # without a schema concept need no step). Returns a detail string.
422
+ def run_schema_load(resolved, url)
423
+ cmd = db_schema_load(resolved) ||
424
+ ("bin/rails db:schema:load" if rails_app?)
425
+ return "no schema-load command" unless cmd
426
+
427
+ ok = system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
428
+ "sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
429
+ ok ? "loaded schema via #{cmd}" : raise("schema-load exited non-zero: #{cmd}")
430
+ end
431
+
432
+ def db_schema_load(resolved)
433
+ resolved.db.is_a?(Hash) ? resolved.db["schema_load"] : nil
434
+ end
435
+
436
+ def rails_app?
437
+ File.file?(File.join(Dir.pwd, "config", "application.rb"))
438
+ end
439
+
169
440
  def supervise_tree(ctx, hostnames, pids)
170
441
  loop do
171
442
  sleep 0.5
@@ -218,21 +489,6 @@ module Yamine
218
489
  end
219
490
  end
220
491
 
221
- def register_all(ctx, hostnames, app, force:, spec: nil)
222
- hostnames[1..].each do |h|
223
- ctx.store.add_route(h, app.target, Process.pid, kind: app.kind,
224
- force: force, spec: spec)
225
- write_backend_sidecar(ctx, h, app.pid)
226
- end
227
- end
228
-
229
- def write_backend_sidecar(ctx, hostname, pid)
230
- ctx.store.ensure_dir
231
- File.write(File.join(ctx.store.dir, "backend-#{hostname}.pid"), "#{pid}\n")
232
- rescue SystemCallError
233
- nil
234
- end
235
-
236
492
  def cleanup_routes(ctx, hostnames)
237
493
  hostnames.each do |h|
238
494
  begin
@@ -81,7 +81,7 @@ module Yamine
81
81
  elsif arg.start_with?("--")
82
82
  key = arg.sub(/\A--/, "").tr("-", "_").to_sym
83
83
  if known.include?(key)
84
- if %i[branch force].include?(key)
84
+ if %i[branch force wait no_wait json].include?(key)
85
85
  opts[key] = true
86
86
  i += 1
87
87
  else