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.
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
+ require 'json'
4
5
 
5
6
  module Mxup
6
7
  # Renders `mxup status` output.
7
8
  class StatusView
8
9
  INDENT = ' '
10
+ # Max length for the `command:` line in `mxup status` (long JVM -cp, etc.).
11
+ STATUS_COMMAND_MAX = 256
9
12
 
10
13
  def initialize(config, resolver:, out: nil)
11
14
  @config = config
@@ -34,6 +37,7 @@ module Mxup
34
37
  bits << "layout: #{active}" if active
35
38
  suffix = bits.empty? ? '' : " (#{bits.join(', ')})"
36
39
  out.puts "SESSION: #{@session} — up since #{created_at}#{suffix}"
40
+ render_watcher_line
37
41
  out.puts
38
42
 
39
43
  panes = Tmux.list_panes(@session)
@@ -54,6 +58,43 @@ module Mxup
54
58
 
55
59
  private
56
60
 
61
+ def render_watcher_line
62
+ return if @config.live_env.empty?
63
+
64
+ pid_path = Watcher.pid_path(@session)
65
+ state_path = Watcher.state_path(@session)
66
+ pid = File.exist?(pid_path) ? File.read(pid_path).strip.to_i : nil
67
+ alive = pid && pid > 0 && watcher_alive?(pid)
68
+
69
+ if alive
70
+ count = @config.live_env.size
71
+ last = last_restart_summary(state_path)
72
+ out.puts "watcher: pid #{pid} (#{count} command#{count == 1 ? '' : 's'}#{last})"
73
+ else
74
+ out.puts 'watcher: not running'
75
+ end
76
+ end
77
+
78
+ def watcher_alive?(pid)
79
+ Process.kill(0, pid)
80
+ true
81
+ rescue Errno::ESRCH, Errno::EPERM
82
+ false
83
+ end
84
+
85
+ def last_restart_summary(state_path)
86
+ return '' unless File.exist?(state_path)
87
+
88
+ data = JSON.parse(File.read(state_path))
89
+ last = data['last_restart'] || {}
90
+ return '' if last.empty?
91
+
92
+ win, ts = last.max_by { |_, v| v }
93
+ ", last restart #{win} at #{Time.at(ts).strftime('%H:%M:%S')}"
94
+ rescue StandardError
95
+ ''
96
+ end
97
+
57
98
  def render_group(group, panes, lines, printed)
58
99
  group_panes = panes.select { |p| p[:name] == group.name }
59
100
  .sort_by { |p| p[:pane_index] }
@@ -139,11 +180,19 @@ module Mxup
139
180
  def running_status(pane, indent)
140
181
  leaf = ProcessProbe.leaf_pid(pane[:pid])
141
182
  cmd = ProcessProbe.ps_field(leaf, 'args') || pane[:fg_cmd]
183
+ cmd = truncate_status_command(cmd)
142
184
  elapsed = ProcessProbe.ps_field(leaf, 'etime')&.strip || '?'
143
185
  started = ProcessProbe.ps_field(leaf, 'lstart') || '?'
144
186
  "RUNNING pid=#{leaf} elapsed=#{elapsed} fg=#{pane[:fg_cmd]}\n" \
145
187
  "#{indent}command: #{cmd}\n" \
146
188
  "#{indent}started: #{started}"
147
189
  end
190
+
191
+ def truncate_status_command(str)
192
+ return str if str.nil? || str.empty?
193
+ return str if str.length <= STATUS_COMMAND_MAX
194
+
195
+ "#{str[0, STATUS_COMMAND_MAX - 3]}..."
196
+ end
148
197
  end
149
198
  end
data/lib/mxup/tmux.rb CHANGED
@@ -7,6 +7,12 @@ module Mxup
7
7
  # knows about mxup's own config. Kept as a module so callers can reference
8
8
  # Mxup::Tmux.list_panes(...) without instantiating anything.
9
9
  module Tmux
10
+ # Raised when a tmux command that is expected to succeed exits non-zero.
11
+ # Carries tmux's own stderr so a failure surfaces with context (e.g. a
12
+ # "pane too small" split) at the point it happens, instead of as a
13
+ # confusing downstream error like "can't find pane 4".
14
+ class Error < StandardError; end
15
+
10
16
  module_function
11
17
 
12
18
  # --- inspection --------------------------------------------------------
@@ -54,26 +60,26 @@ module Mxup
54
60
  # --- mutation ----------------------------------------------------------
55
61
 
56
62
  def new_session(name, first_window_name, root)
57
- system("tmux new-session -d -s #{esc(name)} -n #{esc(first_window_name)} -c #{esc(root)}")
63
+ run!('new-session', "tmux new-session -d -s #{esc(name)} -n #{esc(first_window_name)} -c #{esc(root)}")
58
64
  end
