copse 0.1.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.
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ require_relative "url_options"
6
+ require_relative "database"
7
+
8
+ module Copse
9
+ # Points generated URLs at the Copse hostname in development, gives a linked
10
+ # worktree its own development database, and reports both on boot.
11
+ #
12
+ # All the decision logic lives in Copse::UrlOptions and Copse::Database, which
13
+ # know nothing about Rails; this class is only the wiring.
14
+ class Railtie < ::Rails::Railtie
15
+ initializer "copse.default_url_options" do |app|
16
+ routes = app.routes
17
+
18
+ if UrlOptions.apply?(rails_env: ::Rails.env, existing_host: routes.default_url_options[:host])
19
+ options = UrlOptions.for
20
+ routes.default_url_options.merge!(options)
21
+
22
+ # Reached through on_load rather than `config.action_mailer`, which raises
23
+ # NoMethodError in an app that does not load Action Mailer at all.
24
+ ActiveSupport.on_load(:action_mailer) do
25
+ # Checked again here: an app can set mailer URL options without setting
26
+ # route ones, and Copse should not override either.
27
+ if default_url_options.to_h[:host].to_s.empty?
28
+ self.default_url_options = default_url_options.to_h.merge(options)
29
+ end
30
+ end
31
+
32
+ # R14. Puma prints its own `Listening on http://127.0.0.1:<port>` line too;
33
+ # that one reports the bound address, and binding to the hostname would
34
+ # depend on the system resolver. Both lines are correct about different
35
+ # things, which is why this one names Copse explicitly.
36
+ $stdout.puts "=> Copse: #{UrlOptions.url} (Puma reports the bound address separately)"
37
+ end
38
+ end
39
+
40
+ # A linked worktree gets its own development database, so two branches of one
41
+ # app do not share one schema.
42
+ #
43
+ # Placed *after* `active_record.initialize_database` because that initializer
44
+ # is what assigns `ActiveRecord::Base.configurations` -- before it there is
45
+ # nothing to rename. It also calls `establish_connection`, which is why
46
+ # Copse::Database mutates each configuration in place rather than replacing
47
+ # it; see the note there.
48
+ #
49
+ # Unlike the URL options this does not require COPSE_URL. The database has to
50
+ # be the same one whether you arrived by `bin/dev`, `bin/rails console`, or
51
+ # `bin/rails db:prepare` -- a database only reachable from `bin/dev` is a
52
+ # database no rake task could create.
53
+ initializer "copse.database", after: "active_record.initialize_database" do
54
+ next unless Database.apply?(rails_env: ::Rails.env)
55
+
56
+ suffix = Database.suffix(root: ::Rails.root.to_s)
57
+ next if suffix.to_s.empty?
58
+
59
+ ActiveSupport.on_load(:active_record) do
60
+ renamed = Database.apply(ActiveRecord::Base.configurations, suffix: suffix)
61
+
62
+ # Only when Copse booted the app. Every other development command
63
+ # (`bin/rails console`, every rake task) is renamed too, and printing there
64
+ # would put a Copse line in front of unrelated output; the tasks that
65
+ # matter name the database themselves.
66
+ unless renamed.empty? || UrlOptions.url.to_s.empty?
67
+ $stdout.puts "=> Copse: database #{renamed.join(', ')}"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,558 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "socket"
5
+ require "tmpdir"
6
+ require "fileutils"
7
+
8
+ module Copse
9
+ # Everything the session needs before it spawns anything: the environment, a
10
+ # safe temporary Procfile, and a trustworthy answer to whether foreman works.
11
+ class Session
12
+ MIN_FOREMAN_VERSION = "0.90.0"
13
+
14
+ # How long to wait before checking whether foreman died on the spot. Long
15
+ # enough to catch a missing binary or an empty Procfile, short enough to be
16
+ # invisible next to a Rails boot.
17
+ FOREMAN_STARTUP_GRACE = 0.3
18
+
19
+ # How long teardown waits for a child to honour SIGTERM before SIGKILL. Chosen
20
+ # to sit just past foreman's own 5s escalation so its children get their
21
+ # graceful window first.
22
+ REAP_TIMEOUT = 6
23
+ REAP_POLL = 0.05
24
+
25
+ INTERRUPTED_STATUS = 130
26
+
27
+ attr_reader :worktree, :root
28
+
29
+ def initialize(worktree, root: nil, out: $stdout)
30
+ @worktree = worktree
31
+ @root = File.expand_path(root || worktree.root)
32
+ @out = out
33
+ end
34
+
35
+ # Boots the app and returns the foreground process's exit status.
36
+ #
37
+ # The load-bearing property is that the web process is spawned with this
38
+ # process's own stdin, stdout, and stderr and stays in the terminal's
39
+ # foreground process group, so `binding.irb` and `debug` behave exactly as
40
+ # they do under a bare `rails server`. Nothing may come between the two.
41
+ def start
42
+ @out.puts "=> Copse: #{worktree.url}"
43
+
44
+ # The guard spans everything after the banner, not just the foreground wait.
45
+ # The foreman probe spawns a child of its own, and a signal arriving during it
46
+ # used to escape the handler entirely -- which surfaced as a bogus "cannot run
47
+ # foreman" error rather than a clean interrupt.
48
+ guarded do
49
+ # No secondaries means no foreman -- not even a preflight. The Procfile the
50
+ # install generator writes has only a `web` line, so preflighting a tool
51
+ # this run will never use would refuse to boot the most common app. Running
52
+ # `foreman start` against an empty Procfile is fatal anyway.
53
+ next run_foreground unless secondaries?
54
+
55
+ unless foreman_available?
56
+ @out.puts foreman_error_message
57
+ next 1
58
+ end
59
+ @out.puts foreman_version_warning if foreman_outdated?
60
+
61
+ @foreman_pid = spawn_foreman(write_temp_procfile)
62
+ report_if_foreman_died_early
63
+ run_foreground
64
+ end
65
+ ensure
66
+ teardown
67
+ end
68
+
69
+ private
70
+
71
+ # Runs the body with SIGTERM converted to an Interrupt, and keeps that
72
+ # conversion in place while `start`'s `ensure` tears down.
73
+ #
74
+ # Two windows depend on this. Without the trap at all, SIGTERM kills the
75
+ # process outright and `ensure` never runs. With the trap restored too early, a
76
+ # second SIGTERM arriving during teardown kills us between `terminate_web` and
77
+ # `terminate_foreman` -- which was measured to leave the watcher alive.
78
+ # Interrupt is caught here rather than inside `run_foreground` so the foreman
79
+ # startup grace is covered too; otherwise a signal during that sleep escaped as
80
+ # a raw backtrace.
81
+ def guarded
82
+ previous = Signal.trap("TERM") { raise Interrupt }
83
+ @term_trap_previous = previous
84
+ yield
85
+ rescue Interrupt
86
+ # Ctrl-C, or a SIGTERM converted above. The TTY already delivered SIGINT to
87
+ # the whole foreground group, so there is nothing to announce -- `ensure`
88
+ # cleans up.
89
+ INTERRUPTED_STATUS
90
+ end
91
+
92
+ def restore_term_trap
93
+ return unless defined?(@term_trap_previous)
94
+
95
+ Signal.trap("TERM", @term_trap_previous || "DEFAULT")
96
+ remove_instance_variable(:@term_trap_previous)
97
+ end
98
+
99
+ def run_foreground
100
+ @web_pid = Process.spawn(web_env, web_command, chdir: root)
101
+ _, status = Process.waitpid2(@web_pid)
102
+ @web_pid = nil
103
+
104
+ code = status.exitstatus || INTERRUPTED_STATUS
105
+ report_port_collision if code != 0 && port_in_use?
106
+ code
107
+ end
108
+
109
+ def spawn_foreman(path)
110
+ Process.spawn(
111
+ foreman_env,
112
+ "foreman", "start",
113
+ "-f", path,
114
+ # Not optional. Foreman takes each child's working directory from the
115
+ # Procfile's own directory, so without this every app-relative command
116
+ # (`bin/rails tailwindcss:watch`, `yarn build --watch`) would run from the
117
+ # temp directory and die with "unknown command". Copse's own cwd does not
118
+ # help; only this does.
119
+ "-d", root,
120
+ # jsbundling-rails and cssbundling-rails both pass this. Without it
121
+ # foreman loads the app's .env, which can clobber the derived PORT.
122
+ "--env", "/dev/null",
123
+ chdir: root
124
+ )
125
+ end
126
+
127
+ # A developer should not spend an hour editing CSS with no watcher running.
128
+ # Nothing monitors foreman while the web process holds the foreground, so
129
+ # check once here.
130
+ def report_if_foreman_died_early
131
+ sleep FOREMAN_STARTUP_GRACE
132
+ pid, status = Process.waitpid2(@foreman_pid, Process::WNOHANG)
133
+ return if pid.nil?
134
+
135
+ @foreman_reaped = true
136
+ @out.puts "copse: foreman exited immediately (status #{status.exitstatus}). " \
137
+ "The #{secondaries.size == 1 ? 'process' : 'processes'} " \
138
+ "#{secondaries.map(&:name).join(', ')} are not running."
139
+ rescue Errno::ECHILD
140
+ @foreman_reaped = true
141
+ end
142
+
143
+ # Foreman is signalled *first*, then waited on.
144
+ #
145
+ # Ordering matters: `Process.waitpid` on the web process is unbounded, so a web
146
+ # process that delays or ignores SIGTERM used to park teardown here forever and
147
+ # foreman was never signalled at all -- every watcher survived. Signalling
148
+ # foreman up front means its own 5s SIGTERM-to-SIGKILL escalation runs in
149
+ # parallel with the web process shutting down.
150
+ def teardown
151
+ signal_foreman
152
+ terminate_web
153
+ reap_foreman
154
+ remove_temp_procfile
155
+ restore_term_trap
156
+ end
157
+
158
+ def terminate_web
159
+ return if @web_pid.nil?
160
+
161
+ Process.kill("TERM", @web_pid)
162
+ reap(@web_pid, "the web process")
163
+ rescue Errno::ESRCH, Errno::ECHILD
164
+ # Already gone.
165
+ ensure
166
+ @web_pid = nil
167
+ end
168
+
169
+ # Waits for `pid`, escalating to SIGKILL rather than blocking forever. Teardown
170
+ # must always complete; a child that ignores SIGTERM is not a reason to hang.
171
+ def reap(pid, what, timeout: REAP_TIMEOUT)
172
+ deadline = Time.now + timeout
173
+ loop do
174
+ return if Process.waitpid(pid, Process::WNOHANG)
175
+ break if Time.now >= deadline
176
+
177
+ sleep REAP_POLL
178
+ end
179
+
180
+ @out.puts "copse: #{what} did not exit within #{timeout}s; sending SIGKILL."
181
+ Process.kill("KILL", pid)
182
+ Process.waitpid(pid)
183
+ rescue Errno::ESRCH, Errno::ECHILD
184
+ # Already gone.
185
+ end
186
+
187
+ # Signals foreman's own pid and lets foreman reap its children.
188
+ #
189
+ # Never a negative pgid. Foreman does not call setsid, so its process group is
190
+ # the caller's own -- `Process.kill("-TERM", pgid)` would signal the
191
+ # developer's shell session.
192
+ # Signals foreman's own pid and lets foreman reap its children.
193
+ #
194
+ # Never a negative pgid. Foreman does not call setsid, so its process group is
195
+ # the caller's own -- `Process.kill("-TERM", pgid)` would signal the
196
+ # developer's shell session.
197
+ def signal_foreman
198
+ return if @foreman_pid.nil? || @foreman_reaped
199
+
200
+ Process.kill("TERM", @foreman_pid)
201
+ rescue Errno::ESRCH
202
+ # Expected on Ctrl-C: the TTY signalled foreman directly, because its
203
+ # children share this terminal's foreground process group.
204
+ end
205
+
206
+ def reap_foreman
207
+ return if @foreman_pid.nil? || @foreman_reaped
208
+
209
+ reap(@foreman_pid, "foreman")
210
+ ensure
211
+ @foreman_pid = nil
212
+ end
213
+
214
+ def remove_temp_procfile
215
+ return if @procfile_dir.nil?
216
+
217
+ FileUtils.remove_entry(@procfile_dir) if File.exist?(@procfile_dir)
218
+ ensure
219
+ @procfile_dir = nil
220
+ end
221
+
222
+ # Derived ports are uncoordinated by design, so collisions happen. Puma's
223
+ # "Address already in use" does not reach us -- the web process owns the
224
+ # terminal and we see only an exit status -- so check whether the port is
225
+ # actually held before blaming a collision. A non-zero exit has many causes.
226
+ def port_in_use?
227
+ TCPServer.new("127.0.0.1", worktree.port).close
228
+ false
229
+ rescue Errno::EADDRINUSE
230
+ true
231
+ rescue SystemCallError
232
+ false
233
+ end
234
+
235
+ def report_port_collision
236
+ @out.puts "copse: port #{worktree.port} (derived from #{worktree.host}) is already in use. " \
237
+ "Another app or worktree most likely derived the same port."
238
+ end
239
+
240
+ public
241
+
242
+ def procfile
243
+ return @procfile if defined?(@procfile)
244
+
245
+ @procfile = Procfile.load(procfile_path)
246
+ end
247
+
248
+ def procfile_path = File.join(root, "Procfile.dev")
249
+
250
+ # Hands the whole Procfile -- `web` included -- to Overmind, replacing this
251
+ # process. Never returns.
252
+ #
253
+ # There is no split session here and no foreground/background distinction to
254
+ # preserve: Overmind gives every process its own tmux pty, so `binding.irb`
255
+ # works over `overmind connect web` rather than by holding this terminal. All
256
+ # Copse contributes on this path is the environment.
257
+ #
258
+ # The environment is `copse_env` unoffset -- deliberately not
259
+ # `foreman_port_env`. Overmind derives each child's port as
260
+ # `base + index * 100` from PORT just as foreman does, but here it supervises
261
+ # `web` too, so offsetting the base would hand `web` a port Copse never
262
+ # derived and the banner above would name the wrong URL.
263
+ # `-p` before the caller's own arguments, so `bin/dev -p 4000` still wins.
264
+ #
265
+ # The base port is passed as a *flag* rather than left to the inherited PORT
266
+ # because only the flag survives Overmind's env files. Measured against
267
+ # Overmind 2.5.1: with `PORT` in `.overmind.env` and the derived port merely
268
+ # inherited, the app booted on the env file's port; with `-p` it booted on the
269
+ # derived one. Secondaries still get `base + index * 100`.
270
+ def exec_overmind(args = [])
271
+ @out.puts "=> Copse: #{worktree.url}"
272
+ @out.puts overmind_web_position_warning if web_out_of_position?
273
+ @out.puts overmind_port_flag_warning if web_port_flag?
274
+
275
+ # Matches the `chdir: root` both foreman and the foreground web spawn use, but
276
+ # for a different reason. The processes themselves are fine either way --
277
+ # Overmind takes their working directory from the Procfile's own directory, so
278
+ # `bin/rails server` resolves even when invoked from elsewhere. The *socket* is
279
+ # not: `.overmind.sock` is created relative to Overmind's own cwd, and
280
+ # `overmind connect web` from the app root then dials a path that does not
281
+ # exist.
282
+ Dir.chdir(root)
283
+ exec(overmind_env, "overmind", "start", "-f", procfile_path, "-p", worktree.port.to_s, *args)
284
+ end
285
+
286
+ # An explicit `--port` on the `web` line beats PORT in `rails server`, and
287
+ # vite_ruby's own example ships one. The foreman path strips it, but only
288
+ # because Copse builds that command itself; here Overmind runs the app's own
289
+ # Procfile, and rewriting it into a temp copy would mean `overmind restart` and
290
+ # the file the developer edits were no longer the same thing. So this is a
291
+ # warning, the same call the signal-transparency transform makes for shapes it
292
+ # will not rewrite.
293
+ def web_port_flag?
294
+ entry = procfile&.web
295
+ !entry.nil? && Procfile.port_flag?(entry.command)
296
+ end
297
+
298
+ def overmind_port_flag_warning
299
+ "copse: the `web` line in Procfile.dev sets an explicit port, which beats the derived " \
300
+ "#{worktree.port} -- so the app will not be at #{worktree.url}. Remove the flag."
301
+ end
302
+
303
+ # OVERMIND_SKIP_ENV is this path's `--env /dev/null`, and for the same reason:
304
+ # Overmind loads the app's `.env` and applies it *over* the environment it was
305
+ # handed, so anything Copse derives is otherwise beatable by a stale `.env`.
306
+ #
307
+ # It is not the port's only defence -- `-p` above is, and it covers
308
+ # `.overmind.env`, which this flag does not skip. What this buys is that both
309
+ # supervisors see the same environment. That matters more here than it looks:
310
+ # the overmind `bin/dev` falls back to the foreman session on a machine without
311
+ # overmind, so if the two disagreed about `.env`, one committed repo would
312
+ # behave differently per teammate.
313
+ #
314
+ # Only the supervisor's env-file loading is suppressed, not the app's.
315
+ # dotenv-rails still reads `.env` inside Rails, exactly as on the foreman path.
316
+ def overmind_env
317
+ copse_env.merge("OVERMIND_SKIP_ENV" => "1")
318
+ end
319
+
320
+ # Whether `overmind start` will actually work. Overmind is a Go binary rather
321
+ # than a gem, so unlike foreman there is no bundler environment to strip and no
322
+ # version-manager shim to see past -- but probing still beats `command -v`,
323
+ # which succeeds on an unexecutable file.
324
+ #
325
+ # `--version`, not `version`: Overmind has no `version` subcommand and exits 3
326
+ # on one, which would make every probe fail and silently downgrade the whole
327
+ # path to foreman.
328
+ def overmind_available?
329
+ _out, _err, status = Open3.capture3("overmind", "--version")
330
+ status.success?
331
+ rescue Errno::ENOENT, Errno::EACCES
332
+ false
333
+ end
334
+
335
+ # The variables every process Copse starts receives.
336
+ #
337
+ # COPSE_PORT exists because foreman rewrites PORT for its own children
338
+ # (base_port + index * 100), so a secondary never sees the derived port under
339
+ # the name PORT. COPSE_PORT is the name foreman does not touch.
340
+ # COPSE_DATABASE_SUFFIX is nil in a main worktree, which keeps its plain
341
+ # database name. Nil rather than a missing key on purpose: Process.spawn merges
342
+ # its env hash into the inherited one, so leaving the key out would let a stale
343
+ # value inherited from elsewhere -- a shell opened from a linked worktree's
344
+ # session -- reach a main worktree's children and be believed. Nil is the only
345
+ # way to say "unset this".
346
+ def copse_env
347
+ {
348
+ "PORT" => worktree.port.to_s,
349
+ "COPSE_PORT" => worktree.port.to_s,
350
+ "COPSE_HOST" => worktree.host,
351
+ "COPSE_URL" => worktree.url,
352
+ "VITE_RUBY_PORT" => worktree.companion_port.to_s,
353
+ "COPSE_DATABASE_SUFFIX" => worktree.database_suffix
354
+ }
355
+ end
356
+
357
+ # The foreground process keeps the inherited bundler environment: it *is* the
358
+ # Rails app and needs its bundle.
359
+ def web_env
360
+ copse_env
361
+ end
362
+
363
+ # Foreman derives each child's port as `base_port + index * 100`, and takes
364
+ # base_port from PORT. Handing it the derived port therefore gave the *first*
365
+ # secondary exactly the web process's port -- so a secondary that binds it
366
+ # collides with puma, and copse's own collision message then blames another
367
+ # worktree.
368
+ #
369
+ # Offsetting the base is the least-bad of three options. Removing PORT entirely
370
+ # looks cleaner but is worse: foreman then falls back to 5000, which is the
371
+ # macOS AirPlay Receiver port and is on our own reserved list. Secondaries that
372
+ # need the app's real port read COPSE_PORT, which foreman does not touch.
373
+ FOREMAN_PORT_OFFSET = 100
374
+
375
+ def foreman_port_env
376
+ { "PORT" => (worktree.port + FOREMAN_PORT_OFFSET).to_s }
377
+ end
378
+
379
+ # The environment foreman is actually spawned with -- whichever candidate the
380
+ # probe found works. Falls back to the preferred candidate when nothing has
381
+ # been probed yet.
382
+ def foreman_env
383
+ foreman_version
384
+ @foreman_env || foreman_env_candidates.first
385
+ end
386
+
387
+ # Two ways foreman can be reachable, and neither one covers both.
388
+ #
389
+ # Stripping the bundler environment first is what handles the common case:
390
+ # `bin/dev` has to load the bundle to require copse at all, so foreman would
391
+ # otherwise inherit BUNDLE_GEMFILE and RUBYOPT and die with "foreman is not
392
+ # currently included in the bundle" even though it is installed. That is what
393
+ # Rails' own /bin/sh `bin/dev` achieves by exec'ing foreman outside the bundle.
394
+ #
395
+ # But if foreman is provided *only* by the app's Gemfile, the stripped
396
+ # environment is the one that cannot see it. So the inherited environment is
397
+ # the second candidate rather than an alternative design: probing both is what
398
+ # makes foreman-as-a-system-gem and foreman-in-the-Gemfile both work.
399
+ def foreman_env_candidates
400
+ base = copse_env.merge(foreman_port_env)
401
+ overrides = bundler_overrides
402
+ return [base] if overrides.empty?
403
+
404
+ [base.merge(overrides), base]
405
+ end
406
+
407
+ # Process.spawn *merges* its env hash rather than replacing the environment,
408
+ # so handing it Bundler.original_env is not enough: the inherited BUNDLE_*
409
+ # keys survive the merge. Removing them requires explicit nils.
410
+ def bundler_overrides
411
+ return {} unless defined?(Bundler) && Bundler.respond_to?(:original_env)
412
+
413
+ original = Bundler.original_env
414
+ overrides = {}
415
+ (ENV.keys - original.keys).each { |key| overrides[key] = nil }
416
+ original.each { |key, value| overrides[key] = value if ENV[key] != value }
417
+ overrides
418
+ end
419
+
420
+ # The foreground command, with any explicit port flag removed so the derived
421
+ # PORT applies. Falls back to `bin/rails server` when there is no Procfile, or
422
+ # when a Procfile exists but names no `web` process.
423
+ # The foreground command goes through the same signal-transparency transform as
424
+ # the secondaries. Without it, a `web` line needing a shell (`... 2>&1`) made
425
+ # @web_pid the shell rather than the app: teardown reaped the shell instantly
426
+ # and the real server survived, reparented to pid 1, still holding the derived
427
+ # port. `exec` is only added when the command would have gone through /bin/sh
428
+ # anyway, so the common metacharacter-free case is untouched.
429
+ def web_command
430
+ entry = procfile&.web
431
+ return "bin/rails server" if entry.nil?
432
+
433
+ command, warning = Procfile.signal_transparent(Procfile.strip_port_flag(entry.command))
434
+ @out.puts "copse: `web` #{warning}" if warning
435
+ command
436
+ end
437
+
438
+ # Non-web entries. When a Procfile exists but has no `web` line, every entry
439
+ # is a secondary and the foreground falls back to `bin/rails server`.
440
+ def secondaries
441
+ return [] if procfile.nil?
442
+
443
+ procfile.web ? procfile.secondaries : procfile.entries
444
+ end
445
+
446
+ def secondaries?
447
+ secondaries.any?
448
+ end
449
+
450
+ # Only Overmind cares: it hands `web` the port at its Procfile index, so a
451
+ # `web` line that is not first gets `port + index * 100`. On the foreman path
452
+ # Copse spawns `web` itself with the derived PORT, so its position is
453
+ # irrelevant.
454
+ def web_out_of_position?
455
+ entry = procfile&.web
456
+ !entry.nil? && procfile.entries.first != entry
457
+ end
458
+
459
+ def overmind_web_position_warning
460
+ index = procfile.entries.index(procfile.web)
461
+ "copse: `web` is entry #{index + 1} in Procfile.dev, so overmind will start it on " \
462
+ "#{worktree.port + index * 100} rather than the derived port #{worktree.port} " \
463
+ "(each process gets base + index * 100). Move `web` to the top of Procfile.dev."
464
+ end
465
+
466
+ # Writes the secondaries to a Procfile foreman can run, inside a private
467
+ # directory. The file's contents are commands foreman will execute, and
468
+ # derived hostnames are deliberately reproducible, so a predictable path in a
469
+ # world-writable directory would be a symlink/TOCTOU surface.
470
+ #
471
+ # Returns the Procfile path. The directory is recorded on the instance the
472
+ # moment it exists, so teardown can remove it even if a later step in this
473
+ # method raises -- returning it to the caller would have leaked it.
474
+ def write_temp_procfile
475
+ dir = Dir.mktmpdir("copse-")
476
+ @procfile_dir = dir
477
+ File.chmod(0o700, dir)
478
+ path = File.join(dir, "Procfile")
479
+
480
+ lines = secondaries.map do |entry|
481
+ command, warning = Procfile.signal_transparent(entry.command)
482
+ @out.puts "copse: `#{entry.name}` #{warning}" if warning
483
+ stdin_warning = Procfile.stdin_sensitive_warning(entry.command)
484
+ @out.puts "copse: `#{entry.name}` #{stdin_warning}" if stdin_warning
485
+ "#{entry.name}: #{command}\n"
486
+ end
487
+
488
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
489
+ file.write(lines.join)
490
+ end
491
+
492
+ path
493
+ end
494
+
495
+ # Whether `foreman start` will actually work.
496
+ #
497
+ # Probing is the only reliable answer. `command -v foreman` and
498
+ # File.executable? both succeed on a version-manager shim whose gem is absent
499
+ # for the active Ruby, and the real invocation then dies with a
500
+ # Gem::GemNotFoundException backtrace. Capturing stderr is the point: it is
501
+ # what keeps that backtrace off the developer's screen.
502
+ #
503
+ # Probes with the same environment the real spawn uses -- otherwise the
504
+ # bundler case passes the probe and fails the run.
505
+ def foreman_available?
506
+ foreman_version
507
+ @foreman_probe[:ok]
508
+ end
509
+
510
+ def foreman_version
511
+ return @foreman_probe[:version] if defined?(@foreman_probe)
512
+
513
+ @foreman_probe = { ok: false, version: nil }
514
+
515
+ foreman_env_candidates.each do |candidate|
516
+ out, _err, status = begin
517
+ Open3.capture3(candidate, "foreman", "version")
518
+ rescue Errno::ENOENT, Errno::EACCES
519
+ # Not on this candidate's PATH, or not executable. Try the next one.
520
+ next
521
+ end
522
+
523
+ next unless status.success?
524
+
525
+ # Remember the environment that worked: the real spawn must use the same
526
+ # one, or the probe proves nothing.
527
+ @foreman_env = candidate
528
+ @foreman_probe = { ok: true, version: out.strip }
529
+ break
530
+ end
531
+
532
+ @foreman_probe[:version]
533
+ end
534
+
535
+ # One clear line, never a backtrace.
536
+ def foreman_error_message
537
+ "copse: cannot run `foreman`, which is needed for the #{secondaries.size} non-web " \
538
+ "#{secondaries.size == 1 ? 'process' : 'processes'} in Procfile.dev. " \
539
+ "Add `foreman` to your Gemfile, or install it for the Ruby you are using " \
540
+ "(a version manager keeps gems per Ruby version, so switching versions can " \
541
+ "leave foreman behind)."
542
+ end
543
+
544
+ def foreman_outdated?
545
+ version = foreman_version
546
+ return false if version.nil?
547
+
548
+ Gem::Version.new(version) < Gem::Version.new(MIN_FOREMAN_VERSION)
549
+ rescue ArgumentError
550
+ false
551
+ end
552
+
553
+ def foreman_version_warning
554
+ "copse: foreman #{foreman_version} is older than #{MIN_FOREMAN_VERSION}; " \
555
+ "process teardown is only verified from #{MIN_FOREMAN_VERSION} onward."
556
+ end
557
+ end
558
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Copse
4
+ # Decides whether Copse should set `default_url_options`, and to what.
5
+ #
6
+ # Deliberately Rails-free so every guard can be tested without booting an
7
+ # application -- which matters because only one Rails::Application can be
8
+ # initialized per process.
9
+ module UrlOptions
10
+ # Copse only speaks up when all three are true:
11
+ # * this is development,
12
+ # * the app was actually booted by Copse (COPSE_URL is present), and
13
+ # * the app has not already set its own host.
14
+ #
15
+ # A plain `bin/rails server`, any other environment, and any app with explicit
16
+ # URL options are all left alone.
17
+ def self.apply?(env: ENV, rails_env:, existing_host: nil)
18
+ return false unless rails_env.to_s == "development"
19
+ return false if env["COPSE_URL"].to_s.empty?
20
+ return false unless existing_host.to_s.empty?
21
+
22
+ true
23
+ end
24
+
25
+ # The options to merge. Prefers COPSE_PORT over PORT: under foreman, PORT is
26
+ # rewritten per child, while COPSE_PORT always carries the derived port.
27
+ def self.for(env: ENV)
28
+ options = { host: env["COPSE_HOST"] }
29
+ port = env["COPSE_PORT"] || env["PORT"]
30
+ options[:port] = Integer(port, exception: false) unless port.to_s.empty?
31
+ options.compact
32
+ end
33
+
34
+ def self.url(env: ENV)
35
+ env["COPSE_URL"]
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Copse
4
+ VERSION = "0.1.0"
5
+ end