asterism 0.8.0 → 0.10.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/asterism/cli.rb CHANGED
@@ -1,13 +1,20 @@
1
1
  # The command line of the gem (exe/asterism): look at an Asterism network
2
- # from CRuby, and call an object when asked to.
2
+ # from CRuby, and call an object when asked to. Its commands are a noun and
3
+ # a verb, as ros2's are:
3
4
  #
4
- # asterism nodes --router tcp/192.0.2.2:7447
5
- # asterism tree --credentials admin.tar
6
- # asterism objects 'games/games/*'
7
- # asterism watch # joins, leaves, statuses as they come
8
- # asterism ping games/games/othello1 # the time of a round trip (meta)
9
- # asterism call games/games/othello1 view '"<token>"'
10
- # asterism mcp --allow 'games/othello1.*' # an MCP server on stdio
5
+ # asterism node list --router tcp/192.0.2.2:7447
6
+ # asterism node info games --credentials admin.tar
7
+ # asterism app list / app info games/games
8
+ # asterism object list 'games/games/*' / object info games/games/othello1
9
+ # asterism object find join # the objects that have join
10
+ # asterism status get games/games # echo: as it changes, hz: how often
11
+ # asterism object get games/games/othello1 state # the next put there
12
+ # asterism call games/games/othello1 view '"<token>"'
13
+ # asterism ping / watch / tree / mcp # as before
14
+ # asterism completion bash # the shell's Tab completion
15
+ #
16
+ # The older commands (nodes, objects, echo) stay, with the same output.
17
+ # `-o json` / `-o yaml` (and --json, as before) on every command that reads.
11
18
  #
12
19
  # It is one more Asterism node: it connects with Asterism.connect (as the
13
20
  # certificate's name with --credentials, as cli-<6 hex digits> otherwise)
@@ -21,56 +28,153 @@ require "securerandom"
21
28
  require_relative "cli/literal"
22
29
  require_relative "cli/survey"
23
30
  require_relative "cli/allow"
31
+ require_relative "cli/text"
32
+ require_relative "cli/hz"
33
+ require_relative "cli/complete"
34
+ require_relative "cli/values"
24
35
 
25
36
  module Asterism
26
37
  class CLI
