mxup 0.2.0 → 0.3.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.
data/lib/mxup/config.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'yaml'
4
4
  require 'set'
5
+ require_relative 'env_file'
5
6
 
6
7
  module Mxup
7
8
  # Parsed mxup YAML config. Pure data; no tmux or filesystem side effects.
@@ -13,15 +14,29 @@ module Mxup
13
14
  # rest of the system (Launcher, Reconciler, StatusView…) never has to know
14
15
  # about profiles.
15
16
  class Config
16
- attr_reader :session, :setup, :windows, :layouts, :layout_names,
17
- :profile, :profile_names, :default_profile
17
+ KNOWN_TOP_LEVEL_KEYS = %w[session setup root live_env required_env windows layouts profiles default_profile].freeze
18
+ KNOWN_WINDOW_KEYS = %w[root command env wait_for live_env commands].freeze
19
+ KNOWN_LAYOUT_GROUP_KEYS = %w[panes split].freeze
20
+ KNOWN_PROFILE_KEYS = %w[setup root layouts live_env windows].freeze
21
+
22
+ attr_reader :session, :setup, :root, :windows, :layouts, :layout_names,
23
+ :profile, :profile_names, :default_profile, :live_env,
24
+ :live_env_dir, :required_env, :env_file_path
18
25
 
19
26
  def initialize(path, profile: nil)
20
27
  raw = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
28
+ validate_keys!(raw, KNOWN_TOP_LEVEL_KEYS, 'top level')
21
29
  resolve_profile!(raw, profile)
22
- @session = raw.fetch('session')
23
- @setup = raw['setup']&.strip
24
- @windows = parse_windows(raw.fetch('windows'))
30
+ @session = raw.fetch('session')
31
+ @setup = raw['setup']&.strip
32
+ @live_env_dir = config_base_dir(path)
33
+ @live_env = parse_live_env(raw['live_env'])
34
+ @required_env = parse_required_env(raw['required_env'])
35
+ @env_file_path = env_file_path_for(path)
36
+ load_env_file_into_env!
37
+ @root = resolve_root(raw['root'], path)
38
+ @windows = parse_windows(raw.fetch('windows'))
39
+ validate_window_live_env!
25
40
  @layouts, @layout_names = parse_layouts(raw['layouts'])
26
41
  end
27
42
 
@@ -91,6 +106,8 @@ module Mxup
91
106
  def apply_profile_overrides!(raw, override)
92
107
  return unless override.is_a?(Hash)
93
108
 
109
+ validate_keys!(override, KNOWN_PROFILE_KEYS, "profile '#{@profile}'")
110
+
94
111
  if override.key?('session')
95
112
  raise ArgumentError,
96
113
  "Profile '#{@profile}' cannot override 'session'; profiles of the " \
@@ -98,8 +115,15 @@ module Mxup
98
115
  end
99
116
 
100
117
  raw['setup'] = override['setup'] if override.key?('setup')
118
+ raw['root'] = override['root'] if override.key?('root')
101
119
  raw['layouts'] = override['layouts'] if override.key?('layouts')
102
120
 
121
+ if override.key?('live_env')
122
+ base_le = raw['live_env'] || {}
123
+ prof_le = override['live_env'] || {}
124
+ raw['live_env'] = base_le.merge(prof_le)
125
+ end
126
+
103
127
  removed = []
104
128
  (override['windows'] || {}).each do |wname, woverride|
105
129
  raw['windows'] ||= {}
@@ -135,11 +159,12 @@ module Mxup
135
159
  end
136
160
  end
137
161
 
138
- # Shallow merge with a special case: `env` is itself a hash that should
139
- # be merged (so a profile can tweak one key without redeclaring the rest).
162
+ # Shallow merge with a special case: `env` and `commands` are themselves
163
+ # hashes that should be merged (so a profile can tweak one key without
164
+ # redeclaring the rest).
140
165
  def merge_window(base, override)
141
166
  base.merge(override) do |key, base_val, prof_val|
142
- if key == 'env' && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
167
+ if %w[env commands].include?(key) && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
143
168
  base_val.merge(prof_val)
144
169
  else
145
170
  prof_val
@@ -150,16 +175,165 @@ module Mxup
150
175
  def parse_windows(hash)
151
176
  hash.map do |name, opts|
152
177
  opts ||= {}
