Commit 97a1423

JoeJoeflyn  ·  2026-08-20 11:54:35 -0400 EDT
parent e586f62
fix: audit all commands and ensure non-zero exit codes on error (#178)
4 files changed,  +324, -88
+132, -86
......@@ -23,7 +23,18 @@ pub const std_options: std.Options = .{
2323 .log_level = .debug,
2424 };
2525
26-/// This is the entry point for the CLI.
26+fn detectHelp(arg: []const u8) bool {
27+ return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
28+}
29+
30+/// Prints an error message to stderr and exits with status 1.
31+fn printError(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn {
32+ var buf: [4096]u8 = undefined;
33+ var w = std.Io.File.stderr().writer(io, &buf);
34+ w.interface.print("error: " ++ fmt ++ "\n", args) catch {};
35+ w.interface.flush() catch {};
36+ std.process.exit(1);
37+}
2738 pub fn main(init: std.process.Init) !void {
2839 const gpa = init.gpa;
2940 const io = init.io;
......@@ -65,14 +76,18 @@ pub fn main(init: std.process.Init) !void {
6576 }
6677 return list(gpa, io, &cfg, short);
6778 } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
68- const sesh_name = args.next() orelse return error.SessionNameRequired;
79+ const sesh_name = args.next() orelse {
80+ return printError(io, "session name required (or run inside a zmx session)", .{});
81+ };
6982 if (detectHelp(sesh_name)) return help(io);
7083 const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
7184 defer gpa.free(sesh);
7285 const single_kv = args.next() orelse "";
7386 return labelGet(gpa, io, &cfg, sesh, single_kv);
7487 } else if (std.mem.eql(u8, cmd, "set")) {
75- const sesh_name = args.next() orelse return error.SessionNameRequired;
88+ const sesh_name = args.next() orelse {
89+ return printError(io, "session name required (or run inside a zmx session)", .{});
90+ };
7691 if (detectHelp(sesh_name)) return help(io);
7792 const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
7893 defer gpa.free(sesh);
......@@ -85,19 +100,28 @@ pub fn main(init: std.process.Init) !void {
85100 try kvs.appendSlice(gpa, arg);
86101 first = false;
87102 }
103+ if (kvs.items.len == 0) {
104+ return printError(io, "at least one key=value pair required", .{});
105+ }
88106 return labelSet(gpa, io, &cfg, sesh, kvs.items);
89- } else if (std.mem.eql(u8, cmd, "clear")) {
90- const sesh_name = args.next() orelse return error.SessionNameRequired;
107+ } else if (std.mem.eql(u8, cmd, "clear") or std.mem.eql(u8, cmd, "cl")) {
108+ const sesh_name = args.next() orelse {
109+ return printError(io, "session name required (or run inside a zmx session)", .{});
110+ };
91111 if (detectHelp(sesh_name)) return help(io);
92112 const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
93113 defer gpa.free(sesh);
94114 return labelClear(gpa, io, &cfg, sesh);
95115 } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
96- const arg = args.next() orelse return;
116+ const arg = args.next() orelse {
117+ return printError(io, "completions requires a shell argument (bash, zsh, fish, nu)", .{});
118+ };
97119 if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
98120 return help(io);
99121 }
100- const shell = completions.Shell.fromString(arg) orelse return;
122+ const shell = completions.Shell.fromString(arg) orelse {
123+ return printError(io, "unknown shell \"{s}\" (valid: bash, zsh, fish, nu)", .{arg});
124+ };
101125 return printCompletions(io, shell);
102126 } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
103127 return detachAll(gpa, io, &cfg);
......@@ -116,7 +140,10 @@ pub fn main(init: std.process.Init) !void {
116140 }
117141 }
118142 const sesh_env = socket.getSeshNameFromEnv();
119- const sesh = try socket.getSeshName(gpa, session_name orelse sesh_env);
143+ const raw_name = session_name orelse (if (sesh_env.len > 0) sesh_env else {
144+ return printError(io, "session name required (or run inside a zmx session)", .{});
145+ });
146+ const sesh = try socket.getSeshName(gpa, raw_name);
120147 defer gpa.free(sesh);
121148 return history(gpa, io, &cfg, sesh, format);
122149 } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
......@@ -131,11 +158,7 @@ pub fn main(init: std.process.Init) !void {
131158 return help(io);
132159 }
133160 if (parsed.missing_labels_value) {
134- var buf: [4096]u8 = undefined;
135- var w = std.Io.File.stderr().writer(io, &buf);
136- w.interface.print("error: --labels requires \"key=value ...\"\n", .{}) catch {};
137- w.interface.flush() catch {};
138- std.process.exit(1);
161+ return printError(io, "--labels requires \"key=value ...\"", .{});
139162 }
140163 // Before ensureSession, so a rejected label does not leave a session
141164 // behind that the caller never asked for.
......@@ -206,7 +229,9 @@ pub fn main(init: std.process.Init) !void {
206229 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
207230 return help(io);
208231 }
209- if (session_name.len == 0) return error.SessionNameRequired;
232+ if (session_name.len == 0) {
233+ return printError(io, "session name required", .{});
234+ }
210235
211236 var text_parts: std.ArrayList([]const u8) = .empty;
212237 defer text_parts.deinit(gpa);
......@@ -220,13 +245,18 @@ pub fn main(init: std.process.Init) !void {
220245 error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
221246 error.OutOfMemory => return err,
222247 };
223- return send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Send);
248+ send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Send) catch |err| {
249+ if (err == error.SessionUnresponsive) std.process.exit(1);
250+ return printError(io, "send failed: {s}", .{@errorName(err)});
251+ };
224252 } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) {
225253 const session_name = args.next() orelse "";
226254 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
227255 return help(io);
228256 }
229- if (session_name.len == 0) return error.SessionNameRequired;
257+ if (session_name.len == 0) {
258+ return printError(io, "session name required", .{});
259+ }
230260
231261 var text_parts: std.ArrayList([]const u8) = .empty;
232262 defer text_parts.deinit(gpa);
......@@ -240,7 +270,10 @@ pub fn main(init: std.process.Init) !void {
240270 error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
241271 error.OutOfMemory => return err,
242272 };
243- return send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Output);
273+ send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Output) catch |err| {
274+ if (err == error.SessionUnresponsive) std.process.exit(1);
275+ return printError(io, "print failed: {s}", .{@errorName(err)});
276+ };
244277 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
245278 var stderr_buffer: [1024]u8 = undefined;
246279 var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
......@@ -266,7 +299,7 @@ pub fn main(init: std.process.Init) !void {
266299 try matchers.append(gpa, m);
267300 }
268301 if (matchers.items.len == 0) {
269- return error.SessionNameRequired;
302+ return printError(io, "session name required", .{});
270303 }
271304 var sessions = try util.get_session_entries(gpa, io, cfg.socket_dir);
272305 defer {
......@@ -276,22 +309,36 @@ pub fn main(init: std.process.Init) !void {
276309 sessions.deinit(gpa);
277310 }
278311
312+ var killed_any = false;
279313 for (sessions.items) |session| {
280314 for (matchers.items) |m| {
281- if (!m.matches(session.name)) {
282- continue;
283- }
284-
315+ if (!m.matches(session.name)) continue;
285316 kill(gpa, io, &cfg, session.name, force) catch |err| {
286- try stderr.print(
287- "failed to kill session={s}: {s}\n",
288- .{ session.name, @errorName(err) },
289- );
317+ if (err == error.SessionNotFound and force) {
318+ killed_any = true;
319+ continue;
320+ }
321+ try stderr.print("failed to kill session={s}: {s}\n", .{ session.name, @errorName(err) });
290322 try stderr.flush();
291323 };
324+ killed_any = true;
292325 break;
293326 }
294327 }
328+ if (!killed_any) {
329+ for (matchers.items) |m| {
330+ if (m.is_prefix) continue;
331+ kill(gpa, io, &cfg, m.name, force) catch |err| {
332+ if (err == error.SessionNotFound and force) {
333+ killed_any = true;
334+ continue;
335+ }
336+ return printError(io, "failed to kill session={s}: {s}", .{ m.name, @errorName(err) });
337+ };
338+ killed_any = true;
339+ }
340+ if (!killed_any) return printError(io, "no matching sessions found", .{});
341+ }
295342 } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
296343 var matchers: std.ArrayList(socket.SessionMatch) = .empty;
297344 defer {
......@@ -308,7 +355,7 @@ pub fn main(init: std.process.Init) !void {
308355 try matchers.append(gpa, m);
309356 }
310357 if (matchers.items.len == 0) {
311- return error.SessionNameRequired;
358+ return printError(io, "session name required", .{});
312359 }
313360 return wait(gpa, io, &cfg, matchers);
314361 } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
......@@ -327,7 +374,7 @@ pub fn main(init: std.process.Init) !void {
327374 try matchers.append(gpa, m);
328375 }
329376 if (matchers.items.len == 0) {
330- return error.SessionNameRequired;
377+ return printError(io, "session name required", .{});
331378 }
332379
333380 // Resolve matchers against session list to get actual session names.
......@@ -371,6 +418,10 @@ pub fn main(init: std.process.Init) !void {
371418 }
372419 }
373420
421+ if (resolved_names.items.len == 0) {
422+ return printError(io, "no matching sessions found", .{});
423+ }
424+
374425 var client_socket_fds = try std.ArrayList(i32).initCapacity(gpa, resolved_names.items.len);
375426 defer {
376427 for (client_socket_fds.items) |client_fd| {
......@@ -384,7 +435,9 @@ pub fn main(init: std.process.Init) !void {
384435 error.NameTooLong => return socket.printSessionNameTooLong(init.io, session_name, cfg.socket_dir),
385436 error.OutOfMemory => return err,
386437 };
387- const client_sock = try socket.sessionConnect(socket_path);
438+ const client_sock = socket.sessionConnect(socket_path) catch |err| {
439+ return printError(io, "cannot connect to session \"{s}\": {s}", .{ session_name, @errorName(err) });
440+ };
388441 try client_socket_fds.append(gpa, client_sock);
389442 }
390443 _ = try tail(gpa, client_socket_fds, false, false);
......@@ -393,12 +446,16 @@ pub fn main(init: std.process.Init) !void {
393446 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
394447 return help(io);
395448 }
396- if (session_name.len == 0) return error.SessionNameRequired;
449+ if (session_name.len == 0) {
450+ return printError(io, "session name required", .{});
451+ }
397452 const file_path = args.next() orelse "";
398453 if (std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h")) {
399454 return help(io);
400455 }
401- if (file_path.len == 0) return error.FilePathRequired;
456+ if (file_path.len == 0) {
457+ return printError(io, "file path required", .{});
458+ }
402459
403460 var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
404461 const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
......@@ -414,7 +471,10 @@ pub fn main(init: std.process.Init) !void {
414471 daemon.setCwd(cwd);
415472 daemon.shell = shell_env;
416473 std.log.info("socket path={s}", .{daemon.socket_path});
417- try writeFile(gpa, io, &daemon, file_path);
474+ writeFile(gpa, io, &daemon, file_path) catch |err| {
475+ if (err == error.SessionUnresponsive) std.process.exit(1);
476+ return printError(io, "write failed: {s}", .{@errorName(err)});
477+ };
418478 } else if (std.mem.eql(u8, cmd, "print-env")) {
419479 var shell_mode = false;
420480 var session_name: ?[]const u8 = null;
......@@ -431,12 +491,14 @@ pub fn main(init: std.process.Init) !void {
431491 }
432492 }
433493
434- const sesh_arg = session_name orelse return error.SessionNameRequired;
494+ const sesh_arg = session_name orelse {
495+ return printError(io, "session name required (or run inside a zmx session)", .{});
496+ };
435497 const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_arg);
436498 defer gpa.free(sesh);
437499 return envGet(gpa, io, &cfg, sesh, single_kv, shell_mode);
438500 } else {
439- return help(io);
501+ return printError(io, "unknown command \"{s}\"", .{cmd});
440502 }
441503 }
442504
......@@ -624,10 +686,6 @@ fn printCompletions(io: std.Io, shell: completions.Shell) !void {
624686 try w.interface.flush();
625687 }
626688
627-fn detectHelp(arg: []const u8) bool {
628- return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
629-}
630-
631689 fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
632690 var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
633691 defer poll_fds.deinit(alloc);
......@@ -1002,8 +1060,7 @@ fn list(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, short: bool) !void {
10021060 fn detachAll(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg) !void {
10031061 const session_name = socket.getSeshNameFromEnv();
10041062 if (session_name.len == 0) {
1005- std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{});
1006- return;
1063+ return printError(io, "not inside a zmx session (ZMX_SESSION not set)", .{});
10071064 }
10081065 std.log.info("detach all session={s}", .{session_name});
10091066
......@@ -1040,27 +1097,20 @@ fn kill(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u
10401097
10411098 const exists = try socket.sessionExists(io, dir, session_name);
10421099 if (!exists) {
1043- var buf: [4096]u8 = undefined;
1044- var w = std.Io.File.stderr().writer(io, &buf);
1045- w.interface.print("error: session \"{s}\" does not exist\n", .{session_name}) catch {};
1046- w.interface.flush() catch {};
10471100 return error.SessionNotFound;
10481101 }
10491102 const fd = ipc.connectSession(socket_path) catch |err| {
10501103 std.log.err("session unresponsive: {s}", .{@errorName(err)});
1051- var buf: [4096]u8 = undefined;
1052- var w = std.Io.File.stdout().writer(io, &buf);
10531104 if (force or err == error.ConnectionRefused) {
10541105 socket.cleanupStaleSocket(io, dir, session_name);
1055- w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
1106+ var ebuf: [4096]u8 = undefined;
1107+ var ew = std.Io.File.stderr().writer(io, &ebuf);
1108+ ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
1109+ ew.interface.flush() catch {};
1110+ return;
10561111 } else {
1057- w.interface.print(
1058- "session {s} is unresponsive ({s})\ndaemon may be busy: try again, add `--force` flag, or kill the process directly\n",
1059- .{ session_name, @errorName(err) },
1060- ) catch {};
1112+ return error.SessionUnresponsive;
10611113 }
1062- w.interface.flush() catch {};
1063- return;
10641114 };
10651115
10661116 defer lib_posix.close(fd);
......@@ -1129,7 +1179,9 @@ fn labelGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []con
11291179 return;
11301180 }
11311181
1132- const val = try label.getLabelValueFromPairs(single_kv, payload);
1182+ const val = label.getLabelValueFromPairs(single_kv, payload) catch |err| switch (err) {
1183+ error.LabelKeyNotFound => return printError(io, "label key \"{s}\" not found in session \"{s}\"", .{ single_kv, session_name }),
1184+ };
11331185 try stdout.interface.print("{s}", .{val});
11341186 try stdout.interface.flush();
11351187 }
......@@ -1332,10 +1384,7 @@ fn fetchHistory(
13321384 ) ![]const u8 {
13331385 std.log.info("fetch history session={s}", .{session_name});
13341386 const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1335- error.NameTooLong => {
1336- socket.printSessionNameTooLong(io, session_name, cfg.socket_dir);
1337- return error.NameTooLong;
1338- },
1387+ error.NameTooLong => return error.NameTooLong,
13391388 error.OutOfMemory => return err,
13401389 };
13411390 defer alloc.free(socket_path);
......@@ -1402,16 +1451,12 @@ fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []cons
14021451
14031452 const exists = try socket.sessionExists(io, dir, session_name);
14041453 if (!exists) {
1405- var buf: [4096]u8 = undefined;
1406- var w = std.Io.File.stderr().writer(io, &buf);
1407- w.interface.print("error: session \"{s}\" does not exist\n", .{session_name}) catch {};
1408- w.interface.flush() catch {};
1409- return error.SessionNotFound;
1454+ return printError(io, "session \"{s}\" does not exist", .{session_name});
14101455 }
14111456 const fd = ipc.connectSession(socket_path) catch |err| {
14121457 std.log.err("session unresponsive: {s}", .{@errorName(err)});
14131458 if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1414- return;
1459+ return printError(io, "session \"{s}\" is unresponsive ({s})", .{ session_name, @errorName(err) });
14151460 };
14161461 defer lib_posix.close(fd);
14171462
......@@ -1461,16 +1506,12 @@ fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh:
14611506
14621507 const exists = try socket.sessionExists(io, dir, current_sesh);
14631508 if (!exists) {
1464- var buf: [4096]u8 = undefined;
1465- var w = std.Io.File.stderr().writer(io, &buf);
1466- w.interface.print("error: session \"{s}\" does not exist\n", .{current_sesh}) catch {};
1467- w.interface.flush() catch {};
1468- return error.SessionNotFound;
1509+ return printError(io, "session \"{s}\" does not exist", .{current_sesh});
14691510 }
14701511 const fd = ipc.connectSession(socket_path) catch |err| {
14711512 std.log.err("session unresponsive: {s}", .{@errorName(err)});
14721513 if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, current_sesh);
1473- return;
1514+ return printError(io, "session \"{s}\" is unresponsive ({s})", .{ current_sesh, @errorName(err) });
14741515 };
14751516 defer lib_posix.close(fd);
14761517
......@@ -1545,7 +1586,9 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, env_str: []const
15451586 try labelSet(gpa, io, daemon.cfg, daemon.session_name, kvs);
15461587 }
15471588
1548- const client_sock = try socket.sessionConnect(daemon.socket_path);
1589+ const client_sock = socket.sessionConnect(daemon.socket_path) catch |err| {
1590+ return printError(io, "cannot connect to session \"{s}\": {s}", .{ daemon.session_name, @errorName(err) });
1591+ };
15491592 std.log.info("attached session={s}", .{daemon.session_name});
15501593 // This is typically used with tcsetattr() to modify terminal settings.
15511594 // - you first get the current settings with tcgetattr()
......@@ -1666,18 +1709,19 @@ fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []c
16661709 defer dir.close(io);
16671710
16681711 const result = ipc.probeSession(gpa, socket_path) catch |err| {
1669- std.log.err("session unresponsive: {s}", .{@errorName(err)});
1712+ var errbuf: [4096]u8 = undefined;
1713+ var ew = std.Io.File.stderr().writer(io, &errbuf);
16701714 if (err == error.ConnectionRefused) {
16711715 socket.cleanupStaleSocket(io, dir, daemon.session_name);
1672- w.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
1716+ ew.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
16731717 } else {
1674- w.interface.print(
1718+ ew.interface.print(
16751719 "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
16761720 .{ daemon.session_name, @errorName(err) },
16771721 ) catch {};
16781722 }
1679- w.interface.flush() catch {};
1680- return;
1723+ ew.interface.flush() catch {};
1724+ return error.SessionUnresponsive;
16811725 };
16821726
16831727 defer result.deinit();
......@@ -1717,8 +1761,6 @@ fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []c
17171761
17181762 fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
17191763 std.log.info("send session={s}", .{session_name});
1720- var buf: [4096]u8 = undefined;
1721- var w = std.Io.File.stdout().writer(io, &buf);
17221764
17231765 var payload = std.ArrayList(u8).empty;
17241766 defer payload.deinit(alloc);
......@@ -1750,24 +1792,28 @@ fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u
17501792 }
17511793 }
17521794
1753- if (payload.items.len == 0) return error.TextRequired;
1795+ if (payload.items.len == 0) {
1796+ return printError(io, "text argument required (or pipe input via stdin)", .{});
1797+ }
17541798
17551799 var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
17561800 defer dir.close(io);
17571801
17581802 const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
17591803 std.log.err("session unresponsive: {s}", .{@errorName(err)});
1804+ var errbuf: [4096]u8 = undefined;
1805+ var ew = std.Io.File.stderr().writer(io, &errbuf);
17601806 if (err == error.ConnectionRefused) {
17611807 socket.cleanupStaleSocket(io, dir, session_name);
1762- try w.interface.print("cleaned up stale session {s}\n", .{session_name});
1808+ ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
17631809 } else {
1764- try w.interface.print(
1810+ ew.interface.print(
17651811 "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
17661812 .{ session_name, @errorName(err) },
1767- );
1813+ ) catch {};
17681814 }
1769- try w.interface.flush();
1770- return;
1815+ ew.interface.flush() catch {};
1816+ return error.SessionUnresponsive;
17711817 };
17721818 defer probe_result.deinit();
17731819
......@@ -1840,12 +1886,12 @@ fn run(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, detached: bool, comm
18401886 }
18411887
18421888 if (cmd_to_send == null) {
1843- return error.CommandRequired;
1889+ return printError(io, "run requires a command (or pipe input via stdin)", .{});
18441890 }
18451891
18461892 const client_sock = ipc.connectSession(daemon.socket_path) catch |err| {
18471893 std.log.err("session not ready: {s}", .{@errorName(err)});
1848- return error.SessionNotReady;
1894+ return printError(io, "session not ready: {s}", .{@errorName(err)});
18491895 };
18501896 defer lib_posix.close(client_sock);
18511897
+2, -1
......@@ -137,7 +137,7 @@ pub fn getSocketPath(
137137 return fname;
138138 }
139139
140-pub fn printSessionNameTooLong(io: std.Io, session_name: []const u8, socket_dir: []const u8) void {
140+pub fn printSessionNameTooLong(io: std.Io, session_name: []const u8, socket_dir: []const u8) noreturn {
141141 var buf: [4096]u8 = undefined;
142142 var w = std.Io.File.stderr().writer(io, &buf);
143143 if (maxSessionNameLen(socket_dir)) |max_len| {
......@@ -152,6 +152,7 @@ pub fn printSessionNameTooLong(io: std.Io, session_name: []const u8, socket_dir:
152152 ) catch {};
153153 }
154154 w.interface.flush() catch {};
155+ std.process.exit(1);
155156 }
156157
157158 /// Returns the maximum session name length for a given socket directory,
+155, -0
......@@ -0,0 +1,155 @@
1+#!/usr/bin/env bats
2+# CLI argument validation tests for zmx.
3+# See https://github.com/neurosnap/zmx/issues/178
4+
5+load test_helper
6+
7+# ============================================================================
8+# Invalid commands must exit non-zero
9+# ============================================================================
10+
11+@test "unknown command exits non-zero" {
12+ run "$ZMX" foo
13+ [ "$status" -ne 0 ]
14+ [[ "$output" == *"unknown command"* ]]
15+}
16+
17+@test "unknown command alias exits non-zero" {
18+ run "$ZMX" not-a-real-command
19+ [ "$status" -ne 0 ]
20+ [[ "$output" == *"unknown command"* ]]
21+}
22+
23+@test "completions with invalid shell exits non-zero" {
24+ run "$ZMX" completions foo
25+ [ "$status" -ne 0 ]
26+ [[ "$output" == *"unknown shell"* ]]
27+}
28+
29+@test "completions alias with invalid shell exits non-zero" {
30+ run "$ZMX" c foo
31+ [ "$status" -ne 0 ]
32+ [[ "$output" == *"unknown shell"* ]]
33+}
34+
35+@test "completions with no argument exits non-zero" {
36+ run "$ZMX" completions
37+ [ "$status" -ne 0 ]
38+ [[ "$output" == *"shell argument"* ]]
39+}
40+
41+@test "get with no argument outside session exits non-zero" {
42+ run env -u ZMX_SESSION "$ZMX" get
43+ [ "$status" -ne 0 ]
44+ [[ "$output" == *"session name required"* ]]
45+}
46+
47+@test "set with no argument outside session exits non-zero" {
48+ run env -u ZMX_SESSION "$ZMX" set
49+ [ "$status" -ne 0 ]
50+ [[ "$output" == *"session name required"* ]]
51+}
52+
53+@test "clear with no argument outside session exits non-zero" {
54+ run env -u ZMX_SESSION "$ZMX" clear
55+ [ "$status" -ne 0 ]
56+ [[ "$output" == *"session name required"* ]]
57+}
58+
59+@test "detach outside session exits non-zero" {
60+ run env -u ZMX_SESSION "$ZMX" detach
61+ [ "$status" -ne 0 ]
62+ [[ "$output" == *"not inside a zmx session"* ]]
63+}
64+
65+@test "history with no argument outside session exits non-zero" {
66+ run env -u ZMX_SESSION "$ZMX" history
67+ [ "$status" -ne 0 ]
68+ [[ "$output" == *"session name required"* ]]
69+}
70+
71+@test "kill with no argument exits non-zero" {
72+ run "$ZMX" kill
73+ [ "$status" -ne 0 ]
74+ [[ "$output" == *"session name required"* ]]
75+}
76+
77+@test "wait with no argument exits non-zero" {
78+ run "$ZMX" wait
79+ [ "$status" -ne 0 ]
80+ [[ "$output" == *"session name required"* ]]
81+}
82+
83+@test "tail with no argument exits non-zero" {
84+ run "$ZMX" tail
85+ [ "$status" -ne 0 ]
86+ [[ "$output" == *"session name required"* ]]
87+}
88+
89+@test "send with no argument exits non-zero" {
90+ run "$ZMX" send
91+ [ "$status" -ne 0 ]
92+ [[ "$output" == *"session name required"* ]]
93+}
94+
95+@test "print with no argument exits non-zero" {
96+ run "$ZMX" print
97+ [ "$status" -ne 0 ]
98+ [[ "$output" == *"session name required"* ]]
99+}
100+
101+@test "attach with missing labels value exits non-zero" {
102+ run "$ZMX" attach --labels
103+ [ "$status" -ne 0 ]
104+ [[ "$output" == *"--labels requires"* ]]
105+}
106+
107+@test "print-env with no argument outside session exits non-zero" {
108+ run env -u ZMX_SESSION "$ZMX" print-env
109+ [ "$status" -ne 0 ]
110+ [[ "$output" == *"session name required"* ]]
111+}
112+
113+@test "write with no argument exits non-zero" {
114+ run "$ZMX" write
115+ [ "$status" -ne 0 ]
116+ [[ "$output" == *"session name required"* ]]
117+}
118+
119+@test "write with missing file path exits non-zero" {
120+ run "$ZMX" write dummy_session
121+ [ "$status" -ne 0 ]
122+ [[ "$output" == *"file path required"* ]]
123+}
124+
125+# ============================================================================
126+# Valid commands still exit zero
127+# ============================================================================
128+
129+@test "help exits zero" {
130+ run "$ZMX" help
131+ [ "$status" -eq 0 ]
132+}
133+
134+@test "version exits zero" {
135+ run "$ZMX" version
136+ [ "$status" -eq 0 ]
137+}
138+
139+@test "completions with valid shell exits zero" {
140+ run "$ZMX" completions bash
141+ [ "$status" -eq 0 ]
142+ [[ "$output" == *"_zmx_completions"* ]]
143+}
144+
145+@test "completions --help exits zero" {
146+ run "$ZMX" completions --help
147+ [ "$status" -eq 0 ]
148+}
149+
150+@test "print-env --help exits zero" {
151+ run "$ZMX" print-env --help
152+ [ "$status" -eq 0 ]
153+}
154+
155+
+35, -1
......@@ -87,6 +87,38 @@ load test_helper
8787 [ -z "$output" ]
8888 }
8989
90+@test "clear: cl alias works" {
91+ "$ZMX" run test-clear-alias -d sleep 30
92+ wait_for_session test-clear-alias
93+
94+ run "$ZMX" set test-clear-alias x=1 y=2
95+ run "$ZMX" cl test-clear-alias
96+ [ "$status" -eq 0 ]
97+
98+ run "$ZMX" get test-clear-alias
99+ [ "$status" -eq 0 ]
100+ [ -z "$output" ]
101+}
102+
103+@test "set: missing key-value pairs exits non-zero" {
104+ "$ZMX" run test-nokv -d sleep 30
105+ wait_for_session test-nokv
106+
107+ run "$ZMX" set test-nokv
108+ [ "$status" -ne 0 ]
109+ [[ "$output" == *"key=value"* ]]
110+}
111+
112+@test "get: missing key in existing session exits non-zero" {
113+ "$ZMX" run test-getkey -d sleep 30
114+ wait_for_session test-getkey
115+
116+ run "$ZMX" set test-getkey a=1
117+ run "$ZMX" get test-getkey b
118+ [ "$status" -ne 0 ]
119+ [[ "$output" == *"not found"* ]]
120+}
121+
90122 @test "get: no session prints error" {
91123 run "$ZMX" get nonexistent
92124 [ "$status" -ne 0 ]
......@@ -95,5 +127,7 @@ load test_helper
95127
96128 @test "get: no args prints error" {
97129 run env -u ZMX_SESSION "$ZMX" get
98- [[ "$output" == *"SessionNameRequired"* ]]
130+ [ "$status" -ne 0 ]
131+ [[ "$output" == *"session name required"* ]]
99132 }
133+