59
65
 
60
66
  def new_window(session, name, root)
61
- system("tmux new-window -t #{esc(session)} -n #{esc(name)} -c #{esc(root)}")
67
+ run!('new-window', "tmux new-window -t #{esc(session)} -n #{esc(name)} -c #{esc(root)}")
62
68
  end
63
69
 
64
70
  def split_window(session, window, root, target_pane: nil)
65
71
  target = target_pane \
66
72
  ? "#{esc(session)}:#{esc(window)}.#{target_pane}" \
67
73
  : "#{esc(session)}:#{esc(window)}"
68
- system("tmux split-window -t #{target} -c #{esc(root)} -d")
74
+ run!('split-window', "tmux split-window -t #{target} -c #{esc(root)} -d")
69
75
  end
70
76
 
71
77
  def select_layout(session, window, layout)
72
- system("tmux select-layout -t #{esc(session)}:#{esc(window)} #{esc(layout)}")
78
+ run!('select-layout', "tmux select-layout -t #{esc(session)}:#{esc(window)} #{esc(layout)}")
73
79
  end
74
80
 
75
81
  def join_pane(session, src_window, dst_window)
76
- system("tmux join-pane -s #{esc(session)}:#{esc(src_window)} -t #{esc(session)}:#{esc(dst_window)} -d")
82
+ run!('join-pane', "tmux join-pane -s #{esc(session)}:#{esc(src_window)} -t #{esc(session)}:#{esc(dst_window)} -d")
77
83
  end
78
84
 
79
85
  # Break a pane out into its own window. Without an explicit -t target,
@@ -81,20 +87,20 @@ module Mxup
81
87
  # would send it to whichever session the user happens to be attached to
82
88
  # when running tests. Always pin the destination to the source session.
83
89
  def break_pane(session, window, pane_index, new_window_name)
84
- system("tmux break-pane -s #{esc(session)}:#{esc(window)}.#{pane_index} " \
85
- "-t #{esc(session)}: -n #{esc(new_window_name)} -d")
90
+ run!('break-pane', "tmux break-pane -s #{esc(session)}:#{esc(window)}.#{pane_index} " \
91
+ "-t #{esc(session)}: -n #{esc(new_window_name)} -d")
86
92
  end
87
93
 
88
94
  def rename_window(session, old_name, new_name)
89
- system("tmux rename-window -t #{esc(session)}:#{esc(old_name)} #{esc(new_name)}")
95
+ run!('rename-window', "tmux rename-window -t #{esc(session)}:#{esc(old_name)} #{esc(new_name)}")
90
96
  end
91
97
 
92
98
  def set_pane_title(session, window, pane_index, title)
93
- system("tmux select-pane -t #{esc(session)}:#{esc(window)}.#{pane_index} -T #{esc(title)}")
99
+ run!('select-pane', "tmux select-pane -t #{esc(session)}:#{esc(window)}.#{pane_index} -T #{esc(title)}")
94
100
  end
95
101
 
96
102
  def set_environment(session, key, value)
97
- system("tmux set-environment -t #{esc(session)} #{esc(key)} #{esc(value)}")
103
+ run!('set-environment', "tmux set-environment -t #{esc(session)} #{esc(key)} #{esc(value)}")
98
104
  end
99
105
 
100
106
  # Returns the value stored in the session's environment, or nil if unset
@@ -114,11 +120,11 @@ module Mxup
114
120
  end
115
121
 
116
122
  def send_keys(session, target, keys)
117
- system("tmux send-keys -t #{esc(session)}:#{esc(target)} #{esc(keys)} Enter")
123
+ run!('send-keys', "tmux send-keys -t #{esc(session)}:#{esc(target)} #{esc(keys)} Enter")
118
124
  end
119
125
 
120
126
  def send_interrupt(session, target)
121
- system("tmux send-keys -t #{esc(session)}:#{esc(target)} C-c")
127
+ run!('send-keys', "tmux send-keys -t #{esc(session)}:#{esc(target)} C-c")
122
128
  end
123
129
 
124
130
  def move_window(session, src_name, dst_index)
@@ -139,5 +145,16 @@ module Mxup
139
145
  def esc(val)
140
146
  Shellwords.escape(val.to_s)
141
147
  end
148
+
149
+ # Run a tmux command that must succeed. Captures stdout+stderr so a
150
+ # failure can be re-raised with tmux's own message; returns true on
151
+ # success. Best-effort calls (predicates, teardown, reordering) keep
152
+ # using system() directly and stay tolerant of failure.
153
+ def run!(action, cmd)
154
+ output = `#{cmd} 2>&1`.strip
155
+ return true if $?.success?
156
+
157
+ raise Error, "tmux #{action} failed#{output.empty? ? '' : ": #{output}"}"
158
+ end
142
159
  end