178
+ validate_keys!(opts, KNOWN_WINDOW_KEYS, "window '#{name}'")
153
179
  Window.new(
154
180
  name: name,
155
- root: File.expand_path(opts.fetch('root')),
181
+ root: resolve_window_root(opts.fetch('root')),
156
182
  command: opts['command']&.strip,
157
183
  env: opts['env'] || {},
158
- wait_for: WaitSpec.parse(opts['wait_for'])
184
+ wait_for: WaitSpec.parse(opts['wait_for']),
185
+ live_env: Array(opts['live_env']).map(&:to_s),
186
+ commands: parse_window_commands(opts['commands'], name)
159
187
  )
160
188
  end
161
189
  end
162
190
 
191
+ # Per-window named commands: { name => shell command }. Each is a one-off
192
+ # task runnable via `mxup <config> <window>:<name>`. It executes in a copy
193
+ # of the window's environment (root, setup, live_env, env) with its output
194
+ # streamed to the calling shell — it does not touch the live tmux pane.
195
+ def parse_window_commands(raw, window_name)
196
+ return {} if raw.nil?
197
+ unless raw.is_a?(Hash)
198
+ raise ArgumentError,
199
+ "window '#{window_name}': commands must be a map of NAME => command, " \
200
+ "got #{raw.class}"
201
+ end
202
+
203
+ raw.each_with_object({}) do |(name, command), acc|
204
+ if command.nil? || command.to_s.strip.empty?
205
+ raise ArgumentError,
206
+ "window '#{window_name}': command '#{name}' must specify a shell command"
207
+ end
208
+ acc[name.to_s] = command.to_s.strip
209
+ end
210
+ end
211
+
212
+ # Top-level live_env: { VAR_NAME => shell command }. The command's stdout
213
+ # becomes the variable's value; it is re-evaluated on every (re)start and
214
+ # polled by the watcher, so rotating whatever it reads restarts dependents.
215
+ # Commands run in the config's base dir (see #live_env_dir), so relative
216
+ # paths inside them resolve there regardless of a window's own root.
217
+ def parse_live_env(raw)
218
+ return {} if raw.nil?
219
+ unless raw.is_a?(Hash)
220
+ raise ArgumentError, "live_env must be a map of VAR => command, got #{raw.class}"
221
+ end
222
+
223
+ raw.each_with_object({}) do |(name, command), acc|
224
+ if command.nil? || command.to_s.strip.empty?
225
+ raise ArgumentError, "live_env '#{name}' must specify a command"
226
+ end
227
+ acc[name.to_s] = command.to_s.strip
228
+ end
229
+ end
230
+
231
+ # Env vars that must hold a value before `mxup up` will start anything.
232
+ # Two forms:
233
+ # required_env: [DATABASE_URL, API_KEY] # names only
234
+ # required_env: { API_KEY: "from the dashboard" } # name => description
235
+ # Descriptions (optional) are written as comments into the scaffolded
236
+ # env file so the user knows what each value is for.
237
+ def parse_required_env(raw)
238
+ case raw
239
+ when nil
240
+ []
241
+ when Array
242
+ raw.map { |name| RequiredVar.new(name: name.to_s, description: nil) }
243
+ when Hash
244
+ raw.map do |name, desc|
245
+ RequiredVar.new(name: name.to_s, description: desc&.to_s&.strip)
246
+ end
247
+ else
248
+ raise ArgumentError,
249
+ 'required_env must be a list of names or a map of NAME => description, ' \
250
+ "got #{raw.class}"
251
+ end
252
+ end
253
+
254
+ # The `<config-name>.env` file sits next to the config file itself, so
255
+ # several configs sharing a directory (e.g. ~/.config/mxup) don't collide.
256
+ def env_file_path_for(config_path)
257
+ expanded = File.expand_path(config_path)
258
+ File.join(File.dirname(expanded), "#{File.basename(expanded, '.*')}.env")
259
+ end
260
+
261
+ def validate_window_live_env!
262
+ pool = @live_env.keys.to_set
263
+ @windows.each do |win|
264
+ win.live_env.each do |var|
265
+ next if pool.include?(var)
266
+ raise ArgumentError,
267
+ "Window '#{win.name}': live_env '#{var}' not declared in top-level live_env"
268
+ end
269
+ end
270
+ end
271
+
272
+ # Top-level `root` is resolved relative to the config file's "project
273
+ # directory" — that's the directory containing the config, except when
274
+ # the config lives in a project-local `.mxup/` directory, in which case
275
+ # we use its parent. That way a checked-in `.mxup/dev.yml` can write
276
+ # `root: .` and mean "the repo root", not "the .mxup/ folder".
277
+ def resolve_root(raw_root, config_path)
278
+ return nil unless raw_root
279
+ expanded = expand_env_vars(raw_root.to_s, 'root')
280
+ File.expand_path(expanded, config_base_dir(config_path))
281
+ end
282
+
283
+ # Merge the config's `.env` file into the process environment so that
284
+ # `$VAR` references in `root:` fields resolve from the same place the
285
+ # `required_env` gate validates — otherwise a value the user filled into
286
+ # the scaffolded `.env` file would be invisible here (it is only sourced
287
+ # inside each tmux pane) and root expansion would fail with "not set".
288
+ #
289
+ # The live shell environment wins: a var already exported (e.g. via
290
+ # direnv) is never overwritten. Blank file values are skipped so the
291
+ # required_env gate can still report them as missing rather than letting
292
+ # an empty path silently resolve against the wrong base.
293
+ def load_env_file_into_env!
294
+ return if @required_env.empty?
295
+ return unless File.exist?(@env_file_path)
296
+
297
+ EnvFile.new(self).parse.each do |key, value|
298
+ next if value.nil? || value.strip.empty?
299
+ ENV[key] ||= value
300
+ end
301
+ end
302
+
303
+ # Substitute $VAR and ${VAR} references from the process environment.
304
+ # An unset variable is a hard error: silently expanding to "" would
305
+ # resolve the path against the wrong base, so we fail loudly instead.
306
+ def expand_env_vars(value, context)
307
+ value.gsub(/\$\{(\w+)\}|\$(\w+)/) do
308
+ name = Regexp.last_match(1) || Regexp.last_match(2)
309
+ ENV[name] || raise(ArgumentError,
310
+ "#{context}: environment variable '#{name}' is not set")
311
+ end
312
+ end
313
+
314
+ def validate_keys!(hash, allowed, context)
315
+ return unless hash.is_a?(Hash)
316
+ extra = hash.keys.map(&:to_s) - allowed
317
+ return if extra.empty?
318
+ raise ArgumentError,
319
+ "#{context}: unknown key(s) #{extra.map(&:inspect).join(', ')} " \
320
+ "(allowed: #{allowed.join(', ')})"
321
+ end
322
+
323
+ def config_base_dir(config_path)
324
+ dir = File.dirname(File.expand_path(config_path))
325
+ File.basename(dir) == LOCAL_CONFIG_DIR ? File.dirname(dir) : dir
326
+ end
327
+
328
+ # When the config declares a top-level `root`, relative window roots are
329
+ # resolved against it. Absolute paths and `~/...` are unaffected because
330
+ # File.expand_path ignores the base in those cases.
331
+ def resolve_window_root(window_root)
332
+ base = @root || Dir.pwd
333
+ expanded = expand_env_vars(window_root.to_s, 'window root')
334
+ File.expand_path(expanded, base)
335
+ end
336
+
163
337
  def parse_layouts(raw)
