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/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
|
-
|
|
17
|
-
|
|
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
|
|
23
|
-
@setup
|
|
24
|
-
@
|
|
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`
|
|
139
|
-
# be merged (so a profile can tweak one key without
|
|
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
|
|
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:
|
|
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
|
|
207
|
-
PaneGroup
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/mxup/reconciler.rb
CHANGED
|
@@ -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
|
-
|
|
145
|
-
|
|
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
|