mxup 0.2.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +395 -0
- data/bin/mxup +10 -0
- data/examples/myapp-dev.yml +110 -0
- data/lib/mxup/cli.rb +170 -0
- data/lib/mxup/config.rb +240 -0
- data/lib/mxup/exec_runner.rb +178 -0
- data/lib/mxup/graceful_stop.rb +72 -0
- data/lib/mxup/launcher.rb +135 -0
- data/lib/mxup/layout_manager.rb +145 -0
- data/lib/mxup/pane_resolver.rb +70 -0
- data/lib/mxup/process_probe.rb +27 -0
- data/lib/mxup/reconciler.rb +240 -0
- data/lib/mxup/runner.rb +253 -0
- data/lib/mxup/status_view.rb +149 -0
- data/lib/mxup/tmux.rb +143 -0
- data/lib/mxup/version.rb +5 -0
- data/lib/mxup.rb +43 -0
- metadata +99 -0
data/lib/mxup/runner.rb
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mxup
|
|
4
|
+
# Public programmatic API. Composes the focused helpers — nothing here
|
|
5
|
+
# should contain real business logic, only wiring and delegation.
|
|
6
|
+
class Runner
|
|
7
|
+
# Delay (seconds) between the two Ctrl-C presses during a restart, and
|
|
8
|
+
# between Ctrl-C and sending the new command. In production 1s is enough
|
|
9
|
+
# to let the pane's shell redraw its prompt; tests shorten this to avoid
|
|
10
|
+
# paying multi-second waits for a purely cosmetic settle.
|
|
11
|
+
DEFAULT_INTERRUPT_DELAY = 1.0
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
attr_writer :interrupt_delay
|
|
15
|
+
|
|
16
|
+
def interrupt_delay
|
|
17
|
+
@interrupt_delay || DEFAULT_INTERRUPT_DELAY
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(config, dry_run: false, layout: nil)
|
|
22
|
+
@config = config
|
|
23
|
+
@session = config.session
|
|
24
|
+
@dry_run = dry_run
|
|
25
|
+
@layout = layout
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# --- primary commands --------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def up
|
|
31
|
+
handle_profile_switch
|
|
32
|
+
reconciler.up
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def status(lines:)
|
|
36
|
+
status_view.render(lines: lines)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def down
|
|
40
|
+
unless Tmux.has_session?(@session)
|
|
41
|
+
$stdout.puts "Session #{@session} is not running."
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if @dry_run
|
|
46
|
+
$stdout.puts "[dry-run] Would gracefully stop session #{@session}"
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
graceful_stop.call
|
|
51
|
+
Tmux.kill_session(@session)
|
|
52
|
+
launcher.cleanup
|
|
53
|
+
$stdout.puts "Session #{@session} killed."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def restart(window_names)
|
|
57
|
+
abort "Session #{@session} is not running." unless Tmux.has_session?(@session)
|
|
58
|
+
launcher.write_all unless @dry_run
|
|
59
|
+
|
|
60
|
+
targets = resolve_restart_targets(window_names)
|
|
61
|
+
|
|
62
|
+
targets.each do |win|
|
|
63
|
+
target_ref = resolver.pane_target(win.name)
|
|
64
|
+
if target_ref
|
|
65
|
+
restart_existing(win, target_ref)
|
|
66
|
+
else
|
|
67
|
+
create_missing(win)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def switch_layout(target_layout)
|
|
73
|
+
layout_manager.switch(target_layout)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def show_layouts
|
|
77
|
+
layout_manager.show(
|
|
78
|
+
active: Tmux.has_session?(@session) ? resolver.stored_layout : nil
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def target(window_names)
|
|
83
|
+
abort "Session #{@session} is not running." unless Tmux.has_session?(@session)
|
|
84
|
+
|
|
85
|
+
requested = Array(window_names).flat_map { |n| n.split(',') }.reject(&:empty?)
|
|
86
|
+
|
|
87
|
+
if requested.empty?
|
|
88
|
+
print_all_targets
|
|
89
|
+
else
|
|
90
|
+
print_requested_targets(requested)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def exec(target_spec, command, lines: 50, timeout: nil, force: false, quiet: false)
|
|
95
|
+
exec_runner.run(target_spec, command,
|
|
96
|
+
lines: lines, timeout: timeout,
|
|
97
|
+
force: force, quiet: quiet)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
# --- profile switching -------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Before `up` runs, make sure we aren't stepping on a different profile
|
|
105
|
+
# of the same group. If the live session was brought up under a different
|
|
106
|
+
# profile, tear it down first so the new profile starts from a clean
|
|
107
|
+
# slate. Running profiles are expected to be mutually exclusive.
|
|
108
|
+
def handle_profile_switch
|
|
109
|
+
return unless @config.profile
|
|
110
|
+
return unless Tmux.has_session?(@session)
|
|
111
|
+
|
|
112
|
+
stored = Tmux.show_environment(@session, 'MXUP_PROFILE')
|
|
113
|
+
return if stored.nil? || stored == @config.profile
|
|
114
|
+
|
|
115
|
+
if @dry_run
|
|
116
|
+
$stdout.puts "[dry-run] Would tear down profile '#{stored}' before " \
|
|
117
|
+
"bringing up '#{@config.profile}'"
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
$stdout.puts "Switching profile: '#{stored}' -> '#{@config.profile}' " \
|
|
122
|
+
'(tearing down current profile first)...'
|
|
123
|
+
down
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# --- restart helpers ---------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def resolve_restart_targets(names)
|
|
129
|
+
return @config.windows if names.nil? || names.empty?
|
|
130
|
+
|
|
131
|
+
names.flat_map { |n| n.split(',') }.map do |n|
|
|
132
|
+
@config.windows.find { |w| w.name == n } ||
|
|
133
|
+
abort("Window '#{n}' not found in config.")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def restart_existing(win, target_ref)
|
|
138
|
+
if @dry_run
|
|
139
|
+
$stdout.puts "[dry-run] Would restart #{win.name}"
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
# Two C-c's handle both "foreground is command" and "foreground is a
|
|
143
|
+
# 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))
|
|
150
|
+
$stdout.puts " #{win.name}: restarted"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def create_missing(win)
|
|
154
|
+
if @dry_run
|
|
155
|
+
$stdout.puts "[dry-run] Would create #{win.name}"
|
|
156
|
+
else
|
|
157
|
+
Tmux.new_window(@session, win.name, win.root)
|
|
158
|
+
Tmux.set_pane_title(@session, win.name, 0, win.name)
|
|
159
|
+
Tmux.send_keys(@session, win.name, launcher.command_for(win))
|
|
160
|
+
$stdout.puts " #{win.name}: created"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# --- target helpers ----------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def print_all_targets
|
|
167
|
+
@config.windows.each do |win|
|
|
168
|
+
t = resolver.pane_target(win.name)
|
|
169
|
+
$stdout.puts(t ? "#{win.name}\t#{@session}:#{t}" : "#{win.name}\t(not running)")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def print_requested_targets(requested)
|
|
174
|
+
requested.each do |wn|
|
|
175
|
+
@config.windows.find { |w| w.name == wn } ||
|
|
176
|
+
abort("Window '#{wn}' not found in config.")
|
|
177
|
+
|
|
178
|
+
t = resolver.pane_target(wn)
|
|
179
|
+
abort("Window '#{wn}' is not currently in the session.") unless t
|
|
180
|
+
|
|
181
|
+
if requested.size == 1
|
|
182
|
+
$stdout.puts "#{@session}:#{t}"
|
|
183
|
+
else
|
|
184
|
+
$stdout.puts "#{wn}\t#{@session}:#{t}"
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# --- lazily-built collaborators ---------------------------------------
|
|
190
|
+
|
|
191
|
+
def resolver
|
|
192
|
+
@resolver ||= PaneResolver.new(@config, session: @session, layout_override: @layout)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def launcher
|
|
196
|
+
@launcher ||= Launcher.new(@config)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def layout_manager
|
|
200
|
+
@layout_manager ||= LayoutManager.new(
|
|
201
|
+
@config, session: @session, dry_run: @dry_run, resolver: resolver
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def reconciler
|
|
206
|
+
@reconciler ||= Reconciler.new(
|
|
207
|
+
@config,
|
|
208
|
+
launcher: launcher, resolver: resolver,
|
|
209
|
+
layout_manager: layout_manager, dry_run: @dry_run
|
|
210
|
+
)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def status_view
|
|
214
|
+
@status_view ||= StatusView.new(@config, resolver: resolver)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def exec_runner
|
|
218
|
+
@exec_runner ||= ExecRunner.new(@config, resolver: resolver, dry_run: @dry_run)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def graceful_stop
|
|
222
|
+
@graceful_stop ||= GracefulStop.new(@session)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# --- backward-compat shims (referenced by the unit tests) -------------
|
|
226
|
+
|
|
227
|
+
# These delegate to the launcher / layout manager so tests that poke at
|
|
228
|
+
# Runner's internals via .send(:...) keep working unchanged.
|
|
229
|
+
def assemble_command(win)
|
|
230
|
+
launcher.command_for(win)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def write_launcher_scripts
|
|
234
|
+
launcher.write_all
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def launcher_script_path(name)
|
|
238
|
+
launcher.script_path(name)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def session_runtime_dir
|
|
242
|
+
launcher.session_runtime_dir
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def cleanup_runtime_dir
|
|
246
|
+
launcher.cleanup
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def generate_wait_block(_name, spec)
|
|
250
|
+
launcher.build_wait_block(spec)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module Mxup
|
|
6
|
+
# Renders `mxup status` output.
|
|
7
|
+
class StatusView
|
|
8
|
+
INDENT = ' '
|
|
9
|
+
|
|
10
|
+
def initialize(config, resolver:, out: nil)
|
|
11
|
+
@config = config
|
|
12
|
+
@session = config.session
|
|
13
|
+
@resolver = resolver
|
|
14
|
+
@out_override = out
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def out
|
|
18
|
+
@out_override || $stdout
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render(lines:)
|
|
22
|
+
unless Tmux.has_session?(@session)
|
|
23
|
+
out.puts "SESSION: #{@session} — NOT RUNNING"
|
|
24
|
+
out.puts "Start with: mxup up #{@session}"
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
active = @resolver.active_layout
|
|
29
|
+
created = Tmux.session_created(@session)
|
|
30
|
+
created_at = Time.at(created.to_i).strftime('%Y-%m-%d %H:%M:%S')
|
|
31
|
+
stored_prof = Tmux.show_environment(@session, 'MXUP_PROFILE')
|
|
32
|
+
bits = []
|
|
33
|
+
bits << "profile: #{stored_prof}" if stored_prof
|
|
34
|
+
bits << "layout: #{active}" if active
|
|
35
|
+
suffix = bits.empty? ? '' : " (#{bits.join(', ')})"
|
|
36
|
+
out.puts "SESSION: #{@session} — up since #{created_at}#{suffix}"
|
|
37
|
+
out.puts
|
|
38
|
+
|
|
39
|
+
panes = Tmux.list_panes(@session)
|
|
40
|
+
declared_names = @config.windows.map(&:name)
|
|
41
|
+
printed = Set.new
|
|
42
|
+
order = @config.effective_window_order(active)
|
|
43
|
+
|
|
44
|
+
order.each do |entry|
|
|
45
|
+
if entry[:type] == :group
|
|
46
|
+
render_group(entry[:group], panes, lines, printed)
|
|
47
|
+
else
|
|
48
|
+
render_standalone(entry[:name], panes, lines, declared_names, printed)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
render_extras(panes, lines, printed)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def render_group(group, panes, lines, printed)
|
|
58
|
+
group_panes = panes.select { |p| p[:name] == group.name }
|
|
59
|
+
.sort_by { |p| p[:pane_index] }
|
|
60
|
+
|
|
61
|
+
if group_panes.empty?
|
|
62
|
+
group.window_names.each do |wn|
|
|
63
|
+
out.puts "[?] #{wn} MISSING (group: #{group.name})"
|
|
64
|
+
out.puts
|
|
65
|
+
end
|
|
66
|
+
return
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
header_idx = group_panes.first[:window_index]
|
|
70
|
+
out.puts "[#{header_idx}] #{group.name} " \
|
|
71
|
+
"(#{group.window_names.join(', ')}) split=#{group.split}"
|
|
72
|
+
|
|
73
|
+
group.window_names.each_with_index do |wn, idx|
|
|
74
|
+
printed << wn
|
|
75
|
+
pane = group_panes.find { |p| p[:title] == wn } ||
|
|
76
|
+
group_panes.find { |p| p[:pane_index] == idx }
|
|
77
|
+
if pane.nil?
|
|
78
|
+
out.puts " #{wn}: MISSING"
|
|
79
|
+
next
|
|
80
|
+
end
|
|
81
|
+
out.puts " #{wn}:"
|
|
82
|
+
print_pane(pane, wn, lines, indent: ' ')
|
|
83
|
+
end
|
|
84
|
+
out.puts
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def render_standalone(name, panes, lines, declared, printed)
|
|
88
|
+
printed << name
|
|
89
|
+
pane = panes.find { |p| p[:name] == name && p[:pane_index] == 0 }
|
|
90
|
+
if pane.nil?
|
|
91
|
+
out.puts "[?] #{name} MISSING"
|
|
92
|
+
out.puts
|
|
93
|
+
return
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
tag = declared.include?(pane[:name]) ? '' : ' [NOT IN CONFIG]'
|
|
97
|
+
out.puts "[#{pane[:window_index]}] #{pane[:name]}#{tag}"
|
|
98
|
+
print_pane(pane, name, lines, indent: INDENT)
|
|
99
|
+
out.puts
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Surface any panes that weren't already printed (unexpected windows).
|
|
103
|
+
def render_extras(panes, lines, printed)
|
|
104
|
+
panes.each do |pane|
|
|
105
|
+
logical = pane[:title].to_s.empty? ? pane[:name] : pane[:title]
|
|
106
|
+
next if printed.include?(logical) || printed.include?(pane[:name])
|
|
107
|
+
|
|
108
|
+
out.puts "[#{pane[:window_index]}] #{pane[:name]} [NOT IN CONFIG]"
|
|
109
|
+
print_pane(pane, pane[:name], lines, indent: INDENT)
|
|
110
|
+
out.puts
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def print_pane(pane, logical_name, lines, indent: INDENT)
|
|
115
|
+
idle = SHELLS.include?(pane[:fg_cmd])
|
|
116
|
+
status = idle ? idle_status(pane) : running_status(pane, indent)
|
|
117
|
+
|
|
118
|
+
in_group = pane[:name] != logical_name
|
|
119
|
+
target = in_group ? Tmux.pane_target(pane[:name], pane[:pane_index]) : pane[:name]
|
|
120
|
+
out.puts "#{indent}target: #{@session}:#{target}"
|
|
121
|
+
out.puts "#{indent}cwd: #{pane[:cwd]}"
|
|
122
|
+
out.puts "#{indent}#{status}"
|
|
123
|
+
|
|
124
|
+
tail = Tmux.capture_pane(@session, target)
|
|
125
|
+
.split("\n")
|
|
126
|
+
.reject { |l| l.strip.empty? }
|
|
127
|
+
.last(lines)
|
|
128
|
+
return if tail.empty?
|
|
129
|
+
|
|
130
|
+
out.puts "#{indent}--- last output (up to #{lines} lines) ---"
|
|
131
|
+
tail.each { |l| out.puts "#{indent}#{l}" }
|
|
132
|
+
out.puts "#{indent}---"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def idle_status(pane)
|
|
136
|
+
"IDLE (shell: #{pane[:fg_cmd]}, pid=#{pane[:pid]})"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def running_status(pane, indent)
|
|
140
|
+
leaf = ProcessProbe.leaf_pid(pane[:pid])
|
|
141
|
+
cmd = ProcessProbe.ps_field(leaf, 'args') || pane[:fg_cmd]
|
|
142
|
+
elapsed = ProcessProbe.ps_field(leaf, 'etime')&.strip || '?'
|
|
143
|
+
started = ProcessProbe.ps_field(leaf, 'lstart') || '?'
|
|
144
|
+
"RUNNING pid=#{leaf} elapsed=#{elapsed} fg=#{pane[:fg_cmd]}\n" \
|
|
145
|
+
"#{indent}command: #{cmd}\n" \
|
|
146
|
+
"#{indent}started: #{started}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/mxup/tmux.rb
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
module Mxup
|
|
6
|
+
# Thin wrapper over the tmux(1) CLI. Every method shells out; nothing here
|
|
7
|
+
# knows about mxup's own config. Kept as a module so callers can reference
|
|
8
|
+
# Mxup::Tmux.list_panes(...) without instantiating anything.
|
|
9
|
+
module Tmux
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# --- inspection --------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
def has_session?(name)
|
|
15
|
+
system("tmux has-session -t #{esc(name)} 2>/dev/null")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def session_created(name)
|
|
19
|
+
`tmux display-message -t #{esc(name)} -p '\#{session_created}'`.strip
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def list_windows(session)
|
|
23
|
+
`tmux list-windows -t #{esc(session)} -F '\#{window_index}|\#{window_name}'`
|
|
24
|
+
.strip.split("\n").map do |line|
|
|
25
|
+
idx, name = line.split('|', 2)
|
|
26
|
+
{ index: idx.to_i, name: name }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def list_panes(session)
|
|
31
|
+
fmt = '#{window_index}|#{window_name}|#{pane_index}|#{pane_pid}|' \
|
|
32
|
+
'#{pane_current_path}|#{pane_current_command}|#{pane_title}'
|
|
33
|
+
`tmux list-panes -t #{esc(session)} -s -F '#{fmt}'`
|
|
34
|
+
.strip.split("\n").map do |line|
|
|
35
|
+
win_idx, name, pane_idx, pid, cwd, fg, title = line.split('|', 7)
|
|
36
|
+
{ window_index: win_idx.to_i, name: name, pane_index: pane_idx.to_i,
|
|
37
|
+
pid: pid.to_i, cwd: cwd, fg_cmd: fg, title: title.to_s }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def pane_count(session, window)
|
|
42
|
+
`tmux list-panes -t #{esc(session)}:#{esc(window)} 2>/dev/null`
|
|
43
|
+
.strip.split("\n").size
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Capture the pane's *entire* scrollback (-S - is "start of history") so
|
|
47
|
+
# callers can filter and tail as they see fit. We deliberately don't pass
|
|
48
|
+
# -S -N here: if the interesting output scrolled past the visible window,
|
|
49
|
+
# a lower bound would silently hide it.
|
|
50
|
+
def capture_pane(session, target, _lines = nil)
|
|
51
|
+
`tmux capture-pane -t #{esc(session)}:#{esc(target)} -p -S - 2>/dev/null`
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- mutation ----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
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)}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def new_window(session, name, root)
|
|
61
|
+
system("tmux new-window -t #{esc(session)} -n #{esc(name)} -c #{esc(root)}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def split_window(session, window, root, target_pane: nil)
|
|
65
|
+
target = target_pane \
|
|
66
|
+
? "#{esc(session)}:#{esc(window)}.#{target_pane}" \
|
|
67
|
+
: "#{esc(session)}:#{esc(window)}"
|
|
68
|
+
system("tmux split-window -t #{target} -c #{esc(root)} -d")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def select_layout(session, window, layout)
|
|
72
|
+
system("tmux select-layout -t #{esc(session)}:#{esc(window)} #{esc(layout)}")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
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")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Break a pane out into its own window. Without an explicit -t target,
|
|
80
|
+
# tmux places the new window in the *current client's* session, which
|
|
81
|
+
# would send it to whichever session the user happens to be attached to
|
|
82
|
+
# when running tests. Always pin the destination to the source session.
|
|
83
|
+
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")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def rename_window(session, old_name, new_name)
|
|
89
|
+
system("tmux rename-window -t #{esc(session)}:#{esc(old_name)} #{esc(new_name)}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def set_pane_title(session, window, pane_index, title)
|
|
93
|
+
system("tmux select-pane -t #{esc(session)}:#{esc(window)}.#{pane_index} -T #{esc(title)}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def set_environment(session, key, value)
|
|
97
|
+
system("tmux set-environment -t #{esc(session)} #{esc(key)} #{esc(value)}")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Returns the value stored in the session's environment, or nil if unset
|
|
101
|
+
# (tmux prefixes unset entries with '-').
|
|
102
|
+
def show_environment(session, key)
|
|
103
|
+
out = `tmux show-environment -t #{esc(session)} #{esc(key)} 2>/dev/null`.strip
|
|
104
|
+
return nil if out.empty? || out.start_with?('-')
|
|
105
|
+
out.split('=', 2).last
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def kill_window(session, name)
|
|
109
|
+
system("tmux kill-window -t #{esc(session)}:#{esc(name)}")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def kill_session(name)
|
|
113
|
+
system("tmux kill-session -t #{esc(name)}")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def send_keys(session, target, keys)
|
|
117
|
+
system("tmux send-keys -t #{esc(session)}:#{esc(target)} #{esc(keys)} Enter")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def send_interrupt(session, target)
|
|
121
|
+
system("tmux send-keys -t #{esc(session)}:#{esc(target)} C-c")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def move_window(session, src_name, dst_index)
|
|
125
|
+
system("tmux move-window -s #{esc(session)}:#{esc(src_name)} -t #{esc(session)}:#{dst_index} 2>/dev/null")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def swap_window(session, idx_a, idx_b)
|
|
129
|
+
system("tmux swap-window -s #{esc(session)}:#{idx_a} -t #{esc(session)}:#{idx_b}")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# --- helpers -----------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
# Format a pane target string "window.index" for use in other tmux calls.
|
|
135
|
+
def pane_target(window, pane_index)
|
|
136
|
+
"#{window}.#{pane_index}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def esc(val)
|
|
140
|
+
Shellwords.escape(val.to_s)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/lib/mxup/version.rb
ADDED
data/lib/mxup.rb
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Declarative tmux session manager with reconciliation.
|
|
4
|
+
#
|
|
5
|
+
# Public entry points:
|
|
6
|
+
# Mxup::CLI.new.run(argv) — command-line dispatch
|
|
7
|
+
# Mxup::Config.new(path) — parse a YAML config
|
|
8
|
+
# Mxup::Runner.new(config) — programmatic API (up/down/status/...)
|
|
9
|
+
#
|
|
10
|
+
# Internals are organised into focused modules under lib/mxup/:
|
|
11
|
+
# Config / Window / PaneGroup / WaitSpec — pure data
|
|
12
|
+
# Tmux — thin tmux(1) wrapper
|
|
13
|
+
# Launcher — per-window launcher scripts
|
|
14
|
+
# PaneResolver — logical-name → tmux target
|
|
15
|
+
# Reconciler — `up` / `reconcile` orchestration
|
|
16
|
+
# LayoutManager — layout switching (flatten + regroup)
|
|
17
|
+
# StatusView — `status` rendering
|
|
18
|
+
# ExecRunner — `exec` with marker + timeout
|
|
19
|
+
# GracefulStop — cooperative SIGINT-then-wait
|
|
20
|
+
# ProcessProbe — pane → real leaf process info
|
|
21
|
+
# Runner — facade delegating to the above
|
|
22
|
+
# CLI — argv parsing + command dispatch
|
|
23
|
+
|
|
24
|
+
require_relative 'mxup/version'
|
|
25
|
+
|
|
26
|
+
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
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
require_relative 'mxup/config'
|
|
33
|
+
require_relative 'mxup/tmux'
|
|
34
|
+
require_relative 'mxup/launcher'
|
|
35
|
+
require_relative 'mxup/process_probe'
|
|
36
|
+
require_relative 'mxup/pane_resolver'
|
|
37
|
+
require_relative 'mxup/graceful_stop'
|
|
38
|
+
require_relative 'mxup/layout_manager'
|
|
39
|
+
require_relative 'mxup/reconciler'
|
|
40
|
+
require_relative 'mxup/status_view'
|
|
41
|
+
require_relative 'mxup/exec_runner'
|
|
42
|
+
require_relative 'mxup/runner'
|
|
43
|
+
require_relative 'mxup/cli'
|
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mxup
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Vladislav Saifulin
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: minitest
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '5.20'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '5.20'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rake
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '13.1'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '13.1'
|
|
41
|
+
description: |
|
|
42
|
+
mxup brings a live tmux session into agreement with a YAML description of
|
|
43
|
+
windows, commands, and layouts. It creates missing windows, restarts crashed
|
|
44
|
+
ones, removes undeclared ones, and leaves healthy windows alone. Re-running
|
|
45
|
+
`mxup up` is always safe.
|
|
46
|
+
email:
|
|
47
|
+
- vladislav.saifulin@jetbrains.com
|
|
48
|
+
executables:
|
|
49
|
+
- mxup
|
|
50
|
+
extensions: []
|
|
51
|
+
extra_rdoc_files: []
|
|
52
|
+
files:
|
|
53
|
+
- LICENSE
|
|
54
|
+
- README.md
|
|
55
|
+
- bin/mxup
|
|
56
|
+
- examples/myapp-dev.yml
|
|
57
|
+
- lib/mxup.rb
|
|
58
|
+
- lib/mxup/cli.rb
|
|
59
|
+
- lib/mxup/config.rb
|
|
60
|
+
- lib/mxup/exec_runner.rb
|
|
61
|
+
- lib/mxup/graceful_stop.rb
|
|
62
|
+
- lib/mxup/launcher.rb
|
|
63
|
+
- lib/mxup/layout_manager.rb
|
|
64
|
+
- lib/mxup/pane_resolver.rb
|
|
65
|
+
- lib/mxup/process_probe.rb
|
|
66
|
+
- lib/mxup/reconciler.rb
|
|
67
|
+
- lib/mxup/runner.rb
|
|
68
|
+
- lib/mxup/status_view.rb
|
|
69
|
+
- lib/mxup/tmux.rb
|
|
70
|
+
- lib/mxup/version.rb
|
|
71
|
+
homepage: https://github.com/Recognized/mxup
|
|
72
|
+
licenses:
|
|
73
|
+
- MIT
|
|
74
|
+
metadata:
|
|
75
|
+
homepage_uri: https://github.com/Recognized/mxup
|
|
76
|
+
source_code_uri: https://github.com/Recognized/mxup
|
|
77
|
+
bug_tracker_uri: https://github.com/Recognized/mxup/issues
|
|
78
|
+
changelog_uri: https://github.com/Recognized/mxup/releases
|
|
79
|
+
rubygems_mfa_required: 'true'
|
|
80
|
+
post_install_message:
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 3.1.0
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 3.5.22
|
|
96
|
+
signing_key:
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: Declarative tmux session manager with reconciliation.
|
|
99
|
+
test_files: []
|