164
338
  return [{}, []] if raw.nil?
165
339
 
@@ -175,6 +349,8 @@ module Mxup
175
349
 
176
350
  groups_hash.each do |group_name, group_opts|
177
351
  group_opts ||= {}
352
+ validate_keys!(group_opts, KNOWN_LAYOUT_GROUP_KEYS,
353
+ "layout '#{layout_name}' group '#{group_name}'")
178
354
  pane_names = Array(group_opts['panes'])
179
355
 
180
356
  pane_names.each do |pn|
@@ -203,12 +379,15 @@ module Mxup
203
379
  end
204
380
  end
205
381
 
206
- Window = Struct.new(:name, :root, :command, :env, :wait_for, keyword_init: true)
207
- PaneGroup = Struct.new(:name, :window_names, :split, keyword_init: true)
382
+ Window = Struct.new(:name, :root, :command, :env, :wait_for, :live_env, :commands, keyword_init: true)
383
+ PaneGroup = Struct.new(:name, :window_names, :split, keyword_init: true)
384
+ RequiredVar = Struct.new(:name, :description, keyword_init: true)
208
385
 
209
386
  # A readiness check attached to a window.
210
387
  WaitSpec = Struct.new(:type, :target, :timeout, :interval, :label, keyword_init: true) do
211
388
  CHECK_TYPES = %w[tcp http path script].freeze