143
160
  end
data/lib/mxup/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mxup
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'shellwords'
6
+
7
+ module Mxup
8
+ # Re-evaluates the commands declared in `config.live_env` and restarts
9
+ # dependent windows when a command's output changes.
10
+ #
11
+ # Typically launched as a detached child process from `Runner#up` via
12
+ # `mxup watch <session>`; can also be invoked directly in the foreground
13
+ # for debugging.
14
+ class Watcher
15
+ POLL_INTERVAL = 2
16
+
17
+ def initialize(config, session: config.session, launcher: nil,
18
+ out: $stdout, err: $stderr, interval: POLL_INTERVAL)
19
+ @config = config
20
+ @session = session
21
+ @launcher = launcher || Launcher.new(config)
22
+ @out = out
23
+ @err = err
24
+ @interval = interval
25
+ @stop = false
26
+ @cmd_to_windows = build_index
27
+ @outputs = @cmd_to_windows.keys.each_with_object({}) { |c, h| h[c] = evaluate(c) }
28
+ @state = { pid: Process.pid, started_at: Time.now.to_i, last_restart: {} }
29
+ end
30
+
31
+ # Path of the watcher state file inside the session's runtime dir.
32
+ def self.state_path(session, runtime_root: RUNTIME_DIR)
33
+ File.join(runtime_root, session, 'watcher.state')
34
+ end
35
+
36
+ def self.pid_path(session, runtime_root: RUNTIME_DIR)
37
+ File.join(runtime_root, session, 'watcher.pid')
38
+ end
39
+
40
+ def self.log_path(session, runtime_root: RUNTIME_DIR)
41
+ File.join(runtime_root, session, 'watcher.log')
42
+ end
43
+
44
+ def run
45
+ install_signal_handlers
46
+ write_state
47
+ log "watching #{@cmd_to_windows.size} command(s) for session #{@session}"
48
+
49
+ until @stop
50
+ unless Tmux.has_session?(@session)
51
+ log "session #{@session} is gone; exiting"
52
+ break
53
+ end
54
+
55
+ @cmd_to_windows.each_key { |cmd| check(cmd) }
56
+ sleep @interval
57
+ end
58
+
59
+ cleanup_state
60
+ 0
61
+ end
62
+
63
+ # Exposed for tests.
64
+ def tick
65
+ @cmd_to_windows.each_key { |cmd| check(cmd) }
66
+ end
67
+
68
+ def commands
69
+ @cmd_to_windows.keys
70
+ end
71
+
72
+ private
73
+
74
+ def build_index
75
+ idx = Hash.new { |h, k| h[k] = [] }
76
+ @config.windows.each do |win|
77
+ win.live_env.each do |var|
78
+ cmd = @config.live_env[var]
79
+ idx[cmd] << win.name unless idx[cmd].include?(win.name)
80
+ end
81
+ end
82
+ idx
83
+ end
84
+
85
+ # Run a live_env command in the config base dir and return its stdout,
86
+ # or nil if it exits non-zero (so a transiently failing command reads as
87
+ # "no value" rather than an empty string change).
88
+ def evaluate(command)
89
+ out = `{ cd #{Shellwords.escape(@config.live_env_dir)} && #{command}; } 2>/dev/null`
90
+ $?.success? ? out : nil
91
+ rescue StandardError
92
+ nil
93
+ end
94
+
95
+ def check(command)
96
+ current = evaluate(command)
97
+ previous = @outputs[command]
98
+ return if current == previous
99
+
100
+ @outputs[command] = current
101
+ return if current.nil? # command now failing; don't trigger on value->none
102
+
103
+ windows = @cmd_to_windows[command]
104
+ log "live_env output changed; restarting: #{windows.join(', ')}"
105
+ windows.each { |wn| restart_window(wn) }
106
+ end
107
+
108
+ def restart_window(window_name)
109
+ win = @config.window_by_name(window_name)
110
+ return unless win
111
+
112
+ target = pane_target(window_name)
113
+ unless target
114
+ log " #{window_name}: not in session, skipping"
115
+ return
116
+ end
117
+
118
+ Restarter.restart(@session, target, @launcher.command_for(win))
119
+ @state[:last_restart][window_name] = Time.now.to_i
120
+ write_state
121
+ end
122
+
123
+ def pane_target(window_name)
124
+ PaneResolver.new(@config, session: @session).pane_target(window_name)
125
+ end
126
+
127
+ def install_signal_handlers
128
+ %w[TERM INT].each do |sig|
129
+ Signal.trap(sig) { @stop = true }
130
+ end
131
+ end
132
+
133
+ def write_state
134
+ path = self.class.state_path(@session)
135
+ FileUtils.mkdir_p(File.dirname(path))
136
+ File.write(path, JSON.pretty_generate(@state.merge(commands: commands)))
137
+ rescue StandardError => e
138
+ log "warn: could not write state: #{e.message}"
139
+ end
140
+
141
+ def cleanup_state
142
+ path = self.class.state_path(@session)
143
+ File.delete(path) if File.exist?(path)
144
+ rescue StandardError
145
+ # best-effort
146
+ end
147
+
148
+ def log(msg)
149
+ @out.puts "[mxup watch #{@session}] #{Time.now.strftime('%H:%M:%S')} #{msg}"
150
+ @out.flush if @out.respond_to?(:flush)
151
+ end
152
+ end
153
+
154
+ # Shared "restart this window in-place" helper. Used by Runner#restart_existing
155
+ # and by the Watcher daemon. Sends two Ctrl-Cs and re-sources the launcher.
156
+ module Restarter
157
+ module_function
158
+
159
+ def restart(session, target, command_keys, interrupt_delay: nil)
160
+ delay = interrupt_delay || (defined?(Runner) ? Runner.interrupt_delay : 1.0)
161
+ Tmux.send_interrupt(session, target)
162
+ sleep delay
163
+ Tmux.send_interrupt(session, target)
164
+ sleep delay
165
+ Tmux.send_keys(session, target, command_keys)
166
+ end
167
+ end
168
+ end
data/lib/mxup.rb CHANGED
@@ -9,6 +9,7 @@
9
9
  #
