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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f2f166f4e87656548399fe98f82650123ef4fe98091cef4c3e3cc1d123ea8506
4
- data.tar.gz: 5aea8148ab5bd0cbee4e3635a0df5a3e0513c8dfc3e110408db674c795552b84
3
+ metadata.gz: b091e5654c4a748617259e0ea10d9e028d96febfe91d808da4fa4bbc8c2fbe3b
4
+ data.tar.gz: d63f016660507556cde2cc66137bab6eff7a3e02ab14a39b5ec9d1209db5a07a
5
5
  SHA512:
6
- metadata.gz: 10a37a6fad2a6409ceabf94f7bd0c63d36645a7a402d609b4207255753cefeffeee162fefcc0aee8d01777ac7535fba71842b5ff9298a43b48b5cf1c6393f5de
7
- data.tar.gz: 4463a0a269f5cd7fa1ffecc85e30f05822420afe8796df195ffadc8fdab05191bb03f8bd590d99b1c01f60f1a4a199184535badb2e3ec410fe701bbcc6ce0c08
6
+ metadata.gz: 48326d3972367778ad604835d4f8d5c9417256bbc7266e9f8906ccec1d55eca23ad6424515208dc2a4dd9573912201f0760a410323ca74fc55571161bc281dbc
7
+ data.tar.gz: 5dcabc72786c9e48882a944fe583325efbdfb3cfce23ebad09bac7b09e479797288a959dcf8f9434bbc619953352eb0688ebd86b3bb409ca666bf57b72c2870e
data/README.md CHANGED
@@ -53,11 +53,35 @@ mxup down myapp-dev
53
53
 
54
54
  ## Config format
55
55
 
56
- Configs live in `~/.config/mxup/<name>.yml` or can be passed via `-f path`.
56
+ Configs live in `~/.config/mxup/<name>.yml`, in a project-local `./.mxup/<name>.yml`, or can be passed via `-f path`.
57
+
58
+ ### Where mxup looks for a config
59
+
60
+ In order, `mxup` tries:
61
+
62
+ 1. `-f <path>` if supplied.
63
+ 2. `./.mxup/<name>.yml` — project-local, takes precedence over the global dir.
64
+ 3. `~/.config/mxup/<name>.yml`.
65
+ 4. `./mxup.yml` — bare config in the current directory.
66
+ 5. The sole `*.yml` in `./.mxup/`, if exactly one is present.
67
+ 6. The sole `*.yml` in `~/.config/mxup/`, if exactly one is present.
68
+
69
+ A project-local `.mxup/` directory is handy for committing per-repo dev sessions alongside the code:
70
+
71
+ ```bash
72
+ mkdir .mxup
73
+ cp examples/myapp-dev.yml .mxup/dev.yml
74
+ mxup up dev # picks up ./.mxup/dev.yml
75
+ ```
57
76
 
58
77
  ```yaml
59
78
  session: my-project
60
79
 
80
+ # Optional. Relative paths are resolved against the config file's directory
81
+ # (or, when the config lives in `.mxup/`, against the project root that
82
+ # contains it — so a checked-in `.mxup/dev.yml` can just say `root: .`).
83
+ root: ~/projects/my-app
84
+
61
85
  # Shell snippet run in every window before the command
62
86
  setup: |
63
87
  direnv allow . 2>/dev/null
@@ -65,22 +89,22 @@ setup: |
65
89
 
66
90
  windows:
67
91
  database:
68
- root: ~/projects/my-app
92
+ root: . # = ~/projects/my-app
69
93
  command: docker compose up postgres redis
70
94
 
71
95
  backend:
72
- root: ~/projects/my-app/backend
96
+ root: backend # = ~/projects/my-app/backend
73
97
  wait_for: localhost:5432
74
98
  env:
75
99
  DATABASE_URL: postgres://localhost/myapp_dev
76
100
  command: ./start-server.sh
77
101
 
78
102
  frontend:
79
- root: ~/projects/my-app/frontend
103
+ root: frontend
80
104
  command: npm run dev
81
105
 
82
106
  shell:
83
- root: ~/projects/my-app
107
+ root: .
84
108
  ```