389
+ OPTION_KEYS = %w[timeout interval label].freeze
390
+ ALLOWED_KEYS = (CHECK_TYPES + OPTION_KEYS).freeze
212
391
 
213
392
  def self.parse(raw)
214
393
  case raw
@@ -217,6 +396,12 @@ module Mxup
217
396
  when String
218
397
  new(type: :tcp, target: raw, timeout: nil, interval: 2, label: raw)
219
398
  when Hash
399
+ extra = raw.keys.map(&:to_s) - ALLOWED_KEYS
400
+ unless extra.empty?
401
+ raise ArgumentError,
402
+ "wait_for: unknown key(s) #{extra.map(&:inspect).join(', ')} " \
403
+ "(allowed: #{ALLOWED_KEYS.join(', ')})"
404
+ end
220
405
  found = CHECK_TYPES & raw.keys
221
406
  unless found.size == 1
222
407
  raise ArgumentError,
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mxup
4
+ # Reads, validates, and scaffolds the `<config-name>.env` file that supplies
5
+ # values for the config's `required_env` vars.
6
+ #
7
+ # The file is a plain dotenv-style `KEY=value` list. `mxup up` checks every
8
+ # declared required var has a non-empty value here before starting anything;
9
+ # the values themselves reach each window because the launcher script sources
10
+ # this file (see Launcher#build_script).
11
+ class EnvFile
12
+ def initialize(config)
13
+ @config = config
14
+ @path = config.env_file_path
15
+ end
16
+
17
+ # Required vars that are still missing a value (key absent, or value blank).
18
+ def missing
19
+ values = parse
20
+ @config.required_env.reject { |rv| present?(values[rv.name]) }
21
+ end
22
+
23
+ # Create the file, or append entries for any required vars not yet present
24
+ # as keys. Existing content and values are preserved. Returns the path.
25
+ def scaffold!
26
+ existing = File.exist?(@path) ? File.read(@path) : nil
27
+ existing = nil if existing && existing.strip.empty?
28
+
29
+ present_keys = parse(existing || '').keys
30
+ additions = @config.required_env.reject { |rv| present_keys.include?(rv.name) }
31
+ return @path if additions.empty? && existing
32
+
33
+ parts = []
34
+ parts << if existing
35
+ existing.rstrip
36
+ else
37
+ "# Required environment variables for mxup.\n" \
38
+ '# Fill in the values below, then re-run `mxup up`.'
39
+ end
40
+ additions.each do |rv|
41
+ block = []
42
+ block << "# #{rv.description}" if rv.description && !rv.description.empty?
43
+ block << "#{rv.name}="
44
+ parts << block.join("\n")
45
+ end
46
+
47
+ File.write(@path, parts.join("\n\n") + "\n")
48
+ @path
49
+ end
50
+
51
+ # Parse the file (or a given string) into a { KEY => value } hash. Blank
52
+ # lines and `#` comments are skipped; surrounding quotes are stripped.
53
+ def parse(contents = nil)
54
+ contents ||= File.exist?(@path) ? File.read(@path) : ''
55
+ contents.each_line.each_with_object({}) do |line, acc|
56
+ line = line.strip
57
+ next if line.empty? || line.start_with?('#')
58
+
59
+ key, sep, value = line.partition('=')
60
+ next if sep.empty? || key.strip.empty?
61
+
62
+ acc[key.strip] = unquote(value.strip)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def present?(value)
69
+ !value.nil? && !value.strip.empty?
70
+ end
71
+
72
+ def unquote(value)
73
+ if value.length >= 2 &&
74
+ ((value.start_with?('"') && value.end_with?('"')) ||
75
+ (value.start_with?("'") && value.end_with?("'")))
76
+ value[1..-2]
77
+ else
78
+ value
79
+ end
80
+ end
81
+ end
82
+ end
data/lib/mxup/launcher.rb CHANGED
@@ -52,31 +52,33 @@ module Mxup
52
52
  # Build (but don't write) the shell text for a window's launcher script.
53
53
  # Exposed for inspection/tests; production callers use #write_all.