10
10
  # Internals are organised into focused modules under lib/mxup/:
11
11
  # Config / Window / PaneGroup / WaitSpec — pure data
12
+ # EnvFile — required_env validation + .env scaffolding
12
13
  # Tmux — thin tmux(1) wrapper
13
14
  # Launcher — per-window launcher scripts
14
15
  # PaneResolver — logical-name → tmux target
@@ -16,6 +17,7 @@
16
17
  # LayoutManager — layout switching (flatten + regroup)
17
18
  # StatusView — `status` rendering
18
19
  # ExecRunner — `exec` with marker + timeout
20
+ # Watcher / Restarter — live_env command-output daemon
19
21
  # GracefulStop — cooperative SIGINT-then-wait
20
22
  # ProcessProbe — pane → real leaf process info
21
23
  # Runner — facade delegating to the above
@@ -24,12 +26,14 @@
24
26
  require_relative 'mxup/version'
25
27
 
26
28
  module Mxup
27
- CONFIG_DIR = File.expand_path('~/.config/mxup')
28
- RUNTIME_DIR = File.expand_path('~/.local/share/mxup')
29
- SHELLS = %w[zsh bash sh fish dash].freeze
29
+ CONFIG_DIR = File.expand_path('~/.config/mxup')
30
+ LOCAL_CONFIG_DIR = '.mxup'
31
+ RUNTIME_DIR = File.expand_path('~/.local/share/mxup')
32
+ SHELLS = %w[zsh bash sh fish dash].freeze
30
33
  end
31
34
 
32
35
  require_relative 'mxup/config'
36
+ require_relative 'mxup/env_file'
33
37
  require_relative 'mxup/tmux'
34
38
  require_relative 'mxup/launcher'
35
39
  require_relative 'mxup/process_probe'
@@ -39,5 +43,6 @@ require_relative 'mxup/layout_manager'
39
43
  require_relative 'mxup/reconciler'
40
44
  require_relative 'mxup/status_view'
41
45
  require_relative 'mxup/exec_runner'
46
+ require_relative 'mxup/watcher'
42
47
  require_relative 'mxup/runner'
43
48
  require_relative 'mxup/cli'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mxup
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vladislav Saifulin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-04-25 00:00:00.000000000 Z
11
+ date: 2026-07-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: minitest
@@ -57,6 +57,7 @@ files:
57
57
  - lib/mxup.rb
58
58
  - lib/mxup/cli.rb
59
59
  - lib/mxup/config.rb
60
+ - lib/mxup/env_file.rb
60
61
  - lib/mxup/exec_runner.rb
61
62
  - lib/mxup/graceful_stop.rb
62
63
  - lib/mxup/launcher.rb
@@ -68,6 +69,7 @@ files:
68
69
  - lib/mxup/status_view.rb
69
70
  - lib/mxup/tmux.rb
70
71
  - lib/mxup/version.rb
72
+ - lib/mxup/watcher.rb
71
73
  homepage: https://github.com/Recognized/mxup
72
74
  licenses:
73
75
  - MIT