85
109
 
86
110
  ### Fields
@@ -89,16 +113,19 @@ windows:
89
113
  |-------|----------|-------------|
90
114
  | `session` | yes | tmux session name |
91
115
  | `setup` | no | Shell snippet prepended to every window's command |
116
+ | `root` | no | Base directory for relative window `root` values. Relative paths resolve against the config file's directory — or, if the config lives in a project-local `.mxup/` directory, against `.mxup/`'s parent (the project root). `~` and absolute paths are kept as-is. Supports `$VAR` / `${VAR}` env-var expansion (an unset variable is an error). |
117
+ | `required_env` | no | Env vars that must be filled in before `mxup up` will start (see [Required env vars](#required-env-vars)) |
92
118
  | `windows` | yes | Ordered map of window definitions |
93
119
 
94
120
  Per window:
95
121
 
96
122
  | Field | Required | Description |
97
123
  |-------|----------|-------------|
98
- | `root` | yes | Working directory (supports `~`) |
124
+ | `root` | yes | Working directory. `~` and absolute paths are kept as-is; relative paths resolve against the top-level `root` (or CWD if unset). Supports `$VAR` / `${VAR}` env-var expansion (an unset variable is an error). |
99
125
  | `command` | no | Command to run. Omit for an interactive shell. |
100
126
  | `env` | no | Map of environment variables to export |
101
127
  | `wait_for` | no | Readiness check to pass before running command (see below) |
128
+ | `commands` | no | Map of named one-off commands runnable via `mxup <config> <window>:<name>` (see [Named commands](#named-commands-mxup-configwindowname)) |
102
129
 
103
130
  ### Wait-for checks
104
131
 
@@ -160,6 +187,107 @@ Then override at invocation:
160
187
  APP_ENV=production mxup up my-project
161
188
  ```
162
189
 
190
+ ### Live env (command-backed env vars with auto-restart)
191
+
192
+ For env vars whose value comes from a command you re-run periodically (auth
193
+ tokens, short-lived credentials), declare them under `live_env`. Each value
194
+ is a shell command whose stdout becomes the variable's value:
195
+
196
+ ```yaml
197
+ live_env:
198
+ GRAZIE_TOKEN: cat ~/.grazie-token
199
+ AWS_SESSION_TOKEN: aws configure export-credentials --format env | sed -n 's/.*AWS_SESSION_TOKEN=//p'
200
+
201
+ windows:
202
+ api:
203
+ root: api
204
+ live_env: [GRAZIE_TOKEN]
205
+ command: ./gradlew run
206
+
207
+ worker:
208
+ root: worker
209
+ live_env: [GRAZIE_TOKEN, AWS_SESSION_TOKEN]
210
+ command: ./gradlew run
211
+ ```
212
+
213
+ The window's launcher exports each variable from its command's output
214
+ (`export GRAZIE_TOKEN="$(cat ~/.grazie-token)"`) before running its own
215
+ command, so the value is always read fresh on (re)start. Commands run in
216
+ the config's base directory, so relative paths inside them resolve the same
217
+ way as `root` — regardless of an individual window's `root`.
218
+
219
+ When `mxup up` finds a `live_env` block in the config, it spawns a
220
+ background watcher process for the session. The watcher re-evaluates each
221
+ command on an interval; whenever a command's output changes, it restarts
222
+ every window whose `live_env` list references it. So `echo new >
223
+ ~/.grazie-token` is enough to bounce `api` and `worker` with the new value
224
+ — no manual restart. A command that exits non-zero is treated as "no value
225
+ yet" and never triggers a restart on its own.
226
+
227
+ | Field | Required | Description |
228
+ |-------|----------|-------------|
229
+ | `live_env` (top-level) | no | Map of env var name → shell command. The command's stdout is the value; it runs in the config's base directory. |
230
+ | `live_env` (per-window) | no | List of names from the top-level pool that this window depends on. Unknown names are an error. |
231
+
232
+ The watcher is stopped automatically by `mxup down`. Its PID, log, and
233
+ last-restart state live under `~/.local/share/mxup/<session>/`. `mxup
234
+ status` shows the watcher's PID and the most recent restart it issued.
235
+
236
+ ### Required env vars
237
+
238
+ Some values can't live in the config because they're secret or
239
+ machine-specific (API keys, DB passwords). Declare them under `required_env`
240
+ and `mxup up` will refuse to start until they're supplied:
241
+
242
+ ```yaml
243
+ required_env:
244
+ DATABASE_PASSWORD: # list/name-only form
245
+ STRIPE_SECRET_KEY: Test key from the dashboard # map form: name => description
246
+
247
+ windows:
248
+ api:
249
+ root: api
250
+ command: ./gradlew run
251
+ ```
252
+
253
+ Values are read from a dotenv-style file named after the config, sitting
254
+ right next to it — `~/.config/mxup/myapp-dev.yml` → `~/.config/mxup/myapp-dev.env`
255
+ (a project-local `.mxup/dev.yml` → `.mxup/dev.env`).
256
+
257
+ On `mxup up`, mxup checks that each required var has a non-empty value in
258
+ that file. If any are missing, it **creates or extends** the file with a
259
+ blank `NAME=` line for each (descriptions become `#` comments), tells you
260
+ what's missing, and aborts:
261
+
262
+ ```
263
+ $ mxup up myapp-dev
264
+ Missing required environment variable(s):
265
+ DATABASE_PASSWORD
266
+ STRIPE_SECRET_KEY — Test key from the dashboard
267
+ Fill in the values in /Users/me/.config/mxup/myapp-dev.env, then re-run `mxup up`.
268
+ ```
269
+
270
+ ```ini
271
+ # myapp-dev.env (generated)
272
+ DATABASE_PASSWORD=
273
+
274
+ # Test key from the dashboard
275
+ STRIPE_SECRET_KEY=
276
+ ```
277
+
278
+ Fill in the blanks and re-run. Existing values are never overwritten, so the
279
+ file is safe to keep around. Each window's launcher sources it (`set -a; . file;
280
+ set +a`) before `setup` and `command`, so every window inherits the values.
281
+
282
+ `--dry-run` reports what's missing but writes nothing and doesn't abort.
283
+
284
+ > **Keep it out of git.** The `.env` file holds secrets — add `*.env` (or
285
+ > `.mxup/*.env` for project-local configs) to your `.gitignore`.
286
+
287
+ | Field | Required | Description |
288
+ |-------|----------|-------------|
289
+ | `required_env` | no | List of names (`[A, B]`) or a map of `name => description`. Gates `mxup up`; values come from the adjacent `<config-name>.env` file. |
290
+
163
291
  ### Layouts
164
292
 
165
293
  Define multiple named layouts to control how windows are grouped as tmux panes:
@@ -293,6 +421,7 @@ graceful-stop dance), then brings the new profile up from a clean slate.
293
421
  | `mxup target [name:]<window>` | Print the tmux target (`session:window.pane`) for a logical window |
294
422
  | `mxup target [name]` | Print targets for every declared window (tab-separated) |
295
423
  | `mxup exec -t [name:]<window> "<cmd>"` | Run `<cmd>` in a pane, wait for completion, print output, exit with its status |
424
+ | `mxup [name] <window>:<command>` | Run a window's named `command` in a copy of its environment, streaming output to your shell |
296
425
 
297
426
  ### Flags
298
427
 
@@ -345,6 +474,39 @@ send to a pane that's currently running a non-shell process — pass `--force`
345
474
  to override. Use `--timeout N` to avoid hanging indefinitely on a runaway
346
475
  command (exits 124 on timeout).
347
476
 
477
+ ### Named commands (`mxup <config> <window>:<name>`)
478
+
479
+ A window can declare named one-off commands under `commands`. These are handy
480
+ for build/test/lint chores that share a window's working directory and
481
+ environment but shouldn't be baked into its long-running `command`:
482
+
483
+ ```yaml
484
+ windows:
485
+ air-backend:
486
+ root: backend
487
+ command: ./start-server.sh
488
+ env:
489
+ APP_ENV: dev
490
+ commands:
491
+ rebuild: ./gradlew build
492
+ test: ./gradlew test
493
+ ```
494
+
495
+ Run one with:
496
+
497
+ ```bash
498
+ mxup air-dev air-backend:rebuild # config name is optional if it resolves implicitly
499
+ mxup air-backend:test
500
+ ```
501
+
502
+ Unlike `mxup exec`, a named command does **not** run inside the live tmux
503
+ pane. It executes in a *copy* of that window's environment — the same `root`,
504
+ `setup` snippet, `live_env`, and `env` — as a local subprocess, streaming its
505
+ output straight to the calling shell and exiting with the command's own return
506
+ code. Because it never touches the running pane, it works whether or not the
507
+ session is up, and won't disturb the long-running process. (The window's
508
+ `wait_for` readiness gate is skipped; it guards the main command, not chores.)
509
+
348
510
  ## Reconciliation
349
511
 
350
512
  `mxup up` compares the declared config against the running tmux session:
@@ -24,28 +24,59 @@
24
24
 
25
25
  session: myapp-dev
26
26
 
27
+ # Base directory for the windows below. Relative values are resolved against
28
+ # the directory containing this file, so dropping this config into
29
+ # `.mxup/dev.yml` inside the repo would let you write `root: .` instead.
30
+ root: ~/code/myapp
31
+
27
32
  setup: |
28
33
  # Runs in every window before its command. Replace with whatever your
29
34
  # project needs (direnv, nvm, asdf, sourcing a .env, etc.).
30
35
  eval "$(direnv export zsh)"
31
36
 
37
+ # Env vars whose values come from a command and get rotated periodically.
38
+ # Each value is a shell command (run in this file's directory) whose stdout
39
+ # becomes the variable. mxup launches a background watcher; when a command's
40
+ # output changes, every window that lists the var in its `live_env:` block is
41
+ # auto-restarted.
42
+ live_env:
43
+ GRAZIE_TOKEN: cat ~/.grazie-token
44
+
45
+ # Env vars that must be filled in before `mxup up` will start anything.
46
+ # On `up`, mxup checks `myapp-dev.env` (next to this file); if any of these
47
+ # are unset it scaffolds that file with a blank line per var (the descriptions
48
+ # below become comments), then aborts so you can fill them in. Every window
49
+ # sources the file, so the values are available everywhere. Git-ignore *.env.
50
+ required_env:
51
+ STRIPE_SECRET_KEY: Test-mode secret key from the Stripe dashboard
52
+ DATABASE_PASSWORD: Password for the local postgres role
53
+
32
54
  windows:
33
55
  docker:
34
- root: ~/code/myapp-infra
56
+ root: ../myapp-infra # = ~/code/myapp-infra
35
57
  command: docker compose --profile ${DOCKER_PROFILES:-core} up
36
58
 
37
59
  api:
38
- root: ~/code/myapp/api
60
+ root: api # = ~/code/myapp/api
39
61
  wait_for:
40
62
  tcp: localhost:5432
41
63
  timeout: 120
64
+ live_env: [GRAZIE_TOKEN] # re-exported via `cat ~/.grazie-token`; changed output restarts this window
42
65
  env:
43
66
  APP_ENV: development
44
67
  DATABASE_URL: postgres://localhost/myapp_dev
45
68
  command: ./gradlew run
69
+ # Named one-off chores that share this window's dir + env. Run them without
70
+ # disturbing the server: `mxup myapp-dev api:rebuild` (streams to your
71
+ # shell, exits with the command's own status; works even if the session is
72
+ # down). The window's wait_for gate is skipped for these.
73
+ commands:
74
+ rebuild: ./gradlew build
75
+ test: ./gradlew test
76
+ migrate: ./gradlew flywayMigrate
46
77
 
47
78
  worker:
48
- root: ~/code/myapp/worker
79
+ root: worker
49
80
  wait_for: localhost:6379 # shorthand for a tcp check (redis)
50
81
  env:
51
82
  APP_ENV: development
@@ -54,7 +85,7 @@ windows:
54
85
  command: ./gradlew run
55
86
 
56
87
  scheduler:
57
- root: ~/code/myapp/scheduler
88
+ root: scheduler
58
89
  wait_for: localhost:5432
59
90
  env:
60
91
  APP_ENV: development
@@ -62,7 +93,7 @@ windows:
62
93
  command: ./gradlew run
63
94
 
64
95
  frontend:
65
- root: ~/code/myapp/frontend
96
+ root: frontend
66
97
  wait_for:
67
98
  http: http://localhost:8080/health
68
99
  timeout: 60
@@ -71,13 +102,13 @@ windows:
71
102
  command: pnpm run dev
72
103
 
73
104
  storybook:
74
- root: ~/code/myapp/frontend
105
+ root: frontend
75
106
  command: pnpm run storybook
76
107
 
77
108
  scratch:
78
109
  # No `command` -> plain interactive shell, handy for ad-hoc work
79
110
  # (e.g. `mxup exec -t myapp-dev:scratch "./gradlew test"`).
80
- root: ~/code/myapp
111
+ root: . # = ~/code/myapp
81
112
 
82
113
  layouts:
83
114
  full:
data/lib/mxup/cli.rb CHANGED
@@ -6,11 +6,19 @@ module Mxup
6
6
  # Argv parser + dispatch. Keeps parsing rules in one place; the actual
7
7
  # behaviour lives in Runner and the focused modules it drives.
8
8
  class CLI
9
- COMMANDS = %w[up status down restart layout target exec].freeze
9
+ COMMANDS = %w[up status down restart layout target exec watch].freeze
10
10
 
11
11
  def run(argv)
12
12
  args, options = parse(argv.dup)
13
13
  command = extract_command(args)
14
+
15
+ # `mxup [config] <window>:<command>` — run a window's named command.
16
+ # Only in the default (no explicit command keyword) path; `restart`/
17
+ # `target` etc. keep their own `session:windows` colon syntax.
18
+ if command == 'up' && (spec = window_command_spec(args))
19
+ return run_window_command(spec, options)
20
+ end
21
+
14
22
  dispatch(command, args, options)
15
23
  end
16
24
 
@@ -60,11 +68,31 @@ module Mxup
60
68
  when 'restart', 'target' then run_restart_or_target(command, args, options)
61
69
  when 'layout' then run_layout(args, options)
62
70
  when 'exec' then run_exec(args, options)
71
+ when 'watch' then run_watch(args, options)
63
72
  else
64
73
  abort "Unknown command: #{command}. Use: #{COMMANDS.join(', ')}"
65
74
  end
66
75
  end
67
76
 
77
+ # Detect the `mxup [config] WINDOW:COMMAND` form. Returns
78
+ # [config_name, window, command_name] or nil. The colon-bearing positional
79
+ # is the window:command; any other positional is the config name.
80
+ def window_command_spec(args)
81
+ idx = args.index { |a| a.include?(':') }
82
+ return nil unless idx
83
+
84
+ window, command_name = args[idx].split(':', 2)
85
+ return nil if window.empty? || command_name.nil? || command_name.empty?
86
+
87
+ config_name = args.each_index.reject { |i| i == idx }.map { |i| args[i] }.first
88
+ [config_name, window, command_name]
89
+ end
90
+
91
+ def run_window_command(spec, options)
92
+ config_name, window_name, command_name = spec
93
+ build_runner(options, config_name).run_command(window_name, command_name)
94
+ end
95
+
68
96
  def run_basic(command, args, options)
69
97
  name = args.shift
70
98
  runner = build_runner(options, name)
@@ -93,6 +121,18 @@ module Mxup
93
121
  target_layout ? runner.switch_layout(target_layout) : runner.show_layouts
94
122
  end
95
123
 
124
+ # `mxup watch [name]` — runs the live_env command watcher in the foreground.
125
+ # Mostly used internally: `Runner#up` spawns this detached when the config
126
+ # declares any `live_env`. Useful in the foreground for debugging.
127
+ def run_watch(args, options)
128
+ name = args.shift
129
+ config = load_config(options[:config], name, options[:profile])
130
+ if config.live_env.empty?
131
+ abort "Config has no live_env declared; nothing to watch."
132
+ end
133
+ exit Watcher.new(config).run
134
+ end
135
+
96
136
  def run_exec(args, options)
97
137
  target = options[:target] || args.shift
98
138
  abort 'Usage: mxup exec -t [name:]WINDOW "command"' if target.nil? || target.empty?
@@ -124,12 +164,17 @@ module Mxup
124
164
  end
125
165
 
126
166
  # Ambiguous: is `spec` a config name or a window name?
127
- looks_like_config = File.exist?(File.join(CONFIG_DIR, "#{spec}.yml")) ||
167
+ looks_like_config = config_exists?(spec) ||
128
168
  (options[:config] && spec !~ /,/)
129
169
  return [spec, args] if looks_like_config
130
170
  [nil, [spec] + args]
131
171
  end
132
172
 
173
+ def config_exists?(name)
174
+ File.exist?(File.join(local_config_dir, "#{name}.yml")) ||
175
+ File.exist?(File.join(CONFIG_DIR, "#{name}.yml"))
176
+ end
177
+
133
178
  def build_runner(options, name)
134
179
  config = load_config(options[:config], name, options[:profile])
135
180
  Runner.new(config, dry_run: options[:dry_run], layout: options[:layout])
@@ -137,7 +182,10 @@ module Mxup
137
182
 
138
183
  def load_config(explicit_path, name, profile = nil)
139
184
  path = resolve_config(explicit_path, name)
140
- abort "Config not found. Provide -f path or place config in #{CONFIG_DIR}/" unless path
185
+ unless path
186
+ abort "Config not found. Provide -f path, place config in #{CONFIG_DIR}/, " \
187
+ "or in a local ./#{LOCAL_CONFIG_DIR}/ directory."
188
+ end
141
189
  Config.new(path, profile: profile)
142
190
  rescue ArgumentError => e
143
191
  abort e.message
@@ -145,26 +193,40 @@ module Mxup
145
193
 
146
194
  # Config resolution order:
147
195
  # 1. explicit -f path
148
- # 2. ~/.config/mxup/<name>.yml
149
- # 3. ./mxup.yml
150
- # 4. sole *.yml in ~/.config/mxup/
196
+ # 2. ./.mxup/<name>.yml (project-local, takes precedence)
197
+ # 3. ~/.config/mxup/<name>.yml
198
+ # 4. ./mxup.yml
199
+ # 5. sole *.yml in ./.mxup/
200
+ # 6. sole *.yml in ~/.config/mxup/
151
201
  def resolve_config(explicit, name)
152
202
  return explicit if explicit && File.exist?(explicit)
153
203
 
154
204
  if name
155
- candidate = File.join(CONFIG_DIR, "#{name}.yml")
156
- return candidate if File.exist?(candidate)
205
+ local_named = File.join(local_config_dir, "#{name}.yml")
206
+ return local_named if File.exist?(local_named)
207
+
208
+ global_named = File.join(CONFIG_DIR, "#{name}.yml")
209
+ return global_named if File.exist?(global_named)
157
210
  end
158
211
 
159
- local = File.join(Dir.pwd, 'mxup.yml')
160
- return local if File.exist?(local)
212
+ bare = File.join(Dir.pwd, 'mxup.yml')
213
+ return bare if File.exist?(bare)
161
214
 
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
215
+ sole = sole_config_in(local_config_dir) || sole_config_in(CONFIG_DIR)
216
+ return sole if sole
166
217
 
167
218
  nil
168
219
  end
220
+
221
+ def local_config_dir
222
+ File.join(Dir.pwd, LOCAL_CONFIG_DIR)
223
+ end
224
+
225
+ def sole_config_in(dir)
226
+ return nil unless Dir.exist?(dir)
227
+
228
+ configs = Dir.glob(File.join(dir, '*.yml'))
229
+ configs.size == 1 ? configs.first : nil
230
+ end
169
231
  end
170
232
  end