omq-cli 0.1.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.
data/lib/omq/cli.rb ADDED
@@ -0,0 +1,544 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "cli/version"
5
+ require_relative "cli/config"
6
+ require_relative "cli/formatter"
7
+ require_relative "cli/base_runner"
8
+ require_relative "cli/push_pull"
9
+ require_relative "cli/pub_sub"
10
+ require_relative "cli/scatter_gather"
11
+ require_relative "cli/radio_dish"
12
+ require_relative "cli/req_rep"
13
+ require_relative "cli/pair"
14
+ require_relative "cli/router_dealer"
15
+ require_relative "cli/channel"
16
+ require_relative "cli/client_server"
17
+ require_relative "cli/peer"
18
+ require_relative "cli/pipe"
19
+
20
+ module OMQ
21
+
22
+ class << self
23
+ attr_reader :outgoing_proc, :incoming_proc
24
+
25
+ def outgoing(&block) = @outgoing_proc = block
26
+ def incoming(&block) = @incoming_proc = block
27
+ end
28
+
29
+
30
+ module CLI
31
+ SOCKET_TYPE_NAMES = %w[
32
+ req rep pub sub push pull pair dealer router
33
+ client server radio dish scatter gather channel peer
34
+ pipe
35
+ ].freeze
36
+
37
+
38
+ RUNNER_MAP = {
39
+ "push" => [PushRunner, :PUSH],
40
+ "pull" => [PullRunner, :PULL],
41
+ "pub" => [PubRunner, :PUB],
42
+ "sub" => [SubRunner, :SUB],
43
+ "req" => [ReqRunner, :REQ],
44
+ "rep" => [RepRunner, :REP],
45
+ "dealer" => [DealerRunner, :DEALER],
46
+ "router" => [RouterRunner, :ROUTER],
47
+ "pair" => [PairRunner, :PAIR],
48
+ "client" => [ClientRunner, :CLIENT],
49
+ "server" => [ServerRunner, :SERVER],
50
+ "radio" => [RadioRunner, :RADIO],
51
+ "dish" => [DishRunner, :DISH],
52
+ "scatter" => [ScatterRunner, :SCATTER],
53
+ "gather" => [GatherRunner, :GATHER],
54
+ "channel" => [ChannelRunner, :CHANNEL],
55
+ "peer" => [PeerRunner, :PEER],
56
+ "pipe" => [PipeRunner, nil],
57
+ }.freeze
58
+
59
+
60
+ EXAMPLES = <<~'TEXT'
61
+ ── Request / Reply ──────────────────────────────────────────
62
+
63
+ ┌─────┐ "hello" ┌─────┐
64
+ │ REQ │────────────→│ REP │
65
+ │ │←────────────│ │
66
+ └─────┘ "HELLO" └─────┘
67
+
68
+ # terminal 1: echo server
69
+ omq rep --bind tcp://:5555 --recv-eval '$F.map(&:upcase)'
70
+
71
+ # terminal 2: send a request
72
+ echo "hello" | omq req --connect tcp://localhost:5555
73
+
74
+ # or over IPC (unix socket, single machine)
75
+ omq rep --bind ipc:///tmp/echo.sock --echo &
76
+ echo "hello" | omq req --connect ipc:///tmp/echo.sock
77
+
78
+ ── Publish / Subscribe ──────────────────────────────────────
79
+
80
+ ┌─────┐ "weather.nyc 72F" ┌─────┐
81
+ │ PUB │────────────────────→│ SUB │ --subscribe "weather."
82
+ └─────┘ └─────┘
83
+
84
+ # terminal 1: subscriber (all topics by default)
85
+ omq sub --bind tcp://:5556
86
+
87
+ # terminal 2: publisher (needs --delay for subscription to propagate)
88
+ echo "weather.nyc 72F" | omq pub --connect tcp://localhost:5556 --delay 1
89
+
90
+ ── Periodic Publish ───────────────────────────────────────────
91
+
92
+ ┌─────┐ "tick 1" ┌─────┐
93
+ │ PUB │──(every 1s)─→│ SUB │
94
+ └─────┘ └─────┘
95
+
96
+ # terminal 1: subscriber
97
+ omq sub --bind tcp://:5556
98
+
99
+ # terminal 2: publish a tick every second (wall-clock aligned)
100
+ omq pub --connect tcp://localhost:5556 --delay 1 \
101
+ --data "tick" --interval 1
102
+
103
+ # 5 ticks, then exit
104
+ omq pub --connect tcp://localhost:5556 --delay 1 \
105
+ --data "tick" --interval 1 --count 5
106
+
107
+ ── Pipeline ─────────────────────────────────────────────────
108
+
109
+ ┌──────┐ ┌──────┐
110
+ │ PUSH │──────────→│ PULL │
111
+ └──────┘ └──────┘
112
+
113
+ # terminal 1: worker
114
+ omq pull --bind tcp://:5557
115
+
116
+ # terminal 2: send tasks
117
+ echo "task 1" | omq push --connect tcp://localhost:5557
118
+
119
+ # or over IPC (unix socket)
120
+ omq pull --bind ipc:///tmp/pipeline.sock &
121
+ echo "task 1" | omq push --connect ipc:///tmp/pipeline.sock
122
+
123
+ ── Pipe (PULL → eval → PUSH) ────────────────────────────────
124
+
125
+ ┌──────┐ ┌──────┐ ┌──────┐
126
+ │ PUSH │────────→│ pipe │────────→│ PULL │
127
+ └──────┘ └──────┘ └──────┘
128
+
129
+ # terminal 1: producer
130
+ echo -e "hello\nworld" | omq push --bind ipc://@work
131
+
132
+ # terminal 2: worker — uppercase each message
133
+ omq pipe -c ipc://@work -c ipc://@sink -e '$F.map(&:upcase)'
134
+ # terminal 3: collector
135
+ omq pull --bind ipc://@sink
136
+
137
+ # 4 Ractor workers in a single process (-P)
138
+ omq pipe -c ipc://@work -c ipc://@sink -P4 -r./fib -e 'fib(Integer($_)).to_s'
139
+
140
+ # exit when producer disconnects (--transient)
141
+ omq pipe -c ipc://@work -c ipc://@sink --transient -e '$F.map(&:upcase)'
142
+
143
+ # fan-in: multiple sources → one sink
144
+ omq pipe --in -c ipc://@work1 -c ipc://@work2 \
145
+ --out -c ipc://@sink -e '$F.map(&:upcase)'
146
+
147
+ # fan-out: one source → multiple sinks (round-robin)
148
+ omq pipe --in -b tcp://:5555 --out -c ipc://@sink1 -c ipc://@sink2 -e '$F'
149
+
150
+ ── CLIENT / SERVER (draft) ──────────────────────────────────
151
+
152
+ ┌────────┐ "hello" ┌────────┐
153
+ │ CLIENT │───────────→│ SERVER │ --recv-eval '$F.map(&:upcase)'
154
+ │ │←───────────│ │
155
+ └────────┘ "HELLO" └────────┘
156
+
157
+ # terminal 1: upcasing server
158
+ omq server --bind tcp://:5555 --recv-eval '$F.map(&:upcase)'
159
+
160
+ # terminal 2: client
161
+ echo "hello" | omq client --connect tcp://localhost:5555
162
+
163
+ ── Formats ──────────────────────────────────────────────────
164
+
165
+ # ascii (default) — non-printable replaced with dots
166
+ omq pull --bind tcp://:5557 --ascii
167
+
168
+ # quoted — lossless, round-trippable (uses String#dump escaping)
169
+ omq pull --bind tcp://:5557 --quoted
170
+
171
+ # JSON Lines — structured, multipart as arrays
172
+ echo '["key","value"]' | omq push --connect tcp://localhost:5557 --jsonl
173
+ omq pull --bind tcp://:5557 --jsonl
174
+
175
+ # multipart via tabs
176
+ printf "routing-key\tpayload" | omq push --connect tcp://localhost:5557
177
+
178
+ ── Compression ──────────────────────────────────────────────
179
+
180
+ # both sides must use --compress
181
+ omq pull --bind tcp://:5557 --compress &
182
+ echo "compressible data" | omq push --connect tcp://localhost:5557 --compress
183
+
184
+ ── CURVE Encryption ─────────────────────────────────────────
185
+
186
+ # server (prints OMQ_SERVER_KEY=...)
187
+ omq rep --bind tcp://:5555 --echo --curve-server
188
+
189
+ # client (paste the server's key)
190
+ echo "secret" | omq req --connect tcp://localhost:5555 \
191
+ --curve-server-key '<key from server>'
192
+
193
+ ── ROUTER / DEALER ──────────────────────────────────────────
194
+
195
+ ┌────────┐ ┌────────┐
196
+ │ DEALER │─────────→│ ROUTER │
197
+ │ id=w1 │ │ │
198
+ └────────┘ └────────┘
199
+
200
+ # terminal 1: router shows identity + message
201
+ omq router --bind tcp://:5555
202
+
203
+ # terminal 2: dealer with identity
204
+ echo "hello" | omq dealer --connect tcp://localhost:5555 --identity worker-1
205
+
206
+ ── Ruby Eval ────────────────────────────────────────────────
207
+
208
+ # filter incoming: only pass messages containing "error"
209
+ omq pull -b tcp://:5557 --recv-eval '$F.first.include?("error") ? $F : nil'
210
+
211
+ # transform incoming with gems
212
+ omq sub -c tcp://localhost:5556 -rjson -e 'JSON.parse($F.first)["temperature"]'
213
+
214
+ # require a local file, use its methods
215
+ omq rep --bind tcp://:5555 --require ./transform.rb -e 'upcase_all($F)'
216
+
217
+ # next skips, break stops — regexps match against $_
218
+ omq pull -b tcp://:5557 -e 'next if /^#/; break if /quit/; $F'
219
+
220
+ # BEGIN/END blocks (like awk) — accumulate and summarize
221
+ omq pull -b tcp://:5557 -e 'BEGIN{@sum = 0} @sum += Integer($_); nil END{puts @sum}'
222
+
223
+ # transform outgoing messages
224
+ echo hello | omq push -c tcp://localhost:5557 --send-eval '$F.map(&:upcase)'
225
+
226
+ # REQ: transform request and reply independently
227
+ echo hello | omq req -c tcp://localhost:5555 -E '$F.map(&:upcase)' -e '$_'
228
+
229
+ ── Script Handlers (-r) ────────────────────────────────────
230
+
231
+ # handler.rb — register transforms from a file
232
+ # db = PG.connect("dbname=app")
233
+ # OMQ.incoming { |first_part, _| db.exec(first_part).values.flatten }
234
+ # at_exit { db.close }
235
+ omq pull --bind tcp://:5557 -r./handler.rb
236
+
237
+ # combine script handlers with inline eval
238
+ omq req -c tcp://localhost:5555 -r./handler.rb -E '$F.map(&:upcase)'
239
+
240
+ # OMQ.outgoing { |msg| ... } — registered outgoing transform
241
+ # OMQ.incoming { |msg| ... } — registered incoming transform
242
+ # CLI flags (-e/-E) override registered handlers
243
+ TEXT
244
+
245
+ module_function
246
+
247
+
248
+ # Displays text through the system pager, or prints directly
249
+ # when stdout is not a terminal.
250
+ #
251
+ def page(text)
252
+ if $stdout.tty?
253
+ if ENV["PAGER"]
254
+ pager = ENV["PAGER"]
255
+ else
256
+ ENV["LESS"] ||= "-FR"
257
+ pager = "less"
258
+ end
259
+ IO.popen(pager, "w") { |io| io.puts text }
260
+ else
261
+ puts text
262
+ end
263
+ rescue Errno::ENOENT
264
+ puts text
265
+ rescue Errno::EPIPE
266
+ # user quit pager early
267
+ end
268
+
269
+
270
+ # Parses CLI arguments, validates options, and runs the main
271
+ # event loop inside an Async reactor.
272
+ #
273
+ def run(argv = ARGV)
274
+ config = build_config(argv)
275
+
276
+ require "omq"
277
+ require "async"
278
+ require "json"
279
+ require "console"
280
+
281
+ validate_gems!(config)
282
+
283
+ trap("INT") { Process.exit!(0) }
284
+ trap("TERM") { Process.exit!(0) }
285
+
286
+ Console.logger = Console::Logger.new(Console::Output::Null.new) unless config.verbose
287
+
288
+ runner_class, socket_sym = RUNNER_MAP.fetch(config.type_name)
289
+
290
+ Async do |task|
291
+ runner = if socket_sym
292
+ runner_class.new(config, OMQ.const_get(socket_sym))
293
+ else
294
+ runner_class.new(config)
295
+ end
296
+ runner.call(task)
297
+ rescue IO::TimeoutError, Async::TimeoutError
298
+ $stderr.puts "omq: timeout" unless config.quiet
299
+ exit 2
300
+ rescue OMQ::SocketDeadError => e
301
+ $stderr.puts "omq: #{e.cause.class}: #{e.cause.message}"
302
+ exit 1
303
+ rescue ::Socket::ResolutionError => e
304
+ $stderr.puts "omq: #{e.message}"
305
+ exit 1
306
+ end
307
+ end
308
+
309
+
310
+ # Builds a frozen Config from command-line arguments.
311
+ #
312
+ def build_config(argv)
313
+ opts = parse_options(argv)
314
+ validate!(opts)
315
+
316
+ opts[:has_msgpack] = begin; require "msgpack"; true; rescue LoadError; false; end
317
+ opts[:has_zstd] = begin; require "zstd-ruby"; true; rescue LoadError; false; end
318
+ opts[:stdin_is_tty] = $stdin.tty?
319
+
320
+ Ractor.make_shareable(Config.new(**opts))
321
+ end
322
+
323
+
324
+ # Parses command-line arguments into a mutable options hash.
325
+ #
326
+ def parse_options(argv)
327
+ opts = {
328
+ endpoints: [],
329
+ connects: [],
330
+ binds: [],
331
+ in_endpoints: [],
332
+ out_endpoints: [],
333
+ data: nil,
334
+ file: nil,
335
+ format: :ascii,
336
+ subscribes: [],
337
+ joins: [],
338
+ group: nil,
339
+ identity: nil,
340
+ target: nil,
341
+ interval: nil,
342
+ count: nil,
343
+ delay: nil,
344
+ timeout: nil,
345
+ linger: 5,
346
+ reconnect_ivl: nil,
347
+ heartbeat_ivl: nil,
348
+ conflate: false,
349
+ compress: false,
350
+ send_expr: nil,
351
+ recv_expr: nil,
352
+ parallel: nil,
353
+ transient: false,
354
+ verbose: false,
355
+ quiet: false,
356
+ echo: false,
357
+ curve_server: false,
358
+ curve_server_key: nil,
359
+ curve_crypto: nil,
360
+ }
361
+
362
+ pipe_side = nil # nil = legacy positional mode; :in/:out = modal
363
+
364
+ parser = OptionParser.new do |o|
365
+ o.banner = "Usage: omq TYPE [options]\n\n" \
366
+ "Types: req, rep, pub, sub, push, pull, pair, dealer, router\n" \
367
+ "Draft: client, server, radio, dish, scatter, gather, channel, peer\n" \
368
+ "Virtual: pipe (PULL → eval → PUSH)\n\n"
369
+
370
+ o.separator "Connection:"
371
+ o.on("-c", "--connect URL", "Connect to endpoint (repeatable)") { |v|
372
+ ep = Endpoint.new(v, false)
373
+ case pipe_side
374
+ when :in then opts[:in_endpoints] << ep
375
+ when :out then opts[:out_endpoints] << ep
376
+ else opts[:endpoints] << ep; opts[:connects] << v
377
+ end
378
+ }
379
+ o.on("-b", "--bind URL", "Bind to endpoint (repeatable)") { |v|
380
+ ep = Endpoint.new(v, true)
381
+ case pipe_side
382
+ when :in then opts[:in_endpoints] << ep
383
+ when :out then opts[:out_endpoints] << ep
384
+ else opts[:endpoints] << ep; opts[:binds] << v
385
+ end
386
+ }
387
+ o.on("--in", "Pipe: subsequent -b/-c attach to input (PULL) side") { pipe_side = :in }
388
+ o.on("--out", "Pipe: subsequent -b/-c attach to output (PUSH) side") { pipe_side = :out }
389
+
390
+ o.separator "\nData source (REP: reply source):"
391
+ o.on( "--echo", "Echo received messages back (REP)") { opts[:echo] = true }
392
+ o.on("-D", "--data DATA", "Message data (literal string)") { |v| opts[:data] = v }
393
+ o.on("-F", "--file FILE", "Read message from file (- = stdin)") { |v| opts[:file] = v }
394
+
395
+ o.separator "\nFormat (input + output):"
396
+ o.on("-A", "--ascii", "Tab-separated frames, safe ASCII (default)") { opts[:format] = :ascii }
397
+ o.on("-Q", "--quoted", "C-style quoted with escapes") { opts[:format] = :quoted }
398
+ o.on( "--raw", "Raw binary, no framing") { opts[:format] = :raw }
399
+ o.on("-J", "--jsonl", "JSON Lines (array of strings per line)") { opts[:format] = :jsonl }
400
+ o.on( "--msgpack", "MessagePack arrays (binary stream)") { opts[:format] = :msgpack }
401
+ o.on("-M", "--marshal", "Ruby Marshal stream (binary, Array<String>)") { opts[:format] = :marshal }
402
+
403
+ o.separator "\nSubscription/groups:"
404
+ o.on("-s", "--subscribe PREFIX", "Subscribe prefix (SUB, default all)") { |v| opts[:subscribes] << v }
405
+ o.on("-j", "--join GROUP", "Join group (repeatable, DISH only)") { |v| opts[:joins] << v }
406
+ o.on("-g", "--group GROUP", "Publish group (RADIO only)") { |v| opts[:group] = v }
407
+
408
+ o.separator "\nIdentity/routing:"
409
+ o.on("--identity ID", "Set socket identity (DEALER/ROUTER)") { |v| opts[:identity] = v }
410
+ o.on("--target ID", "Target peer (ROUTER/SERVER/PEER, 0x prefix for binary)") { |v| opts[:target] = v }
411
+
412
+ o.separator "\nTiming:"
413
+ o.on("-i", "--interval SECS", Float, "Repeat interval") { |v| opts[:interval] = v }
414
+ o.on("-n", "--count COUNT", Integer, "Max iterations (0=inf)") { |v| opts[:count] = v }
415
+ o.on("-d", "--delay SECS", Float, "Delay before first send") { |v| opts[:delay] = v }
416
+ o.on("-t", "--timeout SECS", Float, "Send/receive timeout") { |v| opts[:timeout] = v }
417
+ o.on("-l", "--linger SECS", Float, "Drain time on close (default 5)") { |v| opts[:linger] = v }
418
+ o.on("--reconnect-ivl IVL", "Reconnect interval: SECS or MIN..MAX (default 0.1)") { |v|
419
+ opts[:reconnect_ivl] = if v.include?("..")
420
+ lo, hi = v.split("..", 2)
421
+ Float(lo)..Float(hi)
422
+ else
423
+ Float(v)
424
+ end
425
+ }
426
+ o.on("--heartbeat-ivl SECS", Float, "ZMTP heartbeat interval (detects dead peers)") { |v| opts[:heartbeat_ivl] = v }
427
+
428
+ o.separator "\nDelivery:"
429
+ o.on("--conflate", "Keep only last message per subscriber (PUB/RADIO)") { opts[:conflate] = true }
430
+
431
+ o.separator "\nCompression:"
432
+ o.on("-z", "--compress", "Zstandard compression per frame") { opts[:compress] = true }
433
+
434
+ o.separator "\nProcessing (-e = incoming, -E = outgoing):"
435
+ o.on("-e", "--recv-eval EXPR", "Eval Ruby for each incoming message ($F = parts)") { |v| opts[:recv_expr] = v }
436
+ o.on("-E", "--send-eval EXPR", "Eval Ruby for each outgoing message ($F = parts)") { |v| opts[:send_expr] = v }
437
+ o.on("-r", "--require LIB", "Require lib/file; scripts can register OMQ.outgoing/incoming") { |v|
438
+ require "omq" unless defined?(OMQ::VERSION)
439
+ v.start_with?("./", "../") ? require(File.expand_path(v)) : require(v)
440
+ }
441
+ o.on("-P", "--parallel [N]", Integer, "Parallel Ractor workers (pipe only, default: nproc)") { |v|
442
+ require "etc"
443
+ opts[:parallel] = v || Etc.nprocessors
444
+ }
445
+
446
+ o.separator "\nCURVE encryption (requires rbnacl or nuckle gem):"
447
+ o.on("--curve-server", "Enable CURVE as server (generates keypair)") { opts[:curve_server] = true }
448
+ o.on("--curve-server-key KEY", "Enable CURVE as client (server's Z85 public key)") { |v| opts[:curve_server_key] = v }
449
+ o.on("--curve-crypto BACKEND", "Crypto backend: rbnacl (default if installed) or nuckle") { |v| opts[:curve_crypto] = v }
450
+ o.separator " Env vars: OMQ_SERVER_KEY (client), OMQ_SERVER_PUBLIC + OMQ_SERVER_SECRET (server)"
451
+ o.separator " OMQ_CURVE_CRYPTO (backend: rbnacl or nuckle)"
452
+
453
+ o.separator "\nOther:"
454
+ o.on("-v", "--verbose", "Print connection events to stderr") { opts[:verbose] = true }
455
+ o.on("-q", "--quiet", "Suppress message output") { opts[:quiet] = true }
456
+ o.on( "--transient", "Exit when all peers disconnect") { opts[:transient] = true }
457
+ o.on("-V", "--version") { require "omq"; puts "omq-cli #{OMQ::CLI::VERSION} (omq #{OMQ::VERSION})"; exit }
458
+ o.on("-h") { puts o; exit }
459
+ o.on( "--help") { page "#{o}\n#{EXAMPLES}"; exit }
460
+ o.on( "--examples") { page EXAMPLES; exit }
461
+
462
+ o.separator "\nExit codes: 0 = success, 1 = error, 2 = timeout"
463
+ end
464
+
465
+ begin
466
+ parser.parse!(argv)
467
+ rescue OptionParser::ParseError => e
468
+ abort e.message
469
+ end
470
+
471
+ type_name = argv.shift
472
+ abort parser.to_s unless type_name
473
+ unless SOCKET_TYPE_NAMES.include?(type_name.downcase)
474
+ abort "Unknown socket type: #{type_name}. Known: #{SOCKET_TYPE_NAMES.join(', ')}"
475
+ end
476
+
477
+ opts[:type_name] = type_name.downcase
478
+
479
+ normalize = ->(url) { url.sub(%r{\Atcp://\*:}, "tcp://0.0.0.0:").sub(%r{\Atcp://:}, "tcp://localhost:") }
480
+ normalize_ep = ->(ep) { Endpoint.new(normalize.call(ep.url), ep.bind?) }
481
+ opts[:binds].map!(&normalize)
482
+ opts[:connects].map!(&normalize)
483
+ opts[:endpoints].map!(&normalize_ep)
484
+ opts[:in_endpoints].map!(&normalize_ep)
485
+ opts[:out_endpoints].map!(&normalize_ep)
486
+
487
+ opts
488
+ end
489
+
490
+
491
+ # Validates option combinations.
492
+ #
493
+ def validate!(opts)
494
+ type_name = opts[:type_name]
495
+
496
+ if type_name == "pipe"
497
+ has_in_out = opts[:in_endpoints].any? || opts[:out_endpoints].any?
498
+ if has_in_out
499
+ abort "pipe --in requires at least one endpoint" if opts[:in_endpoints].empty?
500
+ abort "pipe --out requires at least one endpoint" if opts[:out_endpoints].empty?
501
+ abort "pipe: don't mix --in/--out with bare -b/-c endpoints" unless opts[:endpoints].empty?
502
+ else
503
+ abort "pipe requires exactly 2 endpoints (pull-side and push-side), or use --in/--out" if opts[:endpoints].size != 2
504
+ end
505
+ else
506
+ abort "--in/--out are only valid for pipe" if opts[:in_endpoints].any? || opts[:out_endpoints].any?
507
+ abort "At least one --connect or --bind is required" if opts[:connects].empty? && opts[:binds].empty?
508
+ end
509
+ abort "--data and --file are mutually exclusive" if opts[:data] && opts[:file]
510
+ abort "--subscribe is only valid for SUB" if !opts[:subscribes].empty? && type_name != "sub"
511
+ abort "--join is only valid for DISH" if !opts[:joins].empty? && type_name != "dish"
512
+ abort "--group is only valid for RADIO" if opts[:group] && type_name != "radio"
513
+ abort "--identity is only valid for DEALER/ROUTER" if opts[:identity] && !%w[dealer router].include?(type_name)
514
+ abort "--target is only valid for ROUTER/SERVER/PEER" if opts[:target] && !%w[router server peer].include?(type_name)
515
+ abort "--conflate is only valid for PUB/RADIO" if opts[:conflate] && !%w[pub radio].include?(type_name)
516
+ abort "--recv-eval is not valid for send-only sockets (use --send-eval / -E)" if opts[:recv_expr] && SEND_ONLY.include?(type_name)
517
+ abort "--send-eval is not valid for recv-only sockets (use --recv-eval / -e)" if opts[:send_expr] && RECV_ONLY.include?(type_name)
518
+ abort "--send-eval and --target are mutually exclusive" if opts[:send_expr] && opts[:target]
519
+
520
+ if opts[:parallel]
521
+ abort "-P/--parallel is only valid for pipe" unless type_name == "pipe"
522
+ abort "-P/--parallel must be >= 2" if opts[:parallel] < 2
523
+ all_pipe_eps = opts[:in_endpoints] + opts[:out_endpoints] + opts[:endpoints]
524
+ abort "-P/--parallel requires all endpoints to use --connect (not --bind)" if all_pipe_eps.any?(&:bind?)
525
+ end
526
+
527
+ (opts[:connects] + opts[:binds]).each do |url|
528
+ abort "inproc not supported, use tcp:// or ipc://" if url.include?("inproc://")
529
+ end
530
+ end
531
+
532
+
533
+ # Validates that required gems are available.
534
+ #
535
+ def validate_gems!(config)
536
+ abort "--msgpack requires the msgpack gem" if config.format == :msgpack && !config.has_msgpack
537
+ abort "--compress requires the zstd-ruby gem" if config.compress && !config.has_zstd
538
+
539
+ if config.recv_only? && (config.data || config.file)
540
+ abort "--data/--file not valid for #{config.type_name} (receive-only)"
541
+ end
542
+ end
543
+ end
544
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omq-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: omq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.8'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.8'
26
+ description: Command-line tool for sending and receiving ZeroMQ messages on any socket
27
+ type (REQ/REP, PUB/SUB, PUSH/PULL, DEALER/ROUTER, and all draft types). Supports
28
+ Ruby eval (-e/-E), script handlers (-r), pipe virtual socket with Ractor parallelism,
29
+ multiple formats (ASCII, JSON Lines, msgpack, Marshal), Zstandard compression, and
30
+ CURVE encryption. Like nngcat from libnng, but with Ruby superpowers.
31
+ email:
32
+ - paddor@gmail.com
33
+ executables:
34
+ - omq
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - LICENSE
39
+ - README.md
40
+ - exe/omq
41
+ - lib/omq/cli.rb
42
+ - lib/omq/cli/base_runner.rb
43
+ - lib/omq/cli/channel.rb
44
+ - lib/omq/cli/client_server.rb
45
+ - lib/omq/cli/config.rb
46
+ - lib/omq/cli/formatter.rb
47
+ - lib/omq/cli/pair.rb
48
+ - lib/omq/cli/peer.rb
49
+ - lib/omq/cli/pipe.rb
50
+ - lib/omq/cli/pub_sub.rb
51
+ - lib/omq/cli/push_pull.rb
52
+ - lib/omq/cli/radio_dish.rb
53
+ - lib/omq/cli/req_rep.rb
54
+ - lib/omq/cli/router_dealer.rb
55
+ - lib/omq/cli/scatter_gather.rb
56
+ - lib/omq/cli/version.rb
57
+ homepage: https://github.com/paddor/omq-cli
58
+ licenses:
59
+ - ISC
60
+ metadata: {}
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.3'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.6
76
+ specification_version: 4
77
+ summary: ZeroMQ CLI — pipe, filter, and transform messages from the terminal
78
+ test_files: []