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