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/cli.rb
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Mxup
|
|
6
|
+
# Argv parser + dispatch. Keeps parsing rules in one place; the actual
|
|
7
|
+
# behaviour lives in Runner and the focused modules it drives.
|
|
8
|
+
class CLI
|
|
9
|
+
COMMANDS = %w[up status down restart layout target exec].freeze
|
|
10
|
+
|
|
11
|
+
def run(argv)
|
|
12
|
+
args, options = parse(argv.dup)
|
|
13
|
+
command = extract_command(args)
|
|
14
|
+
dispatch(command, args, options)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def parse(args)
|
|
20
|
+
options = {
|
|
21
|
+
dry_run: false, config: nil, lines: nil, layout: nil,
|
|
22
|
+
target: nil, timeout: nil, force: false, quiet: false,
|
|
23
|
+
profile: nil
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
parser = OptionParser.new do |opts|
|
|
27
|
+
opts.banner = 'Usage: mxup [command] [name] [options]'
|
|
28
|
+
opts.on('-f', '--file FILE', 'Config file path') { |f| options[:config] = f }
|
|
29
|
+
opts.on('--dry-run', 'Preview changes without applying') { options[:dry_run] = true }
|
|
30
|
+
opts.on('--lines N', Integer, 'Output lines (status/exec)') { |n| options[:lines] = n }
|
|
31
|
+
opts.on('--layout NAME', 'Layout to use (for up/layout)') { |l| options[:layout] = l }
|
|
32
|
+
opts.on('-p', '--profile NAME',
|
|
33
|
+
'Profile to use (for up/status/restart/layout)') { |p| options[:profile] = p }
|
|
34
|
+
opts.on('-t', '--target TARGET',
|
|
35
|
+
'Target window/pane for exec (e.g. name:window)') { |t| options[:target] = t }
|
|
36
|
+
opts.on('--timeout N', Integer, 'Timeout in seconds (exec)') { |n| options[:timeout] = n }
|
|
37
|
+
opts.on('--force', 'Force exec on a busy pane') { options[:force] = true }
|
|
38
|
+
opts.on('-q', '--quiet', 'Suppress captured output (exec)') { options[:quiet] = true }
|
|
39
|
+
opts.on('-v', '--version', 'Show version') { puts "mxup #{VERSION}"; exit }
|
|
40
|
+
opts.on('-h', '--help', 'Show help') { puts opts; exit }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
parser.parse!(args)
|
|
44
|
+
[args, options]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Peek at the first non-file positional; if it's a recognised command,
|
|
48
|
+
# pop it. Otherwise default to "up".
|
|
49
|
+
def extract_command(args)
|
|
50
|
+
first = args.first
|
|
51
|
+
return 'up' if first.nil?
|
|
52
|
+
return 'up' if first.include?('.yml') || first.include?('/')
|
|
53
|
+
|
|
54
|
+
COMMANDS.include?(first) ? args.shift : 'up'
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dispatch(command, args, options)
|
|
58
|
+
case command
|
|
59
|
+
when 'up', 'status', 'down' then run_basic(command, args, options)
|
|
60
|
+
when 'restart', 'target' then run_restart_or_target(command, args, options)
|
|
61
|
+
when 'layout' then run_layout(args, options)
|
|
62
|
+
when 'exec' then run_exec(args, options)
|
|
63
|
+
else
|
|
64
|
+
abort "Unknown command: #{command}. Use: #{COMMANDS.join(', ')}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run_basic(command, args, options)
|
|
69
|
+
name = args.shift
|
|
70
|
+
runner = build_runner(options, name)
|
|
71
|
+
case command
|
|
72
|
+
when 'up' then runner.up
|
|
73
|
+
when 'status' then runner.status(lines: options[:lines] || 2)
|
|
74
|
+
when 'down' then runner.down
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run_restart_or_target(command, args, options)
|
|
79
|
+
name, window_names = split_restart_spec(args, options)
|
|
80
|
+
runner = build_runner(options, name)
|
|
81
|
+
|
|
82
|
+
if command == 'restart'
|
|
83
|
+
runner.restart(window_names)
|
|
84
|
+
else
|
|
85
|
+
runner.target(window_names)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_layout(args, options)
|
|
90
|
+
name = args.shift
|
|
91
|
+
target_layout = args.shift
|
|
92
|
+
runner = build_runner(options, name)
|
|
93
|
+
target_layout ? runner.switch_layout(target_layout) : runner.show_layouts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run_exec(args, options)
|
|
97
|
+
target = options[:target] || args.shift
|
|
98
|
+
abort 'Usage: mxup exec -t [name:]WINDOW "command"' if target.nil? || target.empty?
|
|
99
|
+
|
|
100
|
+
command_str = args.shift
|
|
101
|
+
abort 'Usage: mxup exec -t [name:]WINDOW "command"' if command_str.nil?
|
|
102
|
+
|
|
103
|
+
name = target.include?(':') ? target.split(':', 2).first : nil
|
|
104
|
+
build_runner(options, name).exec(
|
|
105
|
+
target, command_str,
|
|
106
|
+
lines: options[:lines] || 50,
|
|
107
|
+
timeout: options[:timeout],
|
|
108
|
+
force: options[:force],
|
|
109
|
+
quiet: options[:quiet]
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Parse the first positional of `restart` / `target`. Supports:
|
|
114
|
+
# "session:win1,win2" → explicit session + window list
|
|
115
|
+
# "session" → session name, no windows (if it resolves to a config)
|
|
116
|
+
# "win1" → bare window name (no session prefix)
|
|
117
|
+
def split_restart_spec(args, options)
|
|
118
|
+
spec = args.shift
|
|
119
|
+
if spec.nil?
|
|
120
|
+
return [nil, []]
|
|
121
|
+
elsif spec.include?(':')
|
|
122
|
+
name, windows_str = spec.split(':', 2)
|
|
123
|
+
return [name, windows_str ? windows_str.split(',') : []]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Ambiguous: is `spec` a config name or a window name?
|
|
127
|
+
looks_like_config = File.exist?(File.join(CONFIG_DIR, "#{spec}.yml")) ||
|
|
128
|
+
(options[:config] && spec !~ /,/)
|
|
129
|
+
return [spec, args] if looks_like_config
|
|
130
|
+
[nil, [spec] + args]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_runner(options, name)
|
|
134
|
+
config = load_config(options[:config], name, options[:profile])
|
|
135
|
+
Runner.new(config, dry_run: options[:dry_run], layout: options[:layout])
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def load_config(explicit_path, name, profile = nil)
|
|
139
|
+
path = resolve_config(explicit_path, name)
|
|
140
|
+
abort "Config not found. Provide -f path or place config in #{CONFIG_DIR}/" unless path
|
|
141
|
+
Config.new(path, profile: profile)
|
|
142
|
+
rescue ArgumentError => e
|
|
143
|
+
abort e.message
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Config resolution order:
|
|
147
|
+
# 1. explicit -f path
|
|
148
|
+
# 2. ~/.config/mxup/<name>.yml
|
|
149
|
+
# 3. ./mxup.yml
|
|
150
|
+
# 4. sole *.yml in ~/.config/mxup/
|
|
151
|
+
def resolve_config(explicit, name)
|
|
152
|
+
return explicit if explicit && File.exist?(explicit)
|
|
153
|
+
|
|
154
|
+
if name
|
|
155
|
+
candidate = File.join(CONFIG_DIR, "#{name}.yml")
|
|
156
|
+
return candidate if File.exist?(candidate)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
local = File.join(Dir.pwd, 'mxup.yml')
|
|
160
|
+
return local if File.exist?(local)
|
|
161
|
+
|
|
162
|
+
if Dir.exist?(CONFIG_DIR)
|
|
163
|
+
configs = Dir.glob(File.join(CONFIG_DIR, '*.yml'))
|
|
164
|
+
return configs.first if configs.size == 1
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
data/lib/mxup/config.rb
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'set'
|
|
5
|
+
|
|
6
|
+
module Mxup
|
|
7
|
+
# Parsed mxup YAML config. Pure data; no tmux or filesystem side effects.
|
|
8
|
+
#
|
|
9
|
+
# Profiles (optional): a config may declare a `profiles:` map where each
|
|
10
|
+
# entry is a partial override on top of the base `setup`, `windows`, and
|
|
11
|
+
# `layouts`. A single active profile is resolved at parse time and its
|
|
12
|
+
# overrides are merged in before the rest of the Config is built — so the
|
|
13
|
+
# rest of the system (Launcher, Reconciler, StatusView…) never has to know
|
|
14
|
+
# about profiles.
|
|
15
|
+
class Config
|
|
16
|
+
attr_reader :session, :setup, :windows, :layouts, :layout_names,
|
|
17
|
+
:profile, :profile_names, :default_profile
|
|
18
|
+
|
|
19
|
+
def initialize(path, profile: nil)
|
|
20
|
+
raw = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
|
|
21
|
+
resolve_profile!(raw, profile)
|
|
22
|
+
@session = raw.fetch('session')
|
|
23
|
+
@setup = raw['setup']&.strip
|
|
24
|
+
@windows = parse_windows(raw.fetch('windows'))
|
|
25
|
+
@layouts, @layout_names = parse_layouts(raw['layouts'])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def default_layout
|
|
29
|
+
@layout_names.first
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def groups_for(layout_name)
|
|
33
|
+
return [] if layout_name.nil?
|
|
34
|
+
@layouts.fetch(layout_name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns an ordered list of entries describing how windows should appear
|
|
38
|
+
# in tmux under the given layout. Each entry is one of:
|
|
39
|
+
# { type: :group, name: <group name>, group: PaneGroup }
|
|
40
|
+
# { type: :standalone, name: <window name> }
|
|
41
|
+
def effective_window_order(layout_name)
|
|
42
|
+
groups = groups_for(layout_name)
|
|
43
|
+
grouped = groups.flat_map(&:window_names).to_set
|
|
44
|
+
entries = groups.map { |g| { type: :group, name: g.name, group: g } }
|
|
45
|
+
@windows.each do |w|
|
|
46
|
+
entries << { type: :standalone, name: w.name } unless grouped.include?(w.name)
|
|
47
|
+
end
|
|
48
|
+
entries
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def window_by_name(name)
|
|
52
|
+
@windows.find { |w| w.name == name }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Returns [group, index_within_group] or nil.
|
|
56
|
+
def find_group_for_window(layout_name, window_name)
|
|
57
|
+
groups_for(layout_name).each do |g|
|
|
58
|
+
idx = g.window_names.index(window_name)
|
|
59
|
+
return [g, idx] if idx
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Pick the active profile (if any) and merge its overrides into `raw`.
|
|
67
|
+
# Sets @profile / @profile_names / @default_profile.
|
|
68
|
+
def resolve_profile!(raw, requested)
|
|
69
|
+
profiles = raw['profiles'] || {}
|
|
70
|
+
@profile_names = profiles.keys
|
|
71
|
+
@default_profile = raw['default_profile'] || @profile_names.first
|
|
72
|
+
|
|
73
|
+
if profiles.empty?
|
|
74
|
+
if requested
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"--profile '#{requested}' was given, but this config declares no profiles"
|
|
77
|
+
end
|
|
78
|
+
@profile = nil
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
@profile = requested || @default_profile
|
|
83
|
+
unless profiles.key?(@profile)
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"Unknown profile '#{@profile}' (available: #{@profile_names.join(', ')})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
apply_profile_overrides!(raw, profiles.fetch(@profile))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def apply_profile_overrides!(raw, override)
|
|
92
|
+
return unless override.is_a?(Hash)
|
|
93
|
+
|
|
94
|
+
if override.key?('session')
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"Profile '#{@profile}' cannot override 'session'; profiles of the " \
|
|
97
|
+
'same group must share one tmux session name'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
raw['setup'] = override['setup'] if override.key?('setup')
|
|
101
|
+
raw['layouts'] = override['layouts'] if override.key?('layouts')
|
|
102
|
+
|
|
103
|
+
removed = []
|
|
104
|
+
(override['windows'] || {}).each do |wname, woverride|
|
|
105
|
+
raw['windows'] ||= {}
|
|
106
|
+
if woverride.nil?
|
|
107
|
+
# Explicit null ("dev-kit: ~") drops this window for this profile.
|
|
108
|
+
# `parse_windows` skips absent keys; `prune_layouts!` keeps layouts
|
|
109
|
+
# from referencing the now-missing name.
|
|
110
|
+
raw['windows'].delete(wname)
|
|
111
|
+
removed << wname
|
|
112
|
+
else
|
|
113
|
+
base = raw['windows'][wname] || {}
|
|
114
|
+
raw['windows'][wname] = merge_window(base, woverride)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
prune_layouts!(raw, removed) if removed.any?
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Strip any removed window names from layout groups so parse_layouts
|
|
122
|
+
# doesn't raise "window not found". A group whose panes list becomes
|
|
123
|
+
# empty is dropped from the layout entirely.
|
|
124
|
+
def prune_layouts!(raw, removed)
|
|
125
|
+
return unless raw['layouts'].is_a?(Hash)
|
|
126
|
+
removed_set = removed.to_set
|
|
127
|
+
|
|
128
|
+
raw['layouts'].each_value do |layout_def|
|
|
129
|
+
next unless layout_def.is_a?(Hash)
|
|
130
|
+
layout_def.reject! do |_group_name, group_def|
|
|
131
|
+
next false unless group_def.is_a?(Hash) && group_def['panes'].is_a?(Array)
|
|
132
|
+
group_def['panes'] -= removed_set.to_a
|
|
133
|
+
group_def['panes'].empty?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
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).
|
|
140
|
+
def merge_window(base, override)
|
|
141
|
+
base.merge(override) do |key, base_val, prof_val|
|
|
142
|
+
if key == 'env' && base_val.is_a?(Hash) && prof_val.is_a?(Hash)
|
|
143
|
+
base_val.merge(prof_val)
|
|
144
|
+
else
|
|
145
|
+
prof_val
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def parse_windows(hash)
|
|
151
|
+
hash.map do |name, opts|
|
|
152
|
+
opts ||= {}
|
|
153
|
+
Window.new(
|
|
154
|
+
name: name,
|
|
155
|
+
root: File.expand_path(opts.fetch('root')),
|
|
156
|
+
command: opts['command']&.strip,
|
|
157
|
+
env: opts['env'] || {},
|
|
158
|
+
wait_for: WaitSpec.parse(opts['wait_for'])
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def parse_layouts(raw)
|
|
164
|
+
return [{}, []] if raw.nil?
|
|
165
|
+
|
|
166
|
+
valid_windows = @windows.map(&:name).to_set
|
|
167
|
+
layouts = {}
|
|
168
|
+
order = []
|
|
169
|
+
|
|
170
|
+
raw.each do |layout_name, groups_hash|
|
|
171
|
+
order << layout_name
|
|
172
|
+
groups_hash ||= {}
|
|
173
|
+
seen = Set.new
|
|
174
|
+
groups = []
|
|
175
|
+
|
|
176
|
+
groups_hash.each do |group_name, group_opts|
|
|
177
|
+
group_opts ||= {}
|
|
178
|
+
pane_names = Array(group_opts['panes'])
|
|
179
|
+
|
|
180
|
+
pane_names.each do |pn|
|
|
181
|
+
unless valid_windows.include?(pn)
|
|
182
|
+
raise ArgumentError,
|
|
183
|
+
"Layout '#{layout_name}': window '#{pn}' not found in windows"
|
|
184
|
+
end
|
|
185
|
+
if seen.include?(pn)
|
|
186
|
+
raise ArgumentError,
|
|
187
|
+
"Layout '#{layout_name}': window '#{pn}' appears in multiple groups"
|
|
188
|
+
end
|
|
189
|
+
seen << pn
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
groups << PaneGroup.new(
|
|
193
|
+
name: group_name,
|
|
194
|
+
window_names: pane_names,
|
|
195
|
+
split: group_opts['split'] || 'tiled'
|
|
196
|
+
)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
layouts[layout_name] = groups
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
[layouts, order]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
Window = Struct.new(:name, :root, :command, :env, :wait_for, keyword_init: true)
|
|
207
|
+
PaneGroup = Struct.new(:name, :window_names, :split, keyword_init: true)
|
|
208
|
+
|
|
209
|
+
# A readiness check attached to a window.
|
|
210
|
+
WaitSpec = Struct.new(:type, :target, :timeout, :interval, :label, keyword_init: true) do
|
|
211
|
+
CHECK_TYPES = %w[tcp http path script].freeze
|
|
212
|
+
|
|
213
|
+
def self.parse(raw)
|
|
214
|
+
case raw
|
|
215
|
+
when nil
|
|
216
|
+
nil
|
|
217
|
+
when String
|
|
218
|
+
new(type: :tcp, target: raw, timeout: nil, interval: 2, label: raw)
|
|
219
|
+
when Hash
|
|
220
|
+
found = CHECK_TYPES & raw.keys
|
|
221
|
+
unless found.size == 1
|
|
222
|
+
raise ArgumentError,
|
|
223
|
+
"wait_for must specify exactly one of: #{CHECK_TYPES.join(', ')}"
|
|
224
|
+
end
|
|
225
|
+
type = found.first
|
|
226
|
+
target = raw[type]
|
|
227
|
+
default_label = type == 'script' ? 'readiness check' : target
|
|
228
|
+
new(
|
|
229
|
+
type: type.to_sym,
|
|
230
|
+
target: target,
|
|
231
|
+
timeout: raw['timeout'],
|
|
232
|
+
interval: raw['interval'] || 2,
|
|
233
|
+
label: raw['label'] || default_label
|
|
234
|
+
)
|
|
235
|
+
else
|
|
236
|
+
raise ArgumentError, "wait_for must be a string or hash, got #{raw.class}"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
|
|
8
|
+
module Mxup
|
|
9
|
+
# Implements `mxup exec`: send a command to a pane, block until it finishes,
|
|
10
|
+
# print its output, and exit with its return code. The three interesting
|
|
11
|
+
# parts are:
|
|
12
|
+
#
|
|
13
|
+
# 1. Resolving a logical window name to a real tmux pane target.
|
|
14
|
+
# 2. Using `tmux wait-for -S <marker>` so we know the command finished.
|
|
15
|
+
# 3. Capturing both the output (tmux capture-pane) and the exit code
|
|
16
|
+
# (written to a temp file by the wrapped command).
|
|
17
|
+
class ExecRunner
|
|
18
|
+
def initialize(config, resolver:, dry_run: false,
|
|
19
|
+
out: nil, err: nil, exiter: ->(n) { exit(n) })
|
|
20
|
+
@config = config
|
|
21
|
+
@session = config.session
|
|
22
|
+
@resolver = resolver
|
|
23
|
+
@dry_run = dry_run
|
|
24
|
+
@out_override = out
|
|
25
|
+
@err_override = err
|
|
26
|
+
@exit = exiter
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def out
|
|
30
|
+
@out_override || $stdout
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def err
|
|
34
|
+
@err_override || $stderr
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# target_spec may be:
|
|
38
|
+
# "session:window" — session must match config
|
|
39
|
+
# "window" — logical name from config, or a raw tmux window
|
|
40
|
+
# "window.pane_index" — raw tmux pane address
|
|
41
|
+
def run(target_spec, command, lines: 50, timeout: nil, force: false, quiet: false)
|
|
42
|
+
abort "Session #{@session} is not running." unless Tmux.has_session?(@session)
|
|
43
|
+
abort 'mxup exec: command is required.' if command.nil? || command.strip.empty?
|
|
44
|
+
|
|
45
|
+
window_part = strip_session_prefix(target_spec)
|
|
46
|
+
resolved = resolve_target(window_part)
|
|
47
|
+
abort "Target '#{window_part}' not found in session '#{@session}'." unless resolved
|
|
48
|
+
|
|
49
|
+
full_target = "#{@session}:#{resolved}"
|
|
50
|
+
refuse_if_busy(resolved, full_target) unless force
|
|
51
|
+
|
|
52
|
+
if @dry_run
|
|
53
|
+
out.puts "[dry-run] Would exec on #{full_target}: #{command}"
|
|
54
|
+
return 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
send_and_wait(full_target, command, lines: lines, timeout: timeout, quiet: quiet)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def strip_session_prefix(spec)
|
|
63
|
+
return spec unless spec.include?(':')
|
|
64
|
+
sess, rest = spec.split(':', 2)
|
|
65
|
+
if sess != @session
|
|
66
|
+
abort "Target session '#{sess}' does not match config session '#{@session}'."
|
|
67
|
+
end
|
|
68
|
+
rest
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve_target(window_part)
|
|
72
|
+
# First try: logical config window (handles group membership).
|
|
73
|
+
if @config.windows.any? { |w| w.name == window_part }
|
|
74
|
+
resolved = @resolver.pane_target(window_part)
|
|
75
|
+
return resolved if resolved
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Explicit "window.pane_index" form — accept if the window exists.
|
|
79
|
+
if window_part =~ /\A(.+)\.\d+\z/
|
|
80
|
+
raw_win = Regexp.last_match(1)
|
|
81
|
+
return window_part if tmux_window_names.include?(raw_win)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Fallback: raw tmux window name that isn't in the config.
|
|
85
|
+
tmux_window_names.include?(window_part) ? window_part : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def tmux_window_names
|
|
89
|
+
Tmux.list_windows(@session).map { |w| w[:name] }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def refuse_if_busy(resolved, full_target)
|
|
93
|
+
pane = @resolver.pane_for(resolved)
|
|
94
|
+
return unless pane && !SHELLS.include?(pane[:fg_cmd])
|
|
95
|
+
|
|
96
|
+
abort "Target pane #{full_target} is busy (#{pane[:fg_cmd]}). " \
|
|
97
|
+
'Pass --force to send anyway.'
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def send_and_wait(full_target, command, lines:, timeout:, quiet:)
|
|
101
|
+
marker = fresh_marker
|
|
102
|
+
exit_file = File.join(Dir.tmpdir, "#{marker}.rc")
|
|
103
|
+
FileUtils.rm_f(exit_file)
|
|
104
|
+
|
|
105
|
+
cmd_clean = command.strip.sub(/;+\s*\z/, '')
|
|
106
|
+
# Wrap in a subshell so `exit`, `set -e`, or a failing command can't
|
|
107
|
+
# terminate the pane's interactive shell (which would strand us waiting
|
|
108
|
+
# for the marker forever).
|
|
109
|
+
wrapped = "( #{cmd_clean} ); " \
|
|
110
|
+
'__mxup_rc=$?; ' \
|
|
111
|
+
"echo $__mxup_rc > #{Shellwords.escape(exit_file)}; " \
|
|
112
|
+
"tmux wait-for -S #{Shellwords.escape(marker)}"
|
|
113
|
+
|
|
114
|
+
unless system("tmux send-keys -t #{Tmux.esc(full_target)} #{Shellwords.escape(wrapped)} Enter")
|
|
115
|
+
abort "Failed to send command to #{full_target}."
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
timed_out = wait_for_marker(marker, timeout)
|
|
119
|
+
|
|
120
|
+
output = `tmux capture-pane -t #{Tmux.esc(full_target)} -p -S -#{lines.to_i}`
|
|
121
|
+
unless quiet
|
|
122
|
+
out.print output
|
|
123
|
+
out.puts unless output.end_with?("\n")
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
if timed_out
|
|
127
|
+
err.puts "[mxup] exec: timed out after #{timeout}s waiting for command on #{full_target}."
|
|
128
|
+
FileUtils.rm_f(exit_file)
|
|
129
|
+
@exit.call(124)
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
rc = read_exit_code(exit_file)
|
|
134
|
+
FileUtils.rm_f(exit_file)
|
|
135
|
+
@exit.call(rc || 0)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def wait_for_marker(marker, timeout)
|
|
139
|
+
if timeout.nil?
|
|
140
|
+
system("tmux wait-for #{Tmux.esc(marker)}")
|
|
141
|
+
return false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
pid = Process.spawn("tmux wait-for #{Tmux.esc(marker)}")
|
|
145
|
+
begin
|
|
146
|
+
Timeout.timeout(timeout) { Process.waitpid(pid) }
|
|
147
|
+
false
|
|
148
|
+
rescue Timeout::Error
|
|
149
|
+
kill_waiter(pid)
|
|
150
|
+
# Release anyone else still blocked on the marker.
|
|
151
|
+
system("tmux wait-for -S #{Tmux.esc(marker)} 2>/dev/null")
|
|
152
|
+
true
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def kill_waiter(pid)
|
|
157
|
+
Process.kill('TERM', pid)
|
|
158
|
+
rescue StandardError
|
|
159
|
+
# already gone
|
|
160
|
+
ensure
|
|
161
|
+
begin
|
|
162
|
+
Process.waitpid(pid)
|
|
163
|
+
rescue StandardError
|
|
164
|
+
# already reaped
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def read_exit_code(path)
|
|
169
|
+
return nil unless File.exist?(path)
|
|
170
|
+
val = File.read(path).strip
|
|
171
|
+
val.empty? ? nil : val.to_i
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def fresh_marker
|
|
175
|
+
"mxup-exec-#{Process.pid}-#{Time.now.to_i}-#{rand(10**9)}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mxup
|
|
4
|
+
# Sends SIGINT to every non-shell pane in a session, then waits for them to
|
|
5
|
+
# exit. Used by `mxup down` before the final kill-session.
|
|
6
|
+
class GracefulStop
|
|
7
|
+
# Interval (seconds) between SIGINT rounds. Overridable for tests that
|
|
8
|
+
# don't want to pay the 1s settle between retries.
|
|
9
|
+
DEFAULT_ROUND_INTERVAL = 1.0
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
attr_writer :round_interval
|
|
13
|
+
|
|
14
|
+
def round_interval
|
|
15
|
+
@round_interval || DEFAULT_ROUND_INTERVAL
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(session, out: nil, err: nil)
|
|
20
|
+
@session = session
|
|
21
|
+
@out_override = out
|
|
22
|
+
@err_override = err
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def out
|
|
26
|
+
@out_override || $stdout
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def err
|
|
30
|
+
@err_override || $stderr
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call(timeout: 30)
|
|
34
|
+
out.puts "Stopping session #{@session}..."
|
|
35
|
+
deadline = Time.now + timeout
|
|
36
|
+
round = 0
|
|
37
|
+
|
|
38
|
+
loop do
|
|
39
|
+
break unless Tmux.has_session?(@session)
|
|
40
|
+
|
|
41
|
+
busy = busy_panes
|
|
42
|
+
break if busy.empty?
|
|
43
|
+
|
|
44
|
+
if round.positive?
|
|
45
|
+
remaining = (deadline - Time.now).ceil
|
|
46
|
+
plural = busy.size == 1 ? 'process' : 'processes'
|
|
47
|
+
out.puts " waiting... #{busy.size} #{plural} still running (#{remaining}s left)"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
busy.each do |pane|
|
|
51
|
+
target = Tmux.pane_target(pane[:name], pane[:pane_index])
|
|
52
|
+
Tmux.send_interrupt(@session, target)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if Time.now >= deadline
|
|
56
|
+
names = busy.map { |p| p[:title].to_s.empty? ? p[:name] : p[:title] }.uniq
|
|
57
|
+
err.puts " timeout: #{names.join(', ')} did not exit in #{timeout}s — killing anyway"
|
|
58
|
+
break
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
round += 1
|
|
62
|
+
sleep GracefulStop.round_interval
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def busy_panes
|
|
69
|
+
Tmux.list_panes(@session).reject { |p| SHELLS.include?(p[:fg_cmd]) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|