54
54
  def build_script(window)
55
- sections = []
56
- sections << "# mxup launcher for #{window.name}"
57
- sections << "cd #{Shellwords.escape(window.root)}"
58
-
59
- if @config.setup && !@config.setup.empty?
60
- sections << ''
61
- @config.setup.split("\n").each { |l| sections << l }
62
- end
63
-
64
- if window.wait_for
65
- sections << ''
66
- sections.concat(build_wait_block(window.wait_for))
67
- end
68
-
69
- if window.env.any?
70
- sections << ''
71
- window.env.each { |k, v| sections << "export #{k}=#{Shellwords.escape(v)}" }
72
- end
73
-
74
- if window.command && !window.command.empty?
75
- sections << ''
76
- sections << window.command
77
- end
55
+ compose(
56
+ ["# mxup launcher for #{window.name}"],
57
+ cd_lines(window),
58
+ required_env_lines,
59
+ setup_lines,
60
+ wait_lines(window),
61
+ live_env_lines(window),
62
+ env_lines(window),
63
+ command_lines(window.command)
64
+ )
65
+ end
78
66
 
79
- sections.join("\n") + "\n"
67
+ # Build the shell text for one of a window's named `commands`. It mirrors
68
+ # the launcher's environment (root, required_env, setup, live_env, env) but
69
+ # runs the given one-off command instead of the window's main command, and
70
+ # omits the wait_for readiness gate (that guards the long-running command,
71
+ # not arbitrary tasks). Meant to be run as a subprocess, not sent to tmux.
72
+ def build_command_script(window, command)
73
+ compose(
74
+ ["# mxup command for #{window.name}"],
75
+ cd_lines(window),
76
+ required_env_lines,
77
+ setup_lines,
78
+ live_env_lines(window),
79
+ env_lines(window),
80
+ command_lines(command)
81
+ )
80
82
  end
81
83
 
82
84
  # Shell snippet (array of lines) implementing a wait_for check.
@@ -111,10 +113,68 @@ module Mxup
111
113
 
112
114
  private
113
115
 
