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.
- checksums.yaml +4 -4
- data/README.md +168 -6
- data/examples/myapp-dev.yml +38 -7
- data/lib/mxup/cli.rb +76 -14
- data/lib/mxup/config.rb +197 -12
- data/lib/mxup/env_file.rb +82 -0
- data/lib/mxup/launcher.rb +85 -25
- data/lib/mxup/reconciler.rb +7 -0
- data/lib/mxup/runner.rb +133 -6
- data/lib/mxup/status_view.rb +49 -0
- data/lib/mxup/tmux.rb +29 -12
- data/lib/mxup/version.rb +1 -1
- data/lib/mxup/watcher.rb +168 -0
- data/lib/mxup.rb +8 -3
- metadata +4 -2
data/lib/mxup/status_view.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
data/lib/mxup/watcher.rb
ADDED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
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.
|
|
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-
|
|
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
|