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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f2f166f4e87656548399fe98f82650123ef4fe98091cef4c3e3cc1d123ea8506
4
+ data.tar.gz: 5aea8148ab5bd0cbee4e3635a0df5a3e0513c8dfc3e110408db674c795552b84
5
+ SHA512:
6
+ metadata.gz: 10a37a6fad2a6409ceabf94f7bd0c63d36645a7a402d609b4207255753cefeffeee162fefcc0aee8d01777ac7535fba71842b5ff9298a43b48b5cf1c6393f5de
7
+ data.tar.gz: 4463a0a269f5cd7fa1ffecc85e30f05822420afe8796df195ffadc8fdab05191bb03f8bd590d99b1c01f60f1a4a199184535badb2e3ec410fe701bbcc6ce0c08
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Saifulin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,395 @@
1
+ # mxup
2
+
3
+ Declarative tmux session manager with reconciliation.
4
+
5
+ Run `mxup up` any time — it creates what's missing, restarts what crashed, removes what's not declared, and leaves healthy processes alone.
6
+
7
+ ## Install
8
+
9
+ Requires `tmux` and Ruby 3.1+ (stdlib only — no runtime gem dependencies).
10
+
11
+ ### RubyGems
12
+
13
+ ```bash
14
+ gem install mxup
15
+ ```
16
+
17
+ ### Homebrew
18
+
19
+ ```bash
20
+ brew install Recognized/mxup/mxup
21
+ ```
22
+
23
+ ### From source
24
+
25
+ ```bash
26
+ git clone https://github.com/Recognized/mxup.git ~/src/mxup
27
+ ln -sf ~/src/mxup/bin/mxup ~/.local/bin/mxup # ensure ~/.local/bin is on PATH
28
+ ```
29
+
30
+ ## Quick start
31
+
32
+ ```bash
33
+ # Create a config
34
+ mkdir -p ~/.config/mxup
35
+ cp ~/IdeaProjects/mxup/examples/myapp-dev.yml ~/.config/mxup/
36
+
37
+ # Bring the session up (reconcile)
38
+ mxup up myapp-dev
39
+
40
+ # Check what's running
41
+ mxup status myapp-dev
42
+
43
+ # Restart specific windows
44
+ mxup restart myapp-dev:api
45
+ mxup restart myapp-dev:api,worker
46
+
47
+ # Restart all windows
48
+ mxup restart myapp-dev
49
+
50
+ # Tear everything down
51
+ mxup down myapp-dev
52
+ ```
53
+
54
+ ## Config format
55
+
56
+ Configs live in `~/.config/mxup/<name>.yml` or can be passed via `-f path`.
57
+
58
+ ```yaml
59
+ session: my-project
60
+
61
+ # Shell snippet run in every window before the command
62
+ setup: |
63
+ direnv allow . 2>/dev/null
64
+ eval "$(direnv export zsh 2>/dev/null)"
65
+
66
+ windows:
67
+ database:
68
+ root: ~/projects/my-app
69
+ command: docker compose up postgres redis
70
+
71
+ backend:
72
+ root: ~/projects/my-app/backend
73
+ wait_for: localhost:5432
74
+ env:
75
+ DATABASE_URL: postgres://localhost/myapp_dev
76
+ command: ./start-server.sh
77
+
78
+ frontend:
79
+ root: ~/projects/my-app/frontend
80
+ command: npm run dev
81
+
82
+ shell:
83
+ root: ~/projects/my-app
84
+ ```
85
+
86
+ ### Fields
87
+
88
+ | Field | Required | Description |
89
+ |-------|----------|-------------|
90
+ | `session` | yes | tmux session name |
91
+ | `setup` | no | Shell snippet prepended to every window's command |
92
+ | `windows` | yes | Ordered map of window definitions |
93
+
94
+ Per window:
95
+
96
+ | Field | Required | Description |
97
+ |-------|----------|-------------|
98
+ | `root` | yes | Working directory (supports `~`) |
99
+ | `command` | no | Command to run. Omit for an interactive shell. |
100
+ | `env` | no | Map of environment variables to export |
101
+ | `wait_for` | no | Readiness check to pass before running command (see below) |
102
+
103
+ ### Wait-for checks
104
+
105
+ `wait_for` blocks a window's command until a readiness condition is met.
106
+
107
+ **Shorthand** — TCP check (backward compatible):
108
+
109
+ ```yaml
110
+ wait_for: localhost:5432
111
+ ```
112
+
113
+ **Expanded form** with explicit check type:
114
+
115
+ ```yaml
116
+ # TCP port open
117
+ wait_for:
118
+ tcp: localhost:5432
119
+
120
+ # HTTP 2xx response
121
+ wait_for:
122
+ http: http://localhost:8080/health
123
+
124
+ # File or socket exists
125
+ wait_for:
126
+ path: /tmp/app.sock
127
+
128
+ # Arbitrary script (exit 0 = ready)
129
+ wait_for:
130
+ script: pg_isready -h localhost -p 5432
131
+ label: postgres # shown in wait/ready messages
132
+ ```
133
+
134
+ All forms support optional `timeout` (seconds, default: unlimited) and `interval` (seconds between retries, default: 2):
135
+
136
+ ```yaml
137
+ wait_for:
138
+ tcp: localhost:5432
139
+ timeout: 60
140
+ interval: 5
141
+ ```
142
+
143
+ | Option | Default | Description |
144
+ |--------|---------|-------------|
145
+ | `timeout` | unlimited | Max seconds to wait before giving up |
146
+ | `interval` | 2 | Seconds between retry attempts |
147
+ | `label` | target value | Display name in wait/ready messages |
148
+
149
+ ### Parameterization
150
+
151
+ Use standard shell variable expansion in commands:
152
+
153
+ ```yaml
154
+ command: ./run.sh --env=${APP_ENV:-development}
155
+ ```
156
+
157
+ Then override at invocation:
158
+
159
+ ```bash
160
+ APP_ENV=production mxup up my-project
161
+ ```
162
+
163
+ ### Layouts
164
+
165
+ Define multiple named layouts to control how windows are grouped as tmux panes:
166
+
167
+ ```yaml
168
+ layouts:
169
+ full:
170
+ services:
171
+ panes: [database, backend]
172
+ split: even-horizontal
173
+ frontend:
174
+ panes: [frontend]
175
+
176
+ compact:
177
+ all:
178
+ panes: [database, backend, frontend]
179
+ split: tiled
180
+
181
+ flat: {}
182
+ ```
183
+
184
+ Each layout is a map of **group names** to group definitions. Windows in a group share a single tmux window as split panes. Windows not mentioned in any group remain standalone.
185
+
186
+ | Field | Required | Description |
187
+ |-------|----------|-------------|
188
+ | `layouts` | no | Map of named layout definitions |
189
+
190
+ Per group:
191
+
192
+ | Field | Required | Description |
193
+ |-------|----------|-------------|
194
+ | `panes` | yes | List of window names to group as panes |
195
+ | `split` | no | tmux layout: `even-horizontal`, `even-vertical`, `main-horizontal`, `main-vertical`, `tiled` (default: `tiled`) |
196
+
197
+ The first layout is used by default. Override with `--layout`:
198
+
199
+ ```bash
200
+ mxup up my-project --layout=compact
201
+ ```
202
+
203
+ Switch layouts on a running session without killing processes:
204
+
205
+ ```bash
206
+ mxup layout my-project compact
207
+ ```
208
+
209
+ ### Profiles
210
+
211
+ A single project often needs to run under different stacks — "local
212
+ everything", "staging backend with local frontend", etc. Profiles express
213
+ those variants as a set of overrides on top of a shared base. Only one
214
+ profile of a given config may be live at a time; `mxup up` of a different
215
+ profile automatically tears the current one down first.
216
+
217
+ ```yaml
218
+ session: my-project
219
+
220
+ windows:
221
+ backend:
222
+ root: ~/projects/my-app/backend
223
+ command: ./start-server.sh
224
+ env:
225
+ DATABASE_URL: postgres://localhost/myapp_dev
226
+
227
+ frontend:
228
+ root: ~/projects/my-app/frontend
229
+ command: npm run dev
230
+
231
+ profiles:
232
+ local: {} # uses the base as-is
233
+
234
+ staging:
235
+ windows:
236
+ backend:
237
+ command: ./connect-staging.sh
238
+ env:
239
+ DATABASE_URL: postgres://staging-db/myapp
240
+ ```
241
+
242
+ Pick a profile with `--profile` (short: `-p`):
243
+
244
+ ```bash
245
+ mxup up my-project --profile=local
246
+ mxup up my-project -p staging # tears down 'local' first
247
+ mxup status my-project # shows "profile: staging" in the header
248
+ ```
249
+
250
+ | Field | Required | Description |
251
+ |-------|----------|-------------|
252
+ | `profiles` | no | Map of profile name → override block |
253
+ | `default_profile` | no | Profile to use when `--profile` is omitted (defaults to the first declared) |
254
+
255
+ **Override semantics**: the active profile's `setup`, `windows`, and
256
+ `layouts` override the base. Window overrides are merged per-key (so you
257
+ can tweak just `command` or `env` without redeclaring `root`). `env` maps
258
+ are themselves merged — keys not in the profile are inherited from the
259
+ base. A profile may not override `session`; profiles of the same group
260
+ must share one tmux session.
261
+
262
+ **Dropping windows**: to exclude a base window from a profile, map it to
263
+ `~` (YAML null):
264
+
265
+ ```yaml
266
+ profiles:
267
+ minimal:
268
+ windows:
269
+ dev-kit: ~ # don't start dev-kit under the `minimal` profile
270
+ scratch: ~
271
+ ```
272
+
273
+ Any layout groups that reference a dropped window are automatically
274
+ pruned — entries are stripped from `panes:` lists, and a group that ends
275
+ up empty is removed from its layout.
276
+
277
+ **Switching**: if the tmux session is already running under a different
278
+ profile, `mxup up` for a new profile runs `down` first (including the
279
+ graceful-stop dance), then brings the new profile up from a clean slate.
280
+ `mxup status` always shows the currently live profile in its header.
281
+
282
+ ## Commands
283
+
284
+ | Command | Description |
285
+ |---------|-------------|
286
+ | `mxup up [name]` | Reconcile session to match config (default when no subcommand) |
287
+ | `mxup status [name]` | Show per-window status with recent output |
288
+ | `mxup down [name]` | Kill the session |
289
+ | `mxup restart [name:]<w1,w2,...>` | Restart specific window(s) (comma-separated) |
290
+ | `mxup restart [name]` | Restart all windows in the session |
291
+ | `mxup layout [name]` | Show available layouts and which is active |
292
+ | `mxup layout [name] <layout>` | Switch to a different layout (preserves running processes) |
293
+ | `mxup target [name:]<window>` | Print the tmux target (`session:window.pane`) for a logical window |
294
+ | `mxup target [name]` | Print targets for every declared window (tab-separated) |
295
+ | `mxup exec -t [name:]<window> "<cmd>"` | Run `<cmd>` in a pane, wait for completion, print output, exit with its status |
296
+
297
+ ### Flags
298
+
299
+ | Flag | Description |
300
+ |------|-------------|
301
+ | `-f path` | Use a specific config file |
302
+ | `--dry-run` | Preview changes without applying (for `up`, `restart`, `exec`) |
303
+ | `--lines N` | Output lines to show (for `status` default 15, for `exec` default 50) |
304
+ | `--layout NAME` | Layout to use (for `up`) |
305
+ | `-p`, `--profile NAME` | Profile to use; auto-teardowns a live session running under a different profile (for `up`, `status`, `restart`) |
306
+ | `-t TARGET` | Pane target (for `exec`); accepts `name:window`, `window`, or `window.pane` |
307
+ | `--timeout N` | Max seconds to wait for the command (for `exec`; exit 124 on timeout) |
308
+ | `--force` | Send the command even if the pane is busy with another process (for `exec`) |
309
+ | `-q`, `--quiet` | Don't print captured output (for `exec`) |
310
+
311
+ ### Running one-off commands in a pane (`mxup exec`)
312
+
313
+ `mxup exec` is a shortcut for the common "send a command to a tmux pane, wait
314
+ for it to finish, and show the output" loop. It handles the three annoying
315
+ parts for you:
316
+
317
+ 1. **Resolving logical names to real tmux targets** — `api` may actually
318
+ live as pane `services.1`; `mxup exec -t myapp-dev:api` figures that
319
+ out via the active layout.
320
+ 2. **Waiting for the command to finish** — uses `tmux wait-for` with a unique
321
+ marker under the hood, so `exec` blocks until the command actually exits.
322
+ 3. **Capturing output and exit status** — prints the last `--lines N` lines of
323
+ the pane and exits with the command's own exit code.
324
+
325
+ So instead of the verbose recipe:
326
+
327
+ ```bash
328
+ MARKER="fulltest-$(date +%s%N)"
329
+ tmux send-keys -t myapp-dev:scratch \
330
+ "./gradlew test 2>&1 | tail -n 30; echo FULLTEST_EXIT=\$?; tmux wait-for -S $MARKER" Enter \
331
+ && tmux wait-for $MARKER
332
+ tmux capture-pane -t myapp-dev:scratch -p -S -50
333
+ ```
334
+
335
+ you write:
336
+
337
+ ```bash
338
+ mxup exec -t myapp-dev:scratch "./gradlew test 2>&1 | tail -n 30"
339
+ echo "exit: $?"
340
+ ```
341
+
342
+ The user command is wrapped in a subshell, so `exit`, `set -e`, or a failing
343
+ command won't kill the pane's interactive shell. By default `exec` refuses to
344
+ send to a pane that's currently running a non-shell process — pass `--force`
345
+ to override. Use `--timeout N` to avoid hanging indefinitely on a runaway
346
+ command (exits 124 on timeout).
347
+
348
+ ## Reconciliation
349
+
350
+ `mxup up` compares the declared config against the running tmux session:
351
+
352
+ - **Missing windows** → created and command started
353
+ - **Extra windows** → killed (with warning)
354
+ - **Idle/crashed windows** (shell prompt visible) → command re-sent
355
+ - **Healthy running windows** → left untouched
356
+ - **Layout changed** → panes rearranged without killing processes
357
+
358
+ ## Releasing
359
+
360
+ Releases are automated by `.github/workflows/release.yml`. To cut a new version:
361
+
362
+ 1. Bump `Mxup::VERSION` in `lib/mxup/version.rb`.
363
+ 2. Commit and tag: `git commit -am "Release vX.Y.Z" && git tag vX.Y.Z`.
364
+ 3. `git push origin main --tags`.
365
+
366
+ The workflow then runs the test suite, verifies the tag matches
367
+ `Mxup::VERSION`, publishes the gem to RubyGems via [trusted publishing][tp]
368
+ (OIDC — no API keys stored), creates a GitHub release with the built `.gem`
369
+ attached, and — if a Homebrew tap is configured — opens a PR in the tap repo
370
+ bumping the formula.
371
+
372
+ ### One-time setup
373
+
374
+ **RubyGems trusted publishing.** Claim `mxup` on rubygems.org, then under
375
+ _Settings → Trusted publishers_ add:
376
+
377
+ - Repository: `Recognized/mxup`
378
+ - Workflow: `release.yml`
379
+ - Environment: _(leave blank)_
380
+
381
+ **Homebrew tap (optional).** Create a `Recognized/homebrew-mxup` repo,
382
+ copy `packaging/homebrew/mxup.rb` to `Formula/mxup.rb` in it, then in the
383
+ `mxup` repo's settings add:
384
+
385
+ - Variable `HOMEBREW_TAP` = `Recognized/homebrew-mxup`
386
+ - Secret `HOMEBREW_TAP_TOKEN` = a PAT with `contents: write` on the tap repo
387
+
388
+ The `homebrew` job in `release.yml` will then run automatically on every
389
+ tag push and keep the formula in sync.
390
+
391
+ [tp]: https://guides.rubygems.org/trusted-publishing/
392
+
393
+ ## License
394
+
395
+ MIT
data/bin/mxup ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Thin entrypoint. All behaviour lives under lib/mxup/.
5
+ lib = File.expand_path('../lib', __dir__)
6
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
7
+
8
+ require 'mxup'
9
+
10
+ Mxup::CLI.new.run(ARGV) if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,110 @@
1
+ # Example: local development stack for a fictional app called "myapp"
2
+ #
3
+ # This is a showcase config — it exercises most mxup features (multiple
4
+ # windows, wait_for probes, per-window env, alternative layouts, and
5
+ # profiles that drop or override windows). Adapt the paths, commands,
6
+ # and env vars to your own project.
7
+ #
8
+ # Place your copy at ~/.config/mxup/myapp-dev.yml, then:
9
+ # mxup up myapp-dev
10
+ # mxup status myapp-dev
11
+ # mxup restart myapp-dev:api
12
+ # mxup down myapp-dev
13
+ #
14
+ # Switch layouts:
15
+ # mxup layout myapp-dev compact
16
+ # mxup up myapp-dev --layout=flat
17
+ #
18
+ # Switch profiles (tears down the current profile first):
19
+ # mxup up myapp-dev --profile=local # default, everything on localhost
20
+ # mxup up myapp-dev --profile=staging # frontend only, talks to hosted API
21
+ #
22
+ # Parameterize via env vars:
23
+ # DOCKER_PROFILES=core mxup up myapp-dev
24
+
25
+ session: myapp-dev
26
+
27
+ setup: |
28
+ # Runs in every window before its command. Replace with whatever your
29
+ # project needs (direnv, nvm, asdf, sourcing a .env, etc.).
30
+ eval "$(direnv export zsh)"
31
+
32
+ windows:
33
+ docker:
34
+ root: ~/code/myapp-infra
35
+ command: docker compose --profile ${DOCKER_PROFILES:-core} up
36
+
37
+ api:
38
+ root: ~/code/myapp/api
39
+ wait_for:
40
+ tcp: localhost:5432
41
+ timeout: 120
42
+ env:
43
+ APP_ENV: development
44
+ DATABASE_URL: postgres://localhost/myapp_dev
45
+ command: ./gradlew run
46
+
47
+ worker:
48
+ root: ~/code/myapp/worker
49
+ wait_for: localhost:6379 # shorthand for a tcp check (redis)
50
+ env:
51
+ APP_ENV: development
52
+ DATABASE_URL: postgres://localhost/myapp_dev
53
+ REDIS_URL: redis://localhost:6379
54
+ command: ./gradlew run
55
+
56
+ scheduler:
57
+ root: ~/code/myapp/scheduler
58
+ wait_for: localhost:5432
59
+ env:
60
+ APP_ENV: development
61
+ DATABASE_URL: postgres://localhost/myapp_dev
62
+ command: ./gradlew run
63
+
64
+ frontend:
65
+ root: ~/code/myapp/frontend
66
+ wait_for:
67
+ http: http://localhost:8080/health
68
+ timeout: 60
69
+ env:
70
+ API_URL: http://localhost:8080
71
+ command: pnpm run dev
72
+
73
+ storybook:
74
+ root: ~/code/myapp/frontend
75
+ command: pnpm run storybook
76
+
77
+ scratch:
78
+ # No `command` -> plain interactive shell, handy for ad-hoc work
79
+ # (e.g. `mxup exec -t myapp-dev:scratch "./gradlew test"`).
80
+ root: ~/code/myapp
81
+
82
+ layouts:
83
+ full:
84
+ services:
85
+ panes: [docker, api, worker, scheduler]
86
+ split: main-vertical
87
+ frontend:
88
+ panes: [frontend, storybook]
89
+ split: even-horizontal
90
+
91
+ compact:
92
+ all:
93
+ panes: [docker, api, worker, scheduler, frontend, storybook]
94
+ split: tiled
95
+
96
+ flat: {}
97
+
98
+ profiles:
99
+ # Everything runs locally via docker + ./gradlew run (the base config).
100
+ local: {}
101
+
102
+ # Staging: skip local backends, point the frontend at the hosted API.
103
+ staging:
104
+ windows:
105
+ api: ~
106
+ worker: ~
107
+ scheduler: ~
108
+ frontend:
109
+ env:
110
+ API_URL: https://api.staging.example.com