116
+ # Join the section line-arrays into one script. Each helper returns [] when
117
+ # its section is absent, or a leading '' followed by its lines when present,
118
+ # so blank-line separators fall out naturally.
119
+ def compose(*sections)
120
+ sections.flatten(1).join("\n") + "\n"
121
+ end
122
+
123
+ def cd_lines(window)
124
+ ["cd #{Shellwords.escape(window.root)}"]
125
+ end
126
+
127
+ # Required env vars: source the (already-validated) env file so every
128
+ # window inherits the values. Done before setup/command so they can
129
+ # reference them. `set -a` auto-exports each assignment to children.
130
+ def required_env_lines
131
+ return [] unless @config.required_env.any?
132
+ path = Shellwords.escape(@config.env_file_path)
133
+ ['', "set -a; [ -f #{path} ] && . #{path}; set +a"]
134
+ end
135
+
136
+ def setup_lines
137
+ return [] unless @config.setup && !@config.setup.empty?
138
+ ['', *@config.setup.split("\n")]
139
+ end
140
+
141
+ def wait_lines(window)
142
+ return [] unless window.wait_for
143
+ ['', *build_wait_block(window.wait_for)]
144
+ end
145
+
146
+ def live_env_lines(window)
147
+ return [] unless window.live_env.any?
148
+ dir = Shellwords.escape(@config.live_env_dir)
149
+ exports = window.live_env.map do |var|
150
+ command = @config.live_env.fetch(var)
151
+ # This launcher is sourced by the pane's interactive shell (often
152
+ # zsh), whose quoting/word-splitting rules differ from sh's. Rather
153
+ # than embed the raw command — and have that shell mangle its quotes —
154
+ # ship it as an opaque base64 blob and decode+run it under a fixed
155
+ # `sh`. Only [A-Za-z0-9+/=] ever travels through the outer shell.
156
+ encoded = [command].pack('m0')
157
+ %(export #{var}="$({ cd #{dir} && echo #{encoded} | base64 -d | sh; } 2>/dev/null)")
158
+ end
159
+ ['', *exports]
160
+ end
161
+
162
+ def env_lines(window)
163
+ return [] unless window.env.any?
164
+ ['', *window.env.map { |k, v| "export #{k}=#{Shellwords.escape(v)}" }]
165
+ end
166
+
167
+ def command_lines(command)
168
+ return [] unless command && !command.empty?
169
+ ['', command]
170
+ end
171
+
114
172
  def content?(window)
115
- (@config.setup && !@config.setup.empty?) ||
173
+ @config.required_env.any? ||
174
+ (@config.setup && !@config.setup.empty?) ||
116
175
  window.wait_for ||
117
176
  window.env.any? ||
177
+ window.live_env.any? ||
118
178
  (window.command && !window.command.empty?)
119
179
  end
120
180
 
@@ -220,6 +220,13 @@ module Mxup
220
220
  group.window_names.drop(1).each_with_index do |wn, i|
221
221
  win = @config.window_by_name(wn)
222
222
  Tmux.split_window(@session, group.name, win.root, target_pane: i)
223
+ # Re-tile after every split. Splitting always halves the most recent
224
+ # pane, so without rebalancing the panes pile into an ever-shrinking
225
+ # region; past ~4 panes tmux refuses the split as the target is too
226
+ # small. The skipped pane then surfaces downstream as the confusing
227
+ # "can't find pane N" error. Re-tiling keeps every pane large enough
228
+ # to split again.
229
+ Tmux.select_layout(@session, group.name, 'tiled')
223
230
  end
224
231
 
225
232
  Tmux.select_layout(@session, group.name, group.split)
data/lib/mxup/runner.rb CHANGED
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'fileutils'
4
+ require 'rbconfig'
5
+
3
6
  module Mxup
4
7
  # Public programmatic API. Composes the focused helpers — nothing here
5
8
  # should contain real business logic, only wiring and delegation.
@@ -29,7 +32,10 @@ module Mxup
29
32
 
30
33
  def up
31
34
  handle_profile_switch
35
+ require_env!
32
36
  reconciler.up
37
+ ensure_watcher unless @dry_run
38
+ $stdout.puts "Attach with: tmux attach -t #{@session}" unless @dry_run
33
39
  end
34
40
 
35
41
  def status(lines:)
@@ -47,6 +53,7 @@ module Mxup
47
53
  return
48
54
  end
49
55
 
56
+ stop_watcher
50
57
  graceful_stop.call
51
58
  Tmux.kill_session(@session)
52
59
  launcher.cleanup
@@ -97,8 +104,73 @@ module Mxup
97
104
  force: force, quiet: quiet)
98
105
  end
99
106
 
107
+ # Run a window's named command (`mxup <config> <window>:<name>`) in a copy
108
+ # of that window's environment, streaming its output to the calling shell
109
+ # and exiting with its return code. Does not touch the live tmux pane, so
110
+ # it works whether or not the session is up.
111
+ def run_command(window_name, command_name)
112
+ win = @config.window_by_name(window_name) ||
113
+ abort("Window '#{window_name}' not found in config.")
114
+
115
+ command = win.commands[command_name] || abort(unknown_command_message(win, command_name))
116
+
117
+ script = launcher.build_command_script(win, command)
118
+
119
+ if @dry_run
120
+ $stdout.puts "[dry-run] Would run #{window_name}:#{command_name} in #{win.root}:"
121
+ $stdout.puts command
122
+ return
123
+ end
124
+
125
+ exit(run_script(script))
126
+ end
127
+
100
128
  private
101
129
 
130
+ def unknown_command_message(win, command_name)
131
+ available = win.commands.keys
132
+ list = available.empty? ? '(none defined)' : available.join(', ')
133
+ "Window '#{win.name}' has no command '#{command_name}'. Available: #{list}"
134
+ end
135
+
136
+ # Execute a generated script through the user's shell, inheriting stdio so
137
+ # output streams live to the caller. Returns the child's exit status.
138
+ def run_script(script)
139
+ shell = ENV['SHELL'] || '/bin/sh'
140
+ system(shell, '-c', script)
141
+ $?.exitstatus || 1
142
+ end
143
+
144
+ # --- required env gate -------------------------------------------------
145
+
146
+ # Before `up` does anything, every var declared in `required_env` must have
147
+ # a non-empty value in the config's `.env` file. If some are missing, write
148
+ # (or extend) a template so the user has something to fill in, then abort
149
+ # with instructions. Under --dry-run we only report — no file is written.
150
+ def require_env!
151
+ return if @config.required_env.empty?
152
+
153
+ env_file = EnvFile.new(@config)
154
+ missing = env_file.missing
155
+ return if missing.empty?
156
+
157
+ if @dry_run
158
+ names = missing.map(&:name).join(', ')
159
+ $stdout.puts "[dry-run] Required env not set: #{names} (#{@config.env_file_path})"
160
+ return
161
+ end
162
+
163
+ env_file.scaffold!
164
+ details = missing.map do |rv|
165
+ rv.description ? " #{rv.name} — #{rv.description}" : " #{rv.name}"
166
+ end
167
+ abort <<~MSG.chomp
168
+ Missing required environment variable(s):
169
+ #{details.join("\n")}
170
+ Fill in the values in #{@config.env_file_path}, then re-run `mxup up`.
171
+ MSG
172
+ end
173
+
102
174
  # --- profile switching -------------------------------------------------
