Commit fdc2449
Eric Bower
·
2026-08-20 10:23:44 -0400 EDT
parent fb1b6b6
feat: leader sets tracked env vars When a client assumes the leader for a zmx session it sets the tracked environment variables for the session which can the user can then print using `zmx print-env .`. This can be used inside a shell precmd hook to automatically set the leader's env vars to the shell session: `eval "$(zmx print-env .)"`. Link: https://github.com/neurosnap/zmx/issues/225 Link: https://github.com/neurosnap/zmx/issues/104
7 files changed,
+461,
-38
+1,
-0
| ... | ... | @@ -12,6 +12,7 @@ Use spec: https://common-changelog.org/ | |
| 12 | 12 | - Added `--labels` flag to attach command | |
| 13 | 13 | - e.g. `zmx attach --labels "project=pico env=prod" pico` | |
| 14 | 14 | - e.g. `zmx attach --labels "$(zmx get pico)" pico.sub` | |
| 15 | + | - New command `zmx print-env` which prints the leader's tracked env vars | |
| 15 | 16 | ||
| 16 | 17 | ### Fixed | |
| 17 | 18 |
+86,
-8
| ... | ... | @@ -110,8 +110,8 @@ Commands: | |
| 110 | 110 | [l]ist|ls [--short] List active sessions | |
| 111 | 111 | [g]et <name> Get session labels | |
| 112 | 112 | set <name> k=v ... Set session labels | |
| 113 | - | [un]set <name> key ... Remove session labels | |
| 114 | 113 | [cl]ear <name> Clear all session labels | |
| 114 | + | print-env [-s] <name> [key] Print tracked environment variables | |
| 115 | 115 | [k]ill <name>... [--force] Kill session and all attached clients | |
| 116 | 116 | [hi]story <name> [--vt|--html] Output session scrollback | |
| 117 | 117 | [w]ait <name>... Wait for session tasks to complete |
| ... | ... | @@ -123,14 +123,9 @@ Commands: | |
| 123 | 123 | ||
| 124 | 124 | ## nested sessions | |
| 125 | 125 | ||
| 126 | - | Nested sessions are not supported. Inside a session `ZMX_SESSION` is set, and | |
| 127 | - | `attach` reads it: instead of creating another client it switches the calling | |
| 128 | - | terminal to the session you named. | |
| 126 | + | Nested sessions are not supported. Inside a session `ZMX_SESSION` is set, and `attach` reads it: instead of creating another client it switches the calling terminal to the session you named. | |
| 129 | 127 | ||
| 130 | - | That matters when the variable is inherited rather than chosen. A script, build | |
| 131 | - | tool, or coding agent started inside a session runs with `ZMX_SESSION` set, so | |
| 132 | - | `zmx attach other` from there moves the terminal somebody was using, and the | |
| 133 | - | session it was showing is left with no client. | |
| 128 | + | That matters when the variable is inherited rather than chosen. A script, build tool, or coding agent started inside a session runs with `ZMX_SESSION` set, so `zmx attach other` from there moves the terminal somebody was using, and the session it was showing is left with no client. | |
| 134 | 129 | ||
| 135 | 130 | Unset it in anything that attaches on its own behalf: | |
| 136 | 131 |
| ... | ... | @@ -216,6 +211,89 @@ description = "zmx session name" | |
| 216 | 211 | style = "bold magenta" | |
| 217 | 212 | ``` | |
| 218 | 213 | ||
| 214 | + | ## environment variables | |
| 215 | + | ||
| 216 | + | A session's environment is initialized when the session is created. When re-attaching from a new terminal or a different SSH connection, environment variables describing the client connection (such as `SSH_AUTH_SOCK`, `DISPLAY`, or terminal remote control variables like `KITTY_LISTEN_ON`, `KITTY_PID`, `KITTY_WINDOW_ID`) become outdated in the running shell. | |
| 217 | + | ||
| 218 | + | `zmx` tracks these environment variables from attaching clients. You can inspect the current leader client's environment with `zmx print-env`: | |
| 219 | + | ||
| 220 | + | ```bash | |
| 221 | + | # Print all tracked environment variables (set variables as KEY=VALUE, unset as -KEY) | |
| 222 | + | zmx print-env . | |
| 223 | + | zmx print-env dev | |
| 224 | + | ||
| 225 | + | # Print POSIX shell commands (export ... / unset ...) suitable for eval | |
| 226 | + | zmx print-env -s . | |
| 227 | + | ||
| 228 | + | # Print a specific variable value | |
| 229 | + | zmx print-env . SSH_AUTH_SOCK | |
| 230 | + | ``` | |
| 231 | + | ||
| 232 | + | You can automatically update your shell environment before each prompt: | |
| 233 | + | ||
| 234 | + | ### bash | |
| 235 | + | ||
| 236 | + | Add to `~/.bashrc`: | |
| 237 | + | ||
| 238 | + | ```bash | |
| 239 | + | _zmx_env_hook() { | |
| 240 | + | if [[ -n $ZMX_SESSION ]]; then | |
| 241 | + | eval "$(zmx print-env -s .)" | |
| 242 | + | fi | |
| 243 | + | } | |
| 244 | + | PROMPT_COMMAND="_zmx_env_hook${PROMPT_COMMAND:+; $PROMPT_COMMAND}" | |
| 245 | + | ``` | |
| 246 | + | ||
| 247 | + | ### zsh | |
| 248 | + | ||
| 249 | + | Add to `~/.zshrc`: | |
| 250 | + | ||
| 251 | + | ```zsh | |
| 252 | + | precmd() { | |
| 253 | + | if [[ -n $ZMX_SESSION ]]; then | |
| 254 | + | eval "$(zmx print-env -s .)" | |
| 255 | + | fi | |
| 256 | + | } | |
| 257 | + | ``` | |
| 258 | + | ||
| 259 | + | ### fish | |
| 260 | + | ||
| 261 | + | Add to `~/.config/fish/config.fish`: | |
| 262 | + | ||
| 263 | + | ```fish | |
| 264 | + | function _zmx_env_hook --on-event fish_prompt | |
| 265 | + | if test -n "$ZMX_SESSION" | |
| 266 | + | for line in (zmx print-env .) | |
| 267 | + | if string match -q -- "-*" $line | |
| 268 | + | set -e (string sub -s 2 $line) | |
| 269 | + | else | |
| 270 | + | set -gx (string split -m 1 "=" $line) | |
| 271 | + | end | |
| 272 | + | end | |
| 273 | + | end | |
| 274 | + | end | |
| 275 | + | ``` | |
| 276 | + | ||
| 277 | + | ### configuring tracked variables | |
| 278 | + | ||
| 279 | + | By default, `zmx` tracks: | |
| 280 | + | ||
| 281 | + | - `DISPLAY` | |
| 282 | + | - `SSH_AUTH_SOCK` | |
| 283 | + | - `SSH_AGENT_PID` | |
| 284 | + | - `SSH_CONNECTION` | |
| 285 | + | - `WINDOWID` | |
| 286 | + | - `XAUTHORITY` | |
| 287 | + | - `KITTY_LISTEN_ON` | |
| 288 | + | - `KITTY_PID` | |
| 289 | + | - `KITTY_WINDOW_ID` | |
| 290 | + | ||
| 291 | + | You can customize this list by setting `ZMX_TRACK_ENV` to a comma-separated list of variable names: | |
| 292 | + | ||
| 293 | + | ```bash | |
| 294 | + | export ZMX_TRACK_ENV="DISPLAY,SSH_AUTH_SOCK,GPG_AGENT_INFO" | |
| 295 | + | ``` | |
| 296 | + | ||
| 219 | 297 | ## shell completion | |
| 220 | 298 | ||
| 221 | 299 | Shell auto-completion for `zmx` commands and session names can be enabled using the `completions` subcommand. Once configured, you'll get auto-complete for both local `zmx` commands and sessions: |
+1,
-0
| ... | ... | @@ -12,6 +12,7 @@ log_dir: []const u8, | |
| 12 | 12 | max_scrollback_lines: usize = 2_000, // same default as tmux | |
| 13 | 13 | dir_mode: u32 = 0o750, | |
| 14 | 14 | log_mode: u32 = 0o640, | |
| 15 | + | tracked_envs: []const u8 = "DISPLAY,SSH_AUTH_SOCK,SSH_AGENT_PID,SSH_CONNECTION,WINDOWID,XAUTHORITY,KITTY_LISTEN_ON,KITTY_PID,KITTY_WINDOW_ID", | |
| 15 | 16 | ||
| 16 | 17 | pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg { | |
| 17 | 18 | const socket_dir = try socketDir(alloc); |
+13,
-4
| ... | ... | @@ -32,7 +32,7 @@ const bash_completions = | |
| 32 | 32 | \\ cur="${COMP_WORDS[COMP_CWORD]}" | |
| 33 | 33 | \\ prev="${COMP_WORDS[COMP_CWORD-1]}" | |
| 34 | 34 | \\ | |
| 35 | - | \\ local commands="attach run send print write detach list kill history get set clear wait tail completions version help" | |
| 35 | + | \\ local commands="attach run send print write detach list kill history get set clear print-env wait tail completions version help" | |
| 36 | 36 | \\ | |
| 37 | 37 | \\ if [[ $COMP_CWORD -eq 1 ]]; then | |
| 38 | 38 | \\ COMPREPLY=($(compgen -W "$commands" -- "$cur")) |
| ... | ... | @@ -40,7 +40,7 @@ const bash_completions = | |
| 40 | 40 | \\ fi | |
| 41 | 41 | \\ | |
| 42 | 42 | \\ case "$prev" in | |
| 43 | - | \\ attach|run|send|print|write|kill|history|get|set|clear|wait|tail) | |
| 43 | + | \\ attach|run|send|print|write|kill|history|get|set|clear|print-env|wait|tail) | |
| 44 | 44 | \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ') | |
| 45 | 45 | \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur")) | |
| 46 | 46 | \\ ;; |
| ... | ... | @@ -89,6 +89,7 @@ const zsh_completions = | |
| 89 | 89 | \\ 'get:Get session labels' | |
| 90 | 90 | \\ 'set:Set session labels' | |
| 91 | 91 | \\ 'clear:Clear all session labels' | |
| 92 | + | \\ 'print-env:Print tracked environment variables' | |
| 92 | 93 | \\ 'version:Show version' | |
| 93 | 94 | \\ 'help:Show help message' | |
| 94 | 95 | \\ ) |
| ... | ... | @@ -96,7 +97,7 @@ const zsh_completions = | |
| 96 | 97 | \\ ;; | |
| 97 | 98 | \\ args) | |
| 98 | 99 | \\ case $words[2] in | |
| 99 | - | \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|hi|wait|w|tail|t) | |
| 100 | + | \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|print-env|hi|wait|w|tail|t) | |
| 100 | 101 | \\ _zmx_sessions | |
| 101 | 102 | \\ ;; | |
| 102 | 103 | \\ completions|c) |
| ... | ... | @@ -151,10 +152,11 @@ const fish_completions = | |
| 151 | 152 | \\complete -c zmx -n "__fish_is_nth_token 1" -a get -d 'Get session labels' | |
| 152 | 153 | \\complete -c zmx -n "__fish_is_nth_token 1" -a set -d 'Set session labels' | |
| 153 | 154 | \\complete -c zmx -n "__fish_is_nth_token 1" -a clear -d 'Clear all session labels' | |
| 155 | + | \\complete -c zmx -n "__fish_is_nth_token 1" -a print-env -d 'Print tracked environment variables' | |
| 154 | 156 | \\complete -c zmx -n "__fish_is_nth_token 1" -a help -d 'Show help message' | |
| 155 | 157 | \\ | |
| 156 | 158 | \\# Complete session names and shells | |
| 157 | - | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 159 | + | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear print-env" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 158 | 160 | \\complete -c zmx -n "not __fish_is_nth_token 1; and __fish_seen_subcommand_from k kill w wait t tail" -a '(zmx list --short 2>/dev/null)' -d 'Session name' | |
| 159 | 161 | \\ | |
| 160 | 162 | \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish nu' -d Shell |
| ... | ... | @@ -167,6 +169,7 @@ const fish_completions = | |
| 167 | 169 | \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill' | |
| 168 | 170 | \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences' | |
| 169 | 171 | \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l html -d 'History format for escape sequences' | |
| 172 | + | \\complete -c zmx -n "__fish_seen_subcommand_from print-env" -s s -l shell -d 'Output POSIX export/unset commands for eval' | |
| 170 | 173 | ; | |
| 171 | 174 | ||
| 172 | 175 | const nu_completions = |
| ... | ... | @@ -231,5 +234,11 @@ const nu_completions = | |
| 231 | 234 | \\ name?: string@"nu-complete zmx sessions" | |
| 232 | 235 | \\] | |
| 233 | 236 | \\ | |
| 237 | + | \\export extern "zmx print-env" [ | |
| 238 | + | \\ name: string@"nu-complete zmx sessions" | |
| 239 | + | \\ key?: string | |
| 240 | + | \\ --shell(-s) | |
| 241 | + | \\] | |
| 242 | + | \\ | |
| 234 | 243 | \\export extern "zmx help" [] | |
| 235 | 244 | ; |
+3,
-0
| ... | ... | @@ -23,6 +23,9 @@ pub const Tag = enum(u8) { | |
| 23 | 23 | LabelClear = 16, | |
| 24 | 24 | LabelData = 17, | |
| 25 | 25 | Send = 18, | |
| 26 | + | EnvGet = 19, | |
| 27 | + | EnvSet = 20, | |
| 28 | + | EnvData = 21, | |
| 26 | 29 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 27 | 30 | // @enumFromInt, so out-of-range values are representable | |
| 28 | 31 | // rather than UB. Switches must handle `_` (unknown tag). |
+125,
-6
| ... | ... | @@ -15,7 +15,7 @@ const builtin = @import("builtin"); | |
| 15 | 15 | ||
| 16 | 16 | /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking | |
| 17 | 17 | /// mechanism. It will send stdin to the daemon and receive stdout from the daemon. | |
| 18 | - | pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 18 | + | pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult { | |
| 19 | 19 | std.log.info("client loop fd={d}", .{client_sock_fd}); | |
| 20 | 20 | const gpa: std.mem.Allocator = blk: { | |
| 21 | 21 | if (builtin.mode == .Debug) { |
| ... | ... | @@ -41,6 +41,10 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult { | |
| 41 | 41 | var sock_write_buf = try std.ArrayList(u8).initCapacity(gpa, 4096); | |
| 42 | 42 | defer sock_write_buf.deinit(gpa); | |
| 43 | 43 | ||
| 44 | + | if (env_str.len > 0) { | |
| 45 | + | try ipc.appendMessage(gpa, &sock_write_buf, .EnvSet, env_str); | |
| 46 | + | } | |
| 47 | + | ||
| 44 | 48 | // Send init message with terminal size (buffered) | |
| 45 | 49 | const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO); | |
| 46 | 50 | try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size)); |
| ... | ... | @@ -473,9 +477,11 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 473 | 477 | .LabelGet => try daemon.handleLabelGet(gpa, client), | |
| 474 | 478 | .LabelSet => try daemon.handleLabelSet(gpa, client, msg.payload), | |
| 475 | 479 | .LabelClear => try daemon.handleLabelClear(gpa, client), | |
| 480 | + | .EnvGet => try daemon.handleEnvGet(gpa, client), | |
| 481 | + | .EnvSet => try daemon.handleEnvSet(gpa, client, msg.payload), | |
| 476 | 482 | .History => try daemon.handleHistory(gpa, client, &term, msg.payload), | |
| 477 | 483 | .Run => try daemon.handleRun(gpa, io, client, msg.payload), | |
| 478 | - | .Ack, .TaskComplete, .LabelData => {}, | |
| 484 | + | .Ack, .TaskComplete, .LabelData, .EnvData => {}, | |
| 479 | 485 | .Write => try daemon.handleWrite(gpa, client, msg.payload), | |
| 480 | 486 | _ => std.log.warn( | |
| 481 | 487 | "ignoring unknown IPC tag={d}", |
| ... | ... | @@ -530,11 +536,18 @@ pub const Client = struct { | |
| 530 | 536 | has_pending_output: bool = false, | |
| 531 | 537 | read_buf: ipc.SocketBuffer, | |
| 532 | 538 | write_buf: std.ArrayList(u8), | |
| 539 | + | env_str: ?[]u8 = null, | |
| 533 | 540 | ||
| 534 | - | pub fn deinit(self: *Client) void { | |
| 541 | + | pub fn deinit(self: *Client, gpa: std.mem.Allocator) void { | |
| 535 | 542 | lib_posix.close(self.socket_fd); | |
| 536 | 543 | self.read_buf.deinit(); | |
| 537 | 544 | self.write_buf.deinit(self.alloc); | |
| 545 | + | if (self.env_str) |s| gpa.free(s); | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | fn setEnv(self: *Client, gpa: std.mem.Allocator, env_str: []const u8) !void { | |
| 549 | + | if (self.env_str) |s| gpa.free(s); | |
| 550 | + | self.env_str = if (env_str.len > 0) try gpa.dupe(u8, env_str) else null; | |
| 538 | 551 | } | |
| 539 | 552 | }; | |
| 540 | 553 |
| ... | ... | @@ -611,7 +624,7 @@ pub const Daemon = struct { | |
| 611 | 624 | self.running = false; | |
| 612 | 625 | ||
| 613 | 626 | for (self.clients.items) |client| { | |
| 614 | - | client.deinit(); | |
| 627 | + | client.deinit(gpa); | |
| 615 | 628 | gpa.destroy(client); | |
| 616 | 629 | } | |
| 617 | 630 | self.clients.clearRetainingCapacity(); |
| ... | ... | @@ -627,7 +640,7 @@ pub const Daemon = struct { | |
| 627 | 640 | ); | |
| 628 | 641 | self.leader_client_fd = null; | |
| 629 | 642 | } | |
| 630 | - | client.deinit(); | |
| 643 | + | client.deinit(gpa); | |
| 631 | 644 | gpa.destroy(client); | |
| 632 | 645 | _ = self.clients.orderedRemove(i); | |
| 633 | 646 | std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len }); |
| ... | ... | @@ -812,6 +825,15 @@ pub const Daemon = struct { | |
| 812 | 825 | client.has_pending_output = true; | |
| 813 | 826 | } | |
| 814 | 827 | ||
| 828 | + | fn getLeaderClient(self: *Daemon) ?*Client { | |
| 829 | + | for (self.clients.items) |client| { | |
| 830 | + | if (self.leader_client_fd == client.socket_fd) { | |
| 831 | + | return client; | |
| 832 | + | } | |
| 833 | + | } | |
| 834 | + | return null; | |
| 835 | + | } | |
| 836 | + | ||
| 815 | 837 | const PTY_WRITE_BUF_MAX = 256 * 1024; | |
| 816 | 838 | ||
| 817 | 839 | /// Queue bytes for the PTY's stdin. Flushed by daemonLoop on POLLOUT. |
| ... | ... | @@ -1013,7 +1035,7 @@ pub const Daemon = struct { | |
| 1013 | 1035 | pub fn handleDetachAll(self: *Daemon, gpa: std.mem.Allocator) void { | |
| 1014 | 1036 | std.log.info("detach all clients={d}", .{self.clients.items.len}); | |
| 1015 | 1037 | for (self.clients.items) |client_to_close| { | |
| 1016 | - | client_to_close.deinit(); | |
| 1038 | + | client_to_close.deinit(gpa); | |
| 1017 | 1039 | gpa.destroy(client_to_close); | |
| 1018 | 1040 | } | |
| 1019 | 1041 | self.clients.clearRetainingCapacity(); |
| ... | ... | @@ -1255,6 +1277,21 @@ pub const Daemon = struct { | |
| 1255 | 1277 | ); | |
| 1256 | 1278 | } | |
| 1257 | 1279 | ||
| 1280 | + | fn handleEnvGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void { | |
| 1281 | + | const leader_opt = self.getLeaderClient(); | |
| 1282 | + | const payload = if (leader_opt) |leader| leader.env_str orelse "" else ""; | |
| 1283 | + | try ipc.appendMessage(gpa, &client.write_buf, .EnvData, payload); | |
| 1284 | + | client.has_pending_output = true; | |
| 1285 | + | } | |
| 1286 | + | ||
| 1287 | + | fn handleEnvSet(_: *Daemon, gpa: std.mem.Allocator, client: *Client, env_str: []const u8) !void { | |
| 1288 | + | std.log.info("handle env set payload={s}", .{env_str}); | |
| 1289 | + | try client.setEnv(gpa, env_str); | |
| 1290 | + | ||
| 1291 | + | try ipc.appendMessage(gpa, &client.write_buf, .Ack, ""); | |
| 1292 | + | client.has_pending_output = true; | |
| 1293 | + | } | |
| 1294 | + | ||
| 1258 | 1295 | fn handleLabelGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void { | |
| 1259 | 1296 | const out = try label.labelsToU8(gpa, self.labels); | |
| 1260 | 1297 | defer gpa.free(out); |
| ... | ... | @@ -1323,3 +1360,85 @@ test "send queues PTY input without changing leader" { | |
| 1323 | 1360 | try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd); | |
| 1324 | 1361 | try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items); | |
| 1325 | 1362 | } | |
| 1363 | + | ||
| 1364 | + | test "handleEnvGet returns leader client's environment variables including unsets" { | |
| 1365 | + | const alloc = std.testing.allocator; | |
| 1366 | + | var cfg = Cfg{ | |
| 1367 | + | .socket_dir = "/tmp", | |
| 1368 | + | .log_dir = "/tmp", | |
| 1369 | + | }; | |
| 1370 | + | ||
| 1371 | + | var daemon = Daemon{ | |
| 1372 | + | .cfg = &cfg, | |
| 1373 | + | .clients = .empty, | |
| 1374 | + | .session_name = "test", | |
| 1375 | + | .socket_path = try alloc.dupe(u8, ""), | |
| 1376 | + | .running = true, | |
| 1377 | + | .pid = 0, | |
| 1378 | + | .created_at = 0, | |
| 1379 | + | }; | |
| 1380 | + | defer daemon.deinit(alloc); | |
| 1381 | + | ||
| 1382 | + | const fds1 = try lib_posix.pipe2(.{}); | |
| 1383 | + | defer lib_posix.close(fds1[1]); | |
| 1384 | + | var client1 = Client{ | |
| 1385 | + | .alloc = alloc, | |
| 1386 | + | .socket_fd = fds1[0], | |
| 1387 | + | .read_buf = try ipc.SocketBuffer.init(alloc), | |
| 1388 | + | .write_buf = std.ArrayList(u8).empty, | |
| 1389 | + | }; | |
| 1390 | + | defer client1.deinit(alloc); | |
| 1391 | + | try client1.setEnv(alloc, "DISPLAY=:1\nSSH_AUTH_SOCK=/tmp/ssh-1\nKITTY_LISTEN_ON=unix:/tmp/kitty-1\n-WINDOWID\n"); | |
| 1392 | + | ||
| 1393 | + | const fds2 = try lib_posix.pipe2(.{}); | |
| 1394 | + | defer lib_posix.close(fds2[1]); | |
| 1395 | + | var client2 = Client{ | |
| 1396 | + | .alloc = alloc, | |
| 1397 | + | .socket_fd = fds2[0], | |
| 1398 | + | .read_buf = try ipc.SocketBuffer.init(alloc), | |
| 1399 | + | .write_buf = std.ArrayList(u8).empty, | |
| 1400 | + | }; | |
| 1401 | + | defer client2.deinit(alloc); | |
| 1402 | + | try client2.setEnv(alloc, "SSH_AUTH_SOCK=/tmp/ssh-2\nWINDOWID=12345\nKITTY_LISTEN_ON=unix:/tmp/kitty-2\n-DISPLAY\n"); | |
| 1403 | + | ||
| 1404 | + | const fds_req = try lib_posix.pipe2(.{}); | |
| 1405 | + | defer lib_posix.close(fds_req[1]); | |
| 1406 | + | var client_req = Client{ | |
| 1407 | + | .alloc = alloc, | |
| 1408 | + | .socket_fd = fds_req[0], | |
| 1409 | + | .read_buf = try ipc.SocketBuffer.init(alloc), | |
| 1410 | + | .write_buf = std.ArrayList(u8).empty, | |
| 1411 | + | }; | |
| 1412 | + | defer client_req.deinit(alloc); | |
| 1413 | + | ||
| 1414 | + | try daemon.clients.append(alloc, &client1); | |
| 1415 | + | try daemon.clients.append(alloc, &client2); | |
| 1416 | + | ||
| 1417 | + | // No leader yet -> handleEnvGet returns empty string payload | |
| 1418 | + | try daemon.handleEnvGet(alloc, &client_req); | |
| 1419 | + | try std.testing.expect(client_req.write_buf.items.len > 0); | |
| 1420 | + | client_req.write_buf.clearRetainingCapacity(); | |
| 1421 | + | ||
| 1422 | + | // Set client1 as leader | |
| 1423 | + | try daemon.setLeader(alloc, &client1); | |
| 1424 | + | try std.testing.expectEqual(@as(?i32, fds1[0]), daemon.leader_client_fd); | |
| 1425 | + | try daemon.handleEnvGet(alloc, &client_req); | |
| 1426 | + | // Wire message: [Header][Payload] | |
| 1427 | + | const pay1 = client_req.write_buf.items[@sizeOf(ipc.Header)..]; | |
| 1428 | + | try std.testing.expectEqualStrings("DISPLAY=:1\nSSH_AUTH_SOCK=/tmp/ssh-1\nKITTY_LISTEN_ON=unix:/tmp/kitty-1\n-WINDOWID\n", pay1); | |
| 1429 | + | client_req.write_buf.clearRetainingCapacity(); | |
| 1430 | + | ||
| 1431 | + | // Switch leader to client2 | |
| 1432 | + | try daemon.setLeader(alloc, &client2); | |
| 1433 | + | try std.testing.expectEqual(@as(?i32, fds2[0]), daemon.leader_client_fd); | |
| 1434 | + | try daemon.handleEnvGet(alloc, &client_req); | |
| 1435 | + | const pay2 = client_req.write_buf.items[@sizeOf(ipc.Header)..]; | |
| 1436 | + | try std.testing.expectEqualStrings("SSH_AUTH_SOCK=/tmp/ssh-2\nWINDOWID=12345\nKITTY_LISTEN_ON=unix:/tmp/kitty-2\n-DISPLAY\n", pay2); | |
| 1437 | + | client_req.write_buf.clearRetainingCapacity(); | |
| 1438 | + | ||
| 1439 | + | // Client2 updates env | |
| 1440 | + | try daemon.handleEnvSet(alloc, &client2, "DISPLAY=:99\n"); | |
| 1441 | + | try daemon.handleEnvGet(alloc, &client_req); | |
| 1442 | + | const pay3 = client_req.write_buf.items[@sizeOf(ipc.Header)..]; | |
| 1443 | + | try std.testing.expectEqualStrings("DISPLAY=:99\n", pay3); | |
| 1444 | + | } |
+232,
-20
| ... | ... | @@ -1,4 +1,5 @@ | |
| 1 | 1 | const std = @import("std"); | |
| 2 | + | const Environ = std.process.Environ; | |
| 2 | 3 | const build_options = @import("build_options"); | |
| 3 | 4 | const ghostty_vt = @import("ghostty-vt"); | |
| 4 | 5 | const ipc = @import("ipc.zig"); |
| ... | ... | @@ -160,7 +161,12 @@ pub fn main(init: std.process.Init) !void { | |
| 160 | 161 | daemon.setCwd(cwd); | |
| 161 | 162 | daemon.shell = shell_env; | |
| 162 | 163 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 163 | - | return attach(gpa, io, &daemon, parsed.labels); | |
| 164 | + | ||
| 165 | + | const env_keys = lib_posix.getenv("ZMX_TRACK_ENV") orelse cfg.tracked_envs; | |
| 166 | + | const env_str = try getTrackedEnvStr(gpa, env_keys, init.environ_map); | |
| 167 | + | defer gpa.free(env_str); | |
| 168 | + | ||
| 169 | + | return attach(gpa, io, &daemon, env_str, parsed.labels); | |
| 164 | 170 | } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) { | |
| 165 | 171 | const session_name = args.next() orelse ""; | |
| 166 | 172 | if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) { |
| ... | ... | @@ -409,6 +415,26 @@ pub fn main(init: std.process.Init) !void { | |
| 409 | 415 | daemon.shell = shell_env; | |
| 410 | 416 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 411 | 417 | try writeFile(gpa, io, &daemon, file_path); | |
| 418 | + | } else if (std.mem.eql(u8, cmd, "print-env")) { | |
| 419 | + | var shell_mode = false; | |
| 420 | + | var session_name: ?[]const u8 = null; | |
| 421 | + | var single_kv: []const u8 = ""; | |
| 422 | + | ||
| 423 | + | while (args.next()) |arg| { | |
| 424 | + | if (detectHelp(arg)) return help(io); | |
| 425 | + | if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--shell")) { | |
| 426 | + | shell_mode = true; | |
| 427 | + | } else if (session_name == null) { | |
| 428 | + | session_name = arg; | |
| 429 | + | } else if (single_kv.len == 0) { | |
| 430 | + | single_kv = arg; | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | const sesh_arg = session_name orelse return error.SessionNameRequired; | |
| 435 | + | const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_arg); | |
| 436 | + | defer gpa.free(sesh); | |
| 437 | + | return envGet(gpa, io, &cfg, sesh, single_kv, shell_mode); | |
| 412 | 438 | } else { | |
| 413 | 439 | return help(io); | |
| 414 | 440 | } |
| ... | ... | @@ -422,22 +448,23 @@ fn help(io: std.Io) !void { | |
| 422 | 448 | \\ | |
| 423 | 449 | \\Commands: | |
| 424 | 450 | \\ [a]ttach [--labels kv] <name> [command...] Attach to session, creating if needed | |
| 425 | - | \\ [r]un <name> [-d] [command...] Send command without attaching | |
| 426 | - | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 427 | - | \\ [p]rint <name> <text...> Inject text into session display | |
| 428 | - | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 429 | - | \\ [d]etach Detach all clients (ctrl+\\ for current client) | |
| 430 | - | \\ [l]ist|ls [--short] List active sessions | |
| 431 | - | \\ [g]et <name> Get session labels | |
| 432 | - | \\ set <name> k=v ... Set session labels (k= to remove) | |
| 433 | - | \\ [cl]ear <name> Clear all session labels | |
| 434 | - | \\ [k]ill <name>... [--force] Kill session and all attached clients | |
| 435 | - | \\ [hi]story <name> [--vt|--html] Output session scrollback | |
| 436 | - | \\ [w]ait <name>... Wait for session tasks to complete | |
| 437 | - | \\ [t]ail <name>... Follow session output | |
| 438 | - | \\ [c]ompletions <shell> Shell completions (bash, zsh, fish, nu) | |
| 439 | - | \\ [v]ersion Show version and metadata (socket dir, log dir) | |
| 440 | - | \\ [h]elp Show this help | |
| 451 | + | \\ [r]un <name> [-d] [command...] Send command without attaching | |
| 452 | + | \\ [s]end <name> <text...> Send raw input to session PTY | |
| 453 | + | \\ [p]rint <name> <text...> Inject text into session display | |
| 454 | + | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 455 | + | \\ [d]etach Detach all clients (ctrl+\\ for current client) | |
| 456 | + | \\ [l]ist|ls [--short] List active sessions | |
| 457 | + | \\ [g]et <name> Get session labels | |
| 458 | + | \\ set <name> k=v ... Set session labels (k= to remove) | |
| 459 | + | \\ [cl]ear <name> Clear all session labels | |
| 460 | + | \\ print-env [-s] <name> [key] Print tracked environment variables | |
| 461 | + | \\ [k]ill <name>... [--force] Kill session and all attached clients | |
| 462 | + | \\ [hi]story <name> [--vt|--html] Output session scrollback | |
| 463 | + | \\ [w]ait <name>... Wait for session tasks to complete | |
| 464 | + | \\ [t]ail <name>... Follow session output | |
| 465 | + | \\ [c]ompletions <shell> Shell completions (bash, zsh, fish, nu) | |
| 466 | + | \\ [v]ersion Show version and metadata (socket dir, log dir) | |
| 467 | + | \\ [h]elp Show this help | |
| 441 | 468 | \\ | |
| 442 | 469 | \\Attach: | |
| 443 | 470 | \\ This will spawn a login $SHELL with a PTY. You can provide a |
| ... | ... | @@ -545,6 +572,19 @@ fn help(io: std.Io) !void { | |
| 545 | 572 | \\ zmx list | grep project=zmx | |
| 546 | 573 | \\ zmx clear dev | |
| 547 | 574 | \\ | |
| 575 | + | \\Print-env: | |
| 576 | + | \\ Print tracked environment variables for the leader client of a session. | |
| 577 | + | \\ | |
| 578 | + | \\ Flags: | |
| 579 | + | \\ -s, --shell Output POSIX export/unset commands for eval | |
| 580 | + | \\ | |
| 581 | + | \\ Examples: | |
| 582 | + | \\ zmx print-env . | |
| 583 | + | \\ zmx print-env -s . | |
| 584 | + | \\ zmx print-env dev | |
| 585 | + | \\ zmx print-env dev DISPLAY | |
| 586 | + | \\ eval "$(zmx print-env -s .)" # inside shell session precmd hook | |
| 587 | + | \\ | |
| 548 | 588 | \\Environment variables: | |
| 549 | 589 | \\ SHELL Default shell for new sessions | |
| 550 | 590 | \\ ZMX_DIR Socket directory (priority 1) |
| ... | ... | @@ -553,6 +593,8 @@ fn help(io: std.Io) !void { | |
| 553 | 593 | \\ ZMX_SESSION Session name (injected automatically; makes attach | |
| 554 | 594 | \\ switch this session) | |
| 555 | 595 | \\ ZMX_SESSION_PREFIX Prefix added to all session names | |
| 596 | + | \\ ZMX_TRACK_ENV Comma-separated list of environment variables to track | |
| 597 | + | \\ from attaching clients | |
| 556 | 598 | \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750) | |
| 557 | 599 | \\ ZMX_LOG_MODE Sets mode for log files (octal, defaults to 0640) | |
| 558 | 600 | \\ ZMX_NO_DETACH_KEY Disables the ctrl+\ detach shortcut (set to any value) |
| ... | ... | @@ -1122,6 +1164,134 @@ fn assertLabels(io: std.Io, labels: []const u8) void { | |
| 1122 | 1164 | } | |
| 1123 | 1165 | } | |
| 1124 | 1166 | ||
| 1167 | + | fn getTrackedEnvStr( | |
| 1168 | + | alloc: std.mem.Allocator, | |
| 1169 | + | env_keys: []const u8, | |
| 1170 | + | env_map: *const std.process.Environ.Map, | |
| 1171 | + | ) ![]u8 { | |
| 1172 | + | var out = std.ArrayList(u8).empty; | |
| 1173 | + | errdefer out.deinit(alloc); | |
| 1174 | + | ||
| 1175 | + | var iter = std.mem.splitScalar(u8, env_keys, ','); | |
| 1176 | + | while (iter.next()) |entry| { | |
| 1177 | + | const env = std.mem.trim(u8, entry, " \t\r\n"); | |
| 1178 | + | if (env.len == 0) continue; | |
| 1179 | + | if (env_map.get(env)) |val| { | |
| 1180 | + | try out.appendSlice(alloc, env); | |
| 1181 | + | try out.append(alloc, '='); | |
| 1182 | + | try out.appendSlice(alloc, val); | |
| 1183 | + | try out.append(alloc, '\n'); | |
| 1184 | + | } else { | |
| 1185 | + | try out.append(alloc, '-'); | |
| 1186 | + | try out.appendSlice(alloc, env); | |
| 1187 | + | try out.append(alloc, '\n'); | |
| 1188 | + | } | |
| 1189 | + | } | |
| 1190 | + | ||
| 1191 | + | return out.toOwnedSlice(alloc); | |
| 1192 | + | } | |
| 1193 | + | ||
| 1194 | + | pub const EnvEntry = struct { | |
| 1195 | + | key: []const u8, | |
| 1196 | + | value: ?[]const u8, | |
| 1197 | + | }; | |
| 1198 | + | ||
| 1199 | + | pub const EnvIterator = struct { | |
| 1200 | + | it: std.mem.SplitIterator(u8, .scalar), | |
| 1201 | + | ||
| 1202 | + | pub fn init(payload: []const u8) EnvIterator { | |
| 1203 | + | return .{ .it = std.mem.splitScalar(u8, payload, '\n') }; | |
| 1204 | + | } | |
| 1205 | + | ||
| 1206 | + | pub fn next(self: *EnvIterator) ?EnvEntry { | |
| 1207 | + | while (self.it.next()) |line| { | |
| 1208 | + | if (line.len == 0) continue; | |
| 1209 | + | if (line[0] == '-') { | |
| 1210 | + | return .{ .key = line[1..], .value = null }; | |
| 1211 | + | } else if (std.mem.indexOfScalar(u8, line, '=')) |eq_idx| { | |
| 1212 | + | return .{ .key = line[0..eq_idx], .value = line[eq_idx + 1 ..] }; | |
| 1213 | + | } | |
| 1214 | + | } | |
| 1215 | + | return null; | |
| 1216 | + | } | |
| 1217 | + | }; | |
| 1218 | + | ||
| 1219 | + | fn getEnvValue(key: []const u8, payload: []const u8) error{EnvVarNotFound}![]const u8 { | |
| 1220 | + | var it = EnvIterator.init(payload); | |
| 1221 | + | while (it.next()) |entry| { | |
| 1222 | + | if (std.mem.eql(u8, entry.key, key)) { | |
| 1223 | + | return entry.value orelse return error.EnvVarNotFound; | |
| 1224 | + | } | |
| 1225 | + | } | |
| 1226 | + | return error.EnvVarNotFound; | |
| 1227 | + | } | |
| 1228 | + | ||
| 1229 | + | fn printEnvError(io: std.Io, session_name: []const u8, err: anyerror) noreturn { | |
| 1230 | + | var buf: [4096]u8 = undefined; | |
| 1231 | + | var w = std.Io.File.stderr().writer(io, &buf); | |
| 1232 | + | switch (err) { | |
| 1233 | + | error.SessionNotFound => w.interface.print("error: session \"{s}\" not found\n", .{session_name}) catch {}, | |
| 1234 | + | error.DaemonUnresponsive => w.interface.print("error: session \"{s}\" daemon unresponsive\n", .{session_name}) catch {}, | |
| 1235 | + | error.EnvVarNotFound => w.interface.print("error: environment variable not found\n", .{}) catch {}, | |
| 1236 | + | else => w.interface.print( | |
| 1237 | + | "error: could not communicate with session \"{s}\" err={s}\n", | |
| 1238 | + | .{ session_name, @errorName(err) }, | |
| 1239 | + | ) catch {}, | |
| 1240 | + | } | |
| 1241 | + | w.interface.flush() catch {}; | |
| 1242 | + | std.process.exit(1); | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8, shell_mode: bool) !void { | |
| 1246 | + | std.log.info("env get session={s}", .{session_name}); | |
| 1247 | + | ||
| 1248 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) { | |
| 1249 | + | error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir), | |
| 1250 | + | error.OutOfMemory => return err, | |
| 1251 | + | }; | |
| 1252 | + | defer alloc.free(socket_path); | |
| 1253 | + | ||
| 1254 | + | const payload = ipc.roundTripForTag(alloc, socket_path, .EnvGet, "", .EnvData) catch |err| { | |
| 1255 | + | printEnvError(io, session_name, err); | |
| 1256 | + | }; | |
| 1257 | + | defer alloc.free(payload); | |
| 1258 | + | ||
| 1259 | + | var buf: [4096]u8 = undefined; | |
| 1260 | + | var stdout = std.Io.File.stdout().writer(io, &buf); | |
| 1261 | + | if (single_kv.len > 0) { | |
| 1262 | + | const val = getEnvValue(single_kv, payload) catch |err| { | |
| 1263 | + | printEnvError(io, session_name, err); | |
| 1264 | + | }; | |
| 1265 | + | try stdout.interface.print("{s}\n", .{val}); | |
| 1266 | + | try stdout.interface.flush(); | |
| 1267 | + | return; | |
| 1268 | + | } | |
| 1269 | + | ||
| 1270 | + | if (payload.len == 0) return; | |
| 1271 | + | ||
| 1272 | + | if (shell_mode) { | |
| 1273 | + | var it = EnvIterator.init(payload); | |
| 1274 | + | while (it.next()) |entry| { | |
| 1275 | + | if (entry.value) |val| { | |
| 1276 | + | try stdout.interface.print("export {s}='", .{entry.key}); | |
| 1277 | + | for (val) |c| { | |
| 1278 | + | if (c == '\'') { | |
| 1279 | + | try stdout.interface.print("'\\''", .{}); | |
| 1280 | + | } else { | |
| 1281 | + | try stdout.interface.print("{c}", .{c}); | |
| 1282 | + | } | |
| 1283 | + | } | |
| 1284 | + | try stdout.interface.print("';\n", .{}); | |
| 1285 | + | } else { | |
| 1286 | + | try stdout.interface.print("unset {s};\n", .{entry.key}); | |
| 1287 | + | } | |
| 1288 | + | } | |
| 1289 | + | } else { | |
| 1290 | + | try stdout.interface.print("{s}", .{payload}); | |
| 1291 | + | } | |
| 1292 | + | try stdout.interface.flush(); | |
| 1293 | + | } | |
| 1294 | + | ||
| 1125 | 1295 | fn labelSet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, labels: []const u8) !void { | |
| 1126 | 1296 | std.log.info("label set session={s}", .{session_name}); | |
| 1127 | 1297 |
| ... | ... | @@ -1358,7 +1528,7 @@ fn parseAttachArgs(argv: []const []const u8) AttachArgs { | |
| 1358 | 1528 | return parsed; | |
| 1359 | 1529 | } | |
| 1360 | 1530 | ||
| 1361 | - | fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, labels: ?[]const u8) !void { | |
| 1531 | + | fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, env_str: []const u8, labels: ?[]const u8) !void { | |
| 1362 | 1532 | const sesh = socket.getSeshNameFromEnv(); | |
| 1363 | 1533 | if (sesh.len > 0) { | |
| 1364 | 1534 | return switchSesh(gpa, io, daemon, sesh); |
| ... | ... | @@ -1425,7 +1595,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, labels: ?[]const | |
| 1425 | 1595 | const clear_seq = "\x1b[2J\x1b[H"; | |
| 1426 | 1596 | _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq); | |
| 1427 | 1597 | ||
| 1428 | - | const looper = try loop.clientLoop(client_sock); | |
| 1598 | + | const looper = try loop.clientLoop(client_sock, env_str); | |
| 1429 | 1599 | switch (looper.kind) { | |
| 1430 | 1600 | .detach => return, | |
| 1431 | 1601 | .switch_session => { |
| ... | ... | @@ -1453,7 +1623,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, labels: ?[]const | |
| 1453 | 1623 | std.log.info("switching to new session cwd={s}", .{switch_cwd}); | |
| 1454 | 1624 | target_daemon.setCwd(switch_cwd); | |
| 1455 | 1625 | target_daemon.shell = daemon.shell; | |
| 1456 | - | return attach(gpa, io, &target_daemon, null); | |
| 1626 | + | return attach(gpa, io, &target_daemon, env_str, null); | |
| 1457 | 1627 | } | |
| 1458 | 1628 | }, | |
| 1459 | 1629 | } |
| ... | ... | @@ -1751,3 +1921,45 @@ test "parseAttachArgs leaves labels unset when the flag is absent" { | |
| 1751 | 1921 | try std.testing.expectEqual(@as(?[]const u8, null), parsed.labels); | |
| 1752 | 1922 | try std.testing.expect(!parsed.missing_labels_value); | |
| 1753 | 1923 | } | |
| 1924 | + | ||
| 1925 | + | test "getTrackedEnvStr includes set and unset entries" { | |
| 1926 | + | const alloc = std.testing.allocator; | |
| 1927 | + | var env_map = std.process.Environ.Map.init(alloc); | |
| 1928 | + | defer env_map.deinit(); | |
| 1929 | + | ||
| 1930 | + | try env_map.put("DISPLAY", ":1"); | |
| 1931 | + | try env_map.put("SSH_AUTH_SOCK", "/tmp/ssh.sock"); | |
| 1932 | + | ||
| 1933 | + | const str = try getTrackedEnvStr(alloc, "DISPLAY, KITTY_WINDOW_ID, SSH_AUTH_SOCK", &env_map); | |
| 1934 | + | defer alloc.free(str); | |
| 1935 | + | ||
| 1936 | + | try std.testing.expectEqualStrings("DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n", str); | |
| 1937 | + | } | |
| 1938 | + | ||
| 1939 | + | test "getEnvValue extracts set value or returns EnvVarNotFound for unset" { | |
| 1940 | + | const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n"; | |
| 1941 | + | ||
| 1942 | + | try std.testing.expectEqualStrings(":1", try getEnvValue("DISPLAY", payload)); | |
| 1943 | + | try std.testing.expectEqualStrings("/tmp/ssh.sock", try getEnvValue("SSH_AUTH_SOCK", payload)); | |
| 1944 | + | try std.testing.expectError(error.EnvVarNotFound, getEnvValue("KITTY_WINDOW_ID", payload)); | |
| 1945 | + | try std.testing.expectError(error.EnvVarNotFound, getEnvValue("NONEXISTENT", payload)); | |
| 1946 | + | } | |
| 1947 | + | ||
| 1948 | + | test "EnvIterator parses set and unset entries correctly" { | |
| 1949 | + | const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n"; | |
| 1950 | + | var it = EnvIterator.init(payload); | |
| 1951 | + | ||
| 1952 | + | const e1 = it.next().?; | |
| 1953 | + | try std.testing.expectEqualStrings("DISPLAY", e1.key); | |
| 1954 | + | try std.testing.expectEqualStrings(":1", e1.value.?); | |
| 1955 | + | ||
| 1956 | + | const e2 = it.next().?; | |
| 1957 | + | try std.testing.expectEqualStrings("KITTY_WINDOW_ID", e2.key); | |
| 1958 | + | try std.testing.expect(e2.value == null); | |
| 1959 | + | ||
| 1960 | + | const e3 = it.next().?; | |
| 1961 | + | try std.testing.expectEqualStrings("SSH_AUTH_SOCK", e3.key); | |
| 1962 | + | try std.testing.expectEqualStrings("/tmp/ssh.sock", e3.value.?); | |
| 1963 | + | ||
| 1964 | + | try std.testing.expect(it.next() == null); | |
| 1965 | + | } |