27
- USAGE = <<~TEXT
28
- Usage: asterism COMMAND [options]
29
-
30
- Commands (they only read, except call):
31
- nodes the nodes, their applications and profiles
32
- objects [PATTERN] the objects and their methods (PATTERN: node/app/object, * and **)
33
- tree nodes > applications (with their status) > objects (with their methods)
34
- watch nodes, applications and objects coming and going, and status changes
35
- ping PATH round trips: PATH node/app/object (its meta), or node (its profile);
36
- --call METHOD [ARG...] times a call instead (echo is the same command)
37
- call PATH METHOD [ARG...]
38
- calls a method; each ARG is JSON, else a Ruby literal, else text
39
- mcp an MCP server on stdio (needs the mcp gem): the listings as tools,
40
- and the methods --allow names
41
- version the gem's version
42
-
43
- Where the network is (one of them):
44
- --router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
45
- --credentials TAR a console's .tar (mutual TLS; its router and name)
46
- TEXT
47
-
48
- def self.start(argv, out: $stdout, err: $stderr)
49
- new(out: out, err: err).run(argv.dup)
50
- end
51
-
52
- def initialize(out: $stdout, err: $stderr)
38
+ # The nouns and their verbs: verb => [its arguments, what it does].
39
+ NOUNS = {
40
+ "node" => {
41
+ "list" => ["", "the nodes, a name a line"],
42
+ "info" => ["NODE", "one node: its profile, applications (with their statuses) and objects"]
43
+ },
44
+ "app" => {
45
+ "list" => ["", "the applications, node/app a line"],
46
+ "info" => ["NODE/APP", "one application: its status and its objects"]
47
+ },
48
+ "object" => {
49
+ "list" => ["[PATTERN]", "the objects, node/app/object a line (PATTERN: * and ** for whole parts)"],
50
+ "info" => ["PATH", "one object (node/app/object): its description and its methods"],
51
+ "find" => ["METHOD", "the objects that have the method (* as a wildcard)"],
52
+ "get" => ["PATH [KEY]", "the next value the application puts on PATH/KEY (its own keys; KEY: ** " \
53
+ "by default; --timeout 5.0)"],
54
+ "echo" => ["PATH [KEY]", "the values it puts there as they come (the text: what changed; --once: " \
55
+ "the first; --raw: each whole)"]
56
+ },
57
+ "status" => {
58
+ "get" => ["NODE/APP", "an application's status, now"],
59
+ "echo" => ["NODE/APP", "its status, then each change as it comes (--once: now, once)"],
60
+ "hz" => ["NODE/APP", "how often it changes: the rate, the intervals' min / max / std dev"]
61
+ }
62
+ }.freeze
63
+
64
+ # The commands without a noun: name => [its arguments, what it does].
65
+ COMMANDS = {
66
+ "call" => ["PATH METHOD [ARG...]", "calls a method; each ARG is JSON, else a Ruby literal, else text"],
67
+ "ping" => ["PATH", "round trips: PATH node/app/object (its meta) or node (its profile); " \
68
+ "--call METHOD [ARG...] times a call instead"],
69
+ "watch" => ["", "nodes, applications and objects coming and going, and status changes"],
70
+ "tree" => ["", "nodes > applications (with their status) > objects (--methods: their methods)"],
71
+ "mcp" => ["", "an MCP server on stdio (needs the mcp gem): the listings as tools, and the methods " \
72
+ "--allow names"],
73
+ "completion" => ["bash|zsh", "the shell's Tab completion: source <(asterism completion bash)"],
74
+ "version" => ["", "the gem's version"]
75
+ }.freeze
76
+
77
+ # The older names, kept with their output.
78
+ ALIASES = {
79
+ "nodes" => "the nodes, their applications (with their statuses) and profiles, in one listing",
80
+ "objects" => "[PATTERN] the objects with their descriptions and methods (object info of each)",
81
+ "echo" => "ping"
82
+ }.freeze
83
+
84
+ # The options that take a value (what __complete skips over).
85
+ VALUE_OPTIONS = %w[--router --credentials --peer --node --app -o --output --timeout --settle --duration --count
86
+ --interval --call --kwargs --allow --period --window].freeze
87
+
88
+ def self.usage
89
+ col = 30
90
+ line = lambda do |name, args, what|
91
+ Text.wrap_text(what, 80, first: " #{"#{name} #{args}".strip.ljust(col - 3)} ").join("\n")
92
+ end
93
+ nouns = NOUNS.flat_map { |n, verbs| verbs.map { |v, (a, w)| line.("#{n} #{v}", a, w) } }
94
+ cmds = COMMANDS.map { |c, (a, w)| line.(c, a, w) }
95
+ <<~TEXT
96
+ Usage: asterism NOUN VERB [ARG...] [options] (asterism NOUN: its verbs)
97
+
98
+ Commands (they only read, except call):
99
+ #{nouns.join("\n")}
100
+
101
+ #{cmds.join("\n")}
102
+
103
+ Where the network is (one of them):
104
+ --router LOCATOR a router without TLS, e.g. tcp/192.0.2.2:7447
105
+ --credentials TAR a console's .tar (mutual TLS; its router and name)
106
+ With none of --router, --credentials, --peer, the environment's
107
+ ASTERISM_CREDENTIALS and ASTERISM_ROUTER are used instead.
108
+
109
+ Output: text; -o json or -o yaml (the same content) on the commands that read
110
+ (--json is -o json). On a terminal the text has colours (NO_COLOR=1: none) and
111
+ the terminal's width; otherwise it is plain, 80 columns wide (or COLUMNS).
112
+
113
+ Older names, kept: nodes (all the nodes in one listing), objects [PATTERN]
114
+ (object info of each), echo (ping).
115
+ TEXT
116
+ end
117
+
118
+ USAGE = usage
119
+
120
+ # The verbs of a noun, for `asterism NOUN`.
121
+ def self.noun_usage(noun)
122
+ verbs = NOUNS.fetch(noun).map do |v, (a, w)|
123
+ Text.wrap_text(w, 80, first: " #{"#{noun} #{v} #{a}".strip.ljust(27)} ").join("\n")
124
+ end
125
+ "Usage: asterism #{noun} VERB [ARG...] [options]\n\n#{verbs.join("\n")}\n\n" \
126
+ "asterism #{noun} VERB --help: its options; asterism --help: every command\n"
127
+ end
128
+
129
+ def self.start(argv, out: $stdout, err: $stderr, env: ENV)
130
+ new(out: out, err: err, env: env).run(argv.dup)
131
+ end
132
+
133
+ def initialize(out: $stdout, err: $stderr, env: ENV)
53
134
  @out = out
54
135
  @err = err
55
- @opts = { timeout: 2.0, settle: 1.5, json: false, count: 5, interval: 0.2, allow: [] }
136
+ @env = env
137
+ @lock = Mutex.new
138
+ @last_status = {}
139
+ @opts = { timeout: 2.0, settle: 1.5, format: nil, count: 5, interval: 0.2, allow: [], period: 1.0,
140
+ window: 1000 }
56
141
  end
57
142
 
58
143
  def run(argv)
59
144
  cmd = argv.shift
60
145
  case cmd
61
146
  when nil, "help", "-h", "--help"
147
+ if cmd == "help" && NOUNS.key?(argv.first)
148
+ @out.puts CLI.noun_usage(argv.first)
149
+ return 0
150
+ end
62
151
  @out.puts USAGE
63
152
  return cmd.nil? ? 2 : 0
64
153
  when "version", "--version", "-v"
65
154
  @out.puts "asterism #{::Asterism::VERSION}"
66
155
  return 0
156
+ when "__complete"
157
+ return cmd_complete(argv)
67
158
  end
68
- unless %w[nodes objects tree watch ping echo call mcp].include?(cmd)
159
+ key = cmd
160
+ if NOUNS.key?(cmd)
161
+ verb = argv.first
162
+ if verb.nil? || %w[-h --help].include?(verb) || verb.start_with?("-")
163
+ @out.puts CLI.noun_usage(cmd)
164
+ return %w[-h --help].include?(verb) ? 0 : 2
165
+ end
166
+ unless NOUNS[cmd].key?(verb)
167
+ @err.puts "asterism: #{cmd} has no verb #{verb.inspect}\n\n#{CLI.noun_usage(cmd)}"
168
+ return 2
169
+ end
170
+ argv.shift
171
+ key = "#{cmd} #{verb}"
172
+ elsif !(COMMANDS.key?(cmd) || ALIASES.key?(cmd))
69
173
  @err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
70
174
  return 2
71
175
  end
72
- rest = parser(cmd).parse(argv)
73
- send("cmd_#{cmd == 'echo' ? 'ping' : cmd}", rest)
176
+ rest = parser(key).parse(argv)
177
+ send("cmd_#{key == 'echo' ? 'ping' : key.tr(' ', '_')}", rest)
74
178
  rescue OptionParser::ParseError, ArgumentError => e
75
179
  @err.puts "asterism: #{e.message}"
76
180
  2
@@ -84,18 +188,33 @@ module Asterism
84
188
  0
85
189
  end
86
190
 
87
- def parser(cmd)
191
+ # The options of a command (key: "tree", "node info", ...).
192
+ def parser(key)
88
193
  OptionParser.new do |o|
89
- o.banner = "Usage: asterism #{cmd} [options]"
90
- o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
91
- o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
92
- o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
93
- o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
94
- o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
95
- o.on("--json", "JSON instead of text (watch: one JSON object a line)") { @opts[:json] = true }
96
- o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
97
- o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
98
- case cmd
194
+ args = (NOUNS.dig(*key.split(" ")) || COMMANDS[key] || [""])[0]
195
+ o.banner = "Usage: asterism #{key} #{args} [options]".squeeze(" ")
196
+ unless key == "completion"
197
+ o.on("--router LOCATOR", "a router (tcp/HOST:7447)") { @opts[:router] = _1 }
198
+ o.on("--credentials TAR", "a console's .tar") { @opts[:credentials] = _1 }
199
+ o.on("--peer LOCATOR", "a direct peer link to a node listening there (no router)") { @opts[:peer] = _1 }
200
+ o.on("--node NAME", "this node's ID (default: the certificate's name, or cli-<6 hex>)") { @opts[:node] = _1 }
201
+ o.on("--app NAME", "this application's name (default: cli-<4 hex>)") { @opts[:app] = _1 }
202
+ unless key == "mcp"
203
+ o.on("-o", "--output FORMAT", %w[json yaml], "json or yaml instead of text (the same content)") do
204
+ @opts[:format] = _1.to_sym
205
+ end
206
+ o.on("--json", "JSON instead of text (-o json)") { @opts[:format] = :json }
207
+ end
208
+ if key == "object get"
209
+ o.on("--timeout SECONDS", Float, "how long to wait for a put (5.0)") { @opts[:put_timeout] = _1 }
210
+ else
211
+ o.on("--timeout SECONDS", Float, "time limit of each get or call (2.0)") { @opts[:timeout] = _1 }
212
+ end
213
+ o.on("--settle SECONDS", Float, "how long to wait for the tokens at connect (1.5)") { @opts[:settle] = _1 }
214
+ end
215
+ case key
216
+ when "tree"
217
+ o.on("--methods", "list each object's methods (else how many)") { @opts[:methods] = true }
99
218
  when "watch"
100
219
  o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
101
220
  when "ping", "echo"
@@ -107,20 +226,82 @@ module Asterism
107
226
  when "mcp"
108
227
  o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
109
228
  "(repeatable)") { @opts[:allow] << _1 }
229
+ when "object echo"
230
+ o.on("--once", "the first put, then stop") { @opts[:once] = true }
231
+ o.on("--raw", "each value whole (the text; else what changed)") { @opts[:raw] = true }
232
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
233
+ when "status echo"
234
+ o.on("--once", "the status now, once, then stop") { @opts[:once] = true }
235
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
236
+ when "status hz"
237
+ o.on("--period SECONDS", Float, "how often to print (1.0)") { @opts[:period] = _1 }
238
+ o.on("--window N", Integer, "how many of the last intervals count (1000)") { @opts[:window] = _1 }
239
+ o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
110
240
  end
111
241
  end
112
242
  end
113
243
 
244
+ # ------------------------------------------------------------ the tables, for __complete
245
+
246
+ def noun?(w)
247
+ NOUNS.key?(w)
248
+ end
249
+
250
+ def nouns
251
+ NOUNS.keys
252
+ end
253
+
254
+ def verbs(noun)
255
+ NOUNS.fetch(noun).keys
256
+ end
257
+
258
+ def command_words
259
+ NOUNS.keys + COMMANDS.keys + ALIASES.keys + ["help"]
260
+ end
261
+
262
+ # [key, its arguments] of the positional words (nil when they name no
263
+ # command).
264
+ def command_key(pos)
265
+ return [nil, []] if pos.empty?
266
+ if NOUNS.key?(pos[0])
267
+ return [nil, []] unless pos[1] && NOUNS[pos[0]].key?(pos[1])
268
+ return ["#{pos[0]} #{pos[1]}", pos[2..]]
269
+ end
270
+ return [pos[0], pos[1..]] if COMMANDS.key?(pos[0]) || ALIASES.key?(pos[0]) || pos[0] == "help"
271
+ [nil, []]
272
+ end
273
+
274
+ def option_takes_value?(w)
275
+ VALUE_OPTIONS.include?(w)
276
+ end
277
+
278
+ def options_for(key)
279
+ return [] if key == "help"
280
+ parser(key).top.list.flat_map { |sw| sw.respond_to?(:long) ? sw.long + sw.short : [] } + ["--help"]
281
+ end
282
+
114
283
  # ------------------------------------------------------------ connecting
115
284
 
285
+ # [router, credentials, peer]: the command line's, or, when it names
286
+ # none of them, ASTERISM_ROUTER and ASTERISM_CREDENTIALS from the
287
+ # environment (all or nothing, so a router given on the line never meets
288
+ # a .tar from the environment).
289
+ def network(env = @env)
290
+ given = [@opts[:router], @opts[:credentials], @opts[:peer]]
291
+ return given if given.any?
292
+
293
+ router = env["ASTERISM_ROUTER"].to_s
294
+ creds = env["ASTERISM_CREDENTIALS"].to_s
295
+ [router.empty? ? nil : router, creds.empty? ? nil : creds, nil]
296
+ end
297
+
116
298
  # Connects, starts the receiving thread, waits for the tokens and yields
117
299
  # the Net and a Survey.
118
300
  def connect(app_prefix: "cli")
119
- router = @opts[:router]
120
- creds = @opts[:credentials]
121
- peer = @opts[:peer]
301
+ router, creds, peer = network
122
302
  if router.nil? && creds.nil? && peer.nil?
123
- raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar"
303
+ raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar " \
304
+ "(or ASTERISM_CREDENTIALS / ASTERISM_ROUTER)"
124
305
  end
125
306
  node = @opts[:node]
126
307
  node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
@@ -142,8 +323,26 @@ module Asterism
142
323
  end
143
324
  end
144
325
 
145
- def emit_json(v)
146
- @out.puts JSON.pretty_generate(v)
326
+ # -o json or -o yaml (or --json) given: the data instead of the text.
327
+ def structured?
328
+ !@opts[:format].nil?
329
+ end
330
+
331
+ # The data as -o asks: JSON (pretty, as --json always was) or YAML (the
332
+ # same content: what the JSON holds, as YAML).
333
+ def emit(v)
334
+ @opts[:format] == :yaml ? @out.write(yaml(v)) : @out.puts(JSON.pretty_generate(v))
335
+ end
336
+
337
+ # One record of a stream (watch, status echo / hz, call): a JSON line,
338
+ # or a YAML document ("---" before each).
339
+ def emit_line(v)
340
+ @opts[:format] == :yaml ? @out.write(yaml(v)) : @out.puts(JSON.generate(v))
341
+ end
342
+
343
+ def yaml(v)
344
+ require "yaml"
345
+ YAML.dump(JSON.parse(JSON.generate(v)))
147
346
  end
148
347
 
149
348
  # ------------------------------------------------------------ nodes
@@ -151,16 +350,17 @@ module Asterism
151
350
  def cmd_nodes(_rest)
152
351
  connect do |_net, sv|
153
352
  tree = sv.tree(meta: false)
154
- if @opts[:json]
155
- emit_json(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
353
+ if structured?
354
+ emit(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
156
355
  elsif tree.empty?
157
356
  @out.puts "(no other node is visible)"
158
357
  else
159
358
  tree.each do |n|
160
- @out.puts "#{n['node']}#{profile_text(n['profile'])}"
359
+ @out.puts node_line(n)
161
360
  n["apps"].each do |a|
162
- @out.puts " #{a['app']}#{" #{a['objects'].size} objects" unless a['objects'].empty?}" \
163
- "#{" status #{compact(a['status'])}" if a.key?('status')}"
361
+ k = a["objects"].size
362
+ @out.puts " #{paint(a['app'], :app)}#{paint(" #{count(k, 'object')}", :note) unless k.zero?}"
363
+ status_block(a, 4)
164
364
  end
165
365
  end
166
366
  end
@@ -175,8 +375,8 @@ module Asterism
175
375
  connect do |_net, sv|
176
376
  paths = sv.objects(pattern)
177
377
  metas = sv.metas(paths)
178
- if @opts[:json]
179
- emit_json(paths.map do |p|
378
+ if structured?
379
+ emit(paths.map do |p|
180
380
  h = { "path" => p, "methods" => metas[p] }
181
381
  h["doc"] = sv.object_doc(p) if sv.object_doc(p)
182
382
  h
@@ -184,14 +384,7 @@ module Asterism
184
384
  elsif paths.empty?
185
385
  @out.puts "(no object matches #{pattern})"
186
386
  else
187
- paths.each do |p|
188
- @out.puts "#{p} #{metas.key?(p) ? methods_text(metas[p]) : '(no meta: not answered in time, or not allowed)'}"
189
- @out.puts " #{sv.object_doc(p)}" if sv.object_doc(p)
190
- # The methods that tell what they do, one a line (doc's first line).
191
- (metas[p] || []).each do |m|
192
- @out.puts " #{signature(m)} #{m['doc'].lines.first.strip}" if m["doc"]
193
- end
194
- end
387
+ paths.each { |p| object_entry(p, metas[p], sv.object_doc(p)) }
195
388
  end
196
389
  end
197
390
  0
@@ -202,17 +395,20 @@ module Asterism
202
395
  def cmd_tree(_rest)
203
396
  connect do |_net, sv|
204
397
  tree = sv.tree
205
- if @opts[:json]
206
- emit_json(tree)
398
+ if structured?
399
+ emit(tree)
207
400
  elsif tree.empty?
208
401
  @out.puts "(no other node is visible)"
209
402
  else
210
403
  tree.each do |n|
211
- @out.puts "#{n['node']}#{profile_text(n['profile'])}"
404
+ @out.puts node_line(n)
212
405
  n["apps"].each do |a|
213
- @out.puts " #{a['app']}#{" status #{compact(a['status'])}" if a.key?('status')}"
406
+ @out.puts " #{paint(a['app'], :app)}"
407
+ status_block(a, 4)
214
408
  a["objects"].each do |o|
215
- @out.puts " #{o['name']} #{o['methods'] ? methods_text(o['methods']) : '(no meta)'}"
409
+ ms = o["methods"]
410
+ @out.puts " #{paint(o['name'], :object)} #{paint(ms ? count(ms.size, 'method') : '(no meta)', :note)}"
411
+ method_list(ms, 6) if ms && @opts[:methods] && !ms.empty?
216
412
  end
217
413
  end
218
414
  end
@@ -252,7 +448,7 @@ module Asterism
252
448
  end
253
449
  sleep 0.3
254
450
  @watch_started = true
255
- @err.puts "asterism: watching (Ctrl-C to stop)" unless @opts[:json]
451
+ @err.puts "asterism: watching (Ctrl-C to stop)" unless structured?
256
452
  deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
257
453
  loop do
258
454
  break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
@@ -265,25 +461,63 @@ module Asterism
265
461
 
266
462
  def event(kind, path, what: nil, status: nil, profile: nil, now: false)
267
463
  t = Time.now
268
- if @opts[:json]
464
+ if structured?
269
465
  h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
270
466
  h["what"] = what if what
271
467
  h["status"] = status if status
272
468
  h["profile"] = profile if profile
273
469
  h["now"] = true if now
274
- @out.puts JSON.generate(h)
470
+ @lock.synchronize { emit_line(h) }
275
471
  else
276
- mark = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }[kind]
277
- text = case kind
278
- when "status" then "status #{path} #{compact(status)}#{' (now)' if now}"
279
- when "profile" then "profile #{path}#{profile_text(profile)}"
280
- else "#{what} #{path}"
281
- end
282
- @out.puts "#{t.strftime('%H:%M:%S.%L')} #{mark} #{text}"
472
+ @lock.synchronize do
473
+ text = event_text(t, kind, path, what, status, profile, now)
474
+ @out.puts text if text
475
+ end
283
476
  end
284
477
  @out.flush
285
478
  end
286
479
 
480
+ # The lines of one event (nil: nothing to tell). A status is told whole
481
+ # (indented under the event) the first time it is seen, else only what
482
+ # changed, a line for each change. An application or a node that leaves
483
+ # takes its statuses along, so they are told whole when they are back.
484
+ def event_text(t, kind, path, what, status, profile, now)
485
+ head = "#{paint(t.strftime('%H:%M:%S.%L'), :time)} #{paint(EVENT_MARKS[kind], kind.to_sym)} "
486
+ case kind
487
+ when "status"
488
+ status = json_safe(status)
489
+ old = @last_status[path]
490
+ @last_status[path] = status
491
+ label = "status #{paint(path, :app)}"
492
+ if old.nil? || !old.is_a?(Hash) || !status.is_a?(Hash)
493
+ lines = Text.status_lines(status, style.width, Text.width(head))
494
+ return "#{head}#{label}#{paint(' (now)', :note) if now}\n#{lines.join("\n")}"
495
+ end
496
+ changes = Text.status_diff(old, status)
497
+ return nil if changes.empty?
498
+ changes.map { |c| "#{head}#{label} #{change_text(c)}" }.join("\n")
499
+ when "profile" then "#{head}profile #{paint(path, :node)}#{profile_text(profile)}"
500
+ else
501
+ forget_statuses(path) if kind == "leave"
502
+ "#{head}#{what} #{paint(path, WHAT_ROLES.fetch(what, :object))}"
503
+ end
504
+ end
505
+
506
+ EVENT_MARKS = { "join" => "+", "leave" => "-", "status" => "~", "profile" => "*" }.freeze
507
+ WHAT_ROLES = { "node" => :node, "app" => :app, "object" => :object }.freeze
508
+
509
+ def change_text(c)
510
+ case c[0]
511
+ when "+" then paint(c, :join)
512
+ when "-" then paint(c, :leave)
513
+ else c
514
+ end
515
+ end
516
+
517
+ def forget_statuses(path)
518
+ @last_status.delete_if { |k, _| k == path || k.start_with?("#{path}/") }
519
+ end
520
+
287
521
  # ------------------------------------------------------------ ping
288
522
 
289
523
  # Round trips to PATH: the meta of an object (no method runs), the
@@ -313,11 +547,11 @@ module Asterism
313
547
  end
314
548
  dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
315
549
  times << dt if ok
316
- @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless @opts[:json]
550
+ @out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless structured?
317
551
  end
318
552
  end
319
- if @opts[:json]
320
- emit_json({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
553
+ if structured?
554
+ emit({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
321
555
  "ms" => times.map { _1.round(2) } })
322
556
  elsif times.any?
323
557
  @out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
@@ -353,10 +587,10 @@ module Asterism
353
587
  px = ::Asterism[path, timeout: @opts[:timeout]]
354
588
  begin
355
589
  v = px.asterism_call(method, *args, **kwargs)
356
- @opts[:json] ? @out.puts(JSON.generate({ "value" => json_safe(v) })) : @out.puts(v.inspect)
590
+ structured? ? emit_line({ "value" => json_safe(v) }) : @out.puts(v.inspect)
357
591
  rescue ::Asterism::RemoteError => e
358
- if @opts[:json]
359
- @out.puts JSON.generate({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
592
+ if structured?
593
+ emit_line({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
360
594
  "message" => e.remote_message } })
361
595
  else
362
596
  @err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
@@ -390,27 +624,83 @@ module Asterism
390
624
 
391
625
  # ------------------------------------------------------------ text
392
626
 
627
+ def style
628
+ @style ||= Style.new(@out, @env)
629
+ end
630
+
631
+ def paint(text, *styles)
632
+ style.paint(text, *styles)
633
+ end
634
+
635
+ def count(n, what)
636
+ "#{n} #{what}#{'s' unless n == 1}"
637
+ end
638
+
393
639
  def profile_text(pr)
394
640
  return "" unless pr
395
641
  parts = [pr["kind"], pr["host"]].compact.join("/")
396
642
  name = pr["name"] ? " \"#{pr['name']}\"" : ""
397
- " (#{parts})#{name}"
643
+ " #{paint("(#{parts})", :note)}#{name}"
644
+ end
645
+
646
+ def node_line(n)
647
+ "#{paint(n['node'], :node)}#{profile_text(n['profile'])}"
648
+ end
649
+
650
+ # An application's status (when it has one) under "status:", indented.
651
+ def status_block(app, indent)
652
+ return unless app.key?("status")
653
+ @out.puts "#{' ' * indent}#{paint('status:', :note)}"
654
+ Text.status_lines(json_safe(app["status"]), style.width, indent + 2).each { @out.puts _1 }
398
655
  end
399
656
 
400
- def methods_text(list)
401
- list.map { |m| signature(m) }.join(" ")
657
+ # The methods' signatures wrapped to the width, indented.
658
+ def method_list(list, indent)
659
+ sigs = list.map { paint(signature(_1), :method) }
660
+ Text.wrap(sigs, style.width, first: " " * indent, sep: " ").each { @out.puts _1 }
402
661
  end
403
662
 
663
+ # One object of `objects`: its path, its description, then its methods:
664
+ # one a line with their descriptions, the descriptions in one column and
665
+ # wrapped (a signature too long for the column has its description on
666
+ # the next line); or, when none has one, the signatures wrapped.
667
+ def object_entry(path, methods, doc)
668
+ @out.puts paint(path, :object)
669
+ Text.wrap_text(doc, style.width, first: " ").each { @out.puts _1 } if doc
670
+ unless methods
671
+ @out.puts " #{paint('(no meta: not answered in time, or not allowed)', :note)}"
672
+ return
673
+ end
674
+ return method_list(methods, 2) unless methods.any? { _1["doc"] }
675
+
676
+ sigs = methods.map { signature(_1) }
677
+ col = 2 + [sigs.map { Text.width(_1) }.max, SIGNATURE_COLUMN].min + 2
678
+ methods.zip(sigs).each do |m, sig|
679
+ head = " #{paint(sig, :method)}"
680
+ unless m["doc"]
681
+ @out.puts head
682
+ next
683
+ end
684
+ sw = 2 + Text.width(sig)
685
+ if sw + 2 > col
686
+ @out.puts head
687
+ head = " " * col
688
+ else
689
+ head += " " * (col - sw)
690
+ end
691
+ Text.wrap_text(m["doc"], style.width, first: head, rest: " " * col).each { @out.puts _1 }
692
+ end
693
+ end
694
+
695
+ # The widest signature the description column makes room for.
696
+ SIGNATURE_COLUMN = 24
697
+
404
698
  # name(arg, arg) with the names the meta tells, else name(2) / name(*).
405
699
  def signature(m)
406
700
  return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
407
701
  m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
408
702
  end
409
703
 
410
- def compact(v)
411
- JSON.generate(json_safe(v))
412
- end
413
-
414
704
  # A value as JSON can hold it (bytes as a note).
415
705
  def json_safe(v)
416
706
  case v
@@ -422,3 +712,4 @@ module Asterism
422
712
  end
423
713
  end
424
714
  end
715
+ require_relative "cli/nouns"
@@ -3,5 +3,5 @@ module Asterism
3
3
  # is in asterism.gemspec (asterism-zenoh ~> 0.4.0 for 0.5.0 to 0.7.0:
4
4
  # the bindings did not change; see CHANGELOG.md). 0.7.0 speaks wire
5
5
  # protocol 1 only (Asterism::PROTOCOL, docs/protocol.md).
6
- VERSION = "0.8.0"
6
+ VERSION = "0.10.0"
7
7
  end