103
175
 
104
176
  # Before `up` runs, make sure we aren't stepping on a different profile
@@ -141,12 +213,8 @@ module Mxup
141
213
  end
142
214
  # Two C-c's handle both "foreground is command" and "foreground is a
143
215
  # sub-prompt waiting on input"; the sleep gives the shell time to redraw.
144
- delay = Runner.interrupt_delay
145
- Tmux.send_interrupt(@session, target_ref)
146
- sleep delay
147
- Tmux.send_interrupt(@session, target_ref)
148
- sleep delay
149
- Tmux.send_keys(@session, target_ref, launcher.command_for(win))
216
+ Restarter.restart(@session, target_ref, launcher.command_for(win),
217
+ interrupt_delay: Runner.interrupt_delay)
150
218
  $stdout.puts " #{win.name}: restarted"
151
219
  end
152
220
 
@@ -186,6 +254,65 @@ module Mxup
186
254
  end
187
255
  end
188
256
 
257
+ # --- watcher supervisor -----------------------------------------------
258
+
259
+ # Start a background `mxup watch` if the config declares any live_env
260
+ # vars. Always tear down any prior watcher first — its in-memory config
261
+ # may be stale (e.g. the user edited the YAML before re-running `up`).
262
+ def ensure_watcher
263
+ return if @config.live_env.empty?
264
+
265
+ stop_watcher
266
+ log_path = Watcher.log_path(@session)
267
+ pid_path = Watcher.pid_path(@session)
268
+ FileUtils.mkdir_p(File.dirname(log_path))
269
+
270
+ pid = Process.spawn(*watcher_command,
271
+ [:out, :err] => [log_path, 'a'],
272
+ pgroup: true)
273
+ Process.detach(pid)
274
+ File.write(pid_path, "#{pid}\n")
275
+ $stdout.puts "Watcher started (pid #{pid}, watching #{@config.live_env.size} command(s))."
276
+ end
277
+
278
+ def stop_watcher
279
+ pid_path = Watcher.pid_path(@session)
280
+ return unless File.exist?(pid_path)
281
+
282
+ pid = File.read(pid_path).strip.to_i
283
+ if pid > 0 && watcher_alive?(pid)
284
+ begin
285
+ Process.kill(:TERM, pid)
286
+ rescue Errno::ESRCH, Errno::EPERM
287
+ # already gone
288
+ end
289
+ end
290
+ File.delete(pid_path) if File.exist?(pid_path)
291
+ state = Watcher.state_path(@session)
292
+ File.delete(state) if File.exist?(state)
293
+ end
294
+
295
+ def watcher_alive?(pid)
296
+ Process.kill(0, pid)
297
+ true
298
+ rescue Errno::ESRCH, Errno::EPERM
299
+ false
300
+ end
301
+
302
+ def watcher_command
303
+ cmd = mxup_executable + ['watch', @session]
304
+ cmd.concat(['-p', @config.profile]) if @config.profile
305
+ cmd
306
+ end
307
+
308
+ def mxup_executable
309
+ # Re-invoke this same Ruby + same gem entry point so a child watcher
310
+ # runs the exact code that started it (important when the user runs
311
+ # mxup from a checkout rather than the installed gem).
312
+ bin = File.expand_path('../../bin/mxup', __dir__)
313
+ File.exist?(bin) ? [RbConfig.ruby, bin] : ['mxup']
314
+ end
315
+
189
316
  # --- lazily-built collaborators ---------------------------------------
190
317
 
191
318
  def resolver