asterism 0.7.1 → 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 +100 -0
- data/README.md +288 -5
- data/exe/asterism +8 -0
- data/lib/asterism/cli/allow.rb +53 -0
- data/lib/asterism/cli/complete.rb +282 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/literal.rb +132 -0
- data/lib/asterism/cli/mcp.rb +272 -0
- data/lib/asterism/cli/nouns.rb +396 -0
- data/lib/asterism/cli/survey.rb +206 -0
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli.rb +701 -0
- data/lib/asterism/cruby/credentials.rb +61 -116
- data/lib/asterism/cruby/objects.rb +60 -7
- data/lib/asterism/version.rb +1 -1
- data/lib/asterism.rb +1 -1
- data/mrblib/asterism.rb +206 -23
- data/mrblib/credentials.rb +197 -0
- data/mrblib/status.rb +198 -0
- metadata +17 -4
data/lib/asterism/cli.rb
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
# The command line of the gem (exe/asterism): look at an Asterism network
|
|
2
|
+
# from CRuby, and call an object when asked to. Its commands are a noun and
|
|
3
|
+
# a verb, as ros2's are:
|
|
4
|
+
#
|
|
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.
|
|
17
|
+
#
|
|
18
|
+
# It is one more Asterism node: it connects with Asterism.connect (as the
|
|
19
|
+
# certificate's name with --credentials, as cli-<6 hex digits> otherwise)
|
|
20
|
+
# and reads what docs/protocol.md lets anyone read. It calls a method only
|
|
21
|
+
# with `call` (and the MCP bridge only the methods --allow names). What it
|
|
22
|
+
# can see is what the router lets it see: with a console's .tar, its role.
|
|
23
|
+
require "asterism"
|
|
24
|
+
require "optparse"
|
|
25
|
+
require "json"
|
|
26
|
+
require "securerandom"
|
|
27
|
+
require_relative "cli/literal"
|
|
28
|
+
require_relative "cli/survey"
|
|
29
|
+
require_relative "cli/allow"
|
|
30
|
+
require_relative "cli/text"
|
|
31
|
+
require_relative "cli/hz"
|
|
32
|
+
require_relative "cli/complete"
|
|
33
|
+
|
|
34
|
+
module Asterism
|
|
35
|
+
class CLI
|
|
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)
|
|
128
|
+
@out = out
|
|
129
|
+
@err = err
|
|
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 }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def run(argv)
|
|
138
|
+
cmd = argv.shift
|
|
139
|
+
case cmd
|
|
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
|
|
145
|
+
@out.puts USAGE
|
|
146
|
+
return cmd.nil? ? 2 : 0
|
|
147
|
+
when "version", "--version", "-v"
|
|
148
|
+
@out.puts "asterism #{::Asterism::VERSION}"
|
|
149
|
+
return 0
|
|
150
|
+
when "__complete"
|
|
151
|
+
return cmd_complete(argv)
|
|
152
|
+
end
|
|
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))
|
|
167
|
+
@err.puts "asterism: unknown command #{cmd.inspect}\n\n#{USAGE}"
|
|
168
|
+
return 2
|
|
169
|
+
end
|
|
170
|
+
rest = parser(key).parse(argv)
|
|
171
|
+
send("cmd_#{key == 'echo' ? 'ping' : key.tr(' ', '_')}", rest)
|
|
172
|
+
rescue OptionParser::ParseError, ArgumentError => e
|
|
173
|
+
@err.puts "asterism: #{e.message}"
|
|
174
|
+
2
|
|
175
|
+
rescue ::Asterism::Disconnected => e
|
|
176
|
+
@err.puts "asterism: cannot reach the network: #{e.message}"
|
|
177
|
+
1
|
|
178
|
+
rescue ::Asterism::Error => e
|
|
179
|
+
@err.puts "asterism: #{e.class}: #{e.message}"
|
|
180
|
+
1
|
|
181
|
+
rescue Interrupt
|
|
182
|
+
0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The options of a command (key: "tree", "node info", ...).
|
|
186
|
+
def parser(key)
|
|
187
|
+
OptionParser.new do |o|
|
|
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 }
|
|
208
|
+
when "watch"
|
|
209
|
+
o.on("--duration SECONDS", Float, "stop after this long (default: until Ctrl-C)") { @opts[:duration] = _1 }
|
|
210
|
+
when "ping", "echo"
|
|
211
|
+
o.on("--count N", Integer, "round trips (5)") { @opts[:count] = _1 }
|
|
212
|
+
o.on("--interval SECONDS", Float, "pause between them (0.2)") { @opts[:interval] = _1 }
|
|
213
|
+
o.on("--call METHOD", "time a call of METHOD (with the ARGs after PATH) instead of the meta") { @opts[:call] = _1 }
|
|
214
|
+
when "call"
|
|
215
|
+
o.on("--kwargs VALUE", "keyword arguments: a JSON object or a Ruby Hash literal") { @opts[:kwargs] = _1 }
|
|
216
|
+
when "mcp"
|
|
217
|
+
o.on("--allow PATTERN", "methods the MCP client may call: [node/][app/]object.method, * as a wildcard " \
|
|
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 }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
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
|
+
|
|
269
|
+
# ------------------------------------------------------------ connecting
|
|
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
|
+
|
|
284
|
+
# Connects, starts the receiving thread, waits for the tokens and yields
|
|
285
|
+
# the Net and a Survey.
|
|
286
|
+
def connect(app_prefix: "cli")
|
|
287
|
+
router, creds, peer = network
|
|
288
|
+
if router.nil? && creds.nil? && peer.nil?
|
|
289
|
+
raise ArgumentError, "where is the network? --router tcp/HOST:7447 or --credentials FILE.tar " \
|
|
290
|
+
"(or ASTERISM_CREDENTIALS / ASTERISM_ROUTER)"
|
|
291
|
+
end
|
|
292
|
+
node = @opts[:node]
|
|
293
|
+
node ||= "#{app_prefix}-#{SecureRandom.hex(3)}" unless creds
|
|
294
|
+
app = @opts[:app] || "#{app_prefix}-#{SecureRandom.hex(2)}"
|
|
295
|
+
kw = { node: node, app: app }
|
|
296
|
+
kw[:credentials] = creds if creds
|
|
297
|
+
locator = router
|
|
298
|
+
if peer
|
|
299
|
+
locator = peer
|
|
300
|
+
kw[:mode] = :peer
|
|
301
|
+
end
|
|
302
|
+
::Asterism.profile(name: app_prefix == "mcp" ? "asterism MCP" : "asterism CLI")
|
|
303
|
+
::Asterism.connect(locator, **kw) do |net|
|
|
304
|
+
net.on_error { |e, where| @err.puts "asterism: #{where}: #{e.class}: #{e.message}" }
|
|
305
|
+
net.start
|
|
306
|
+
survey = Survey.new(timeout: @opts[:timeout])
|
|
307
|
+
survey.settle(@opts[:settle])
|
|
308
|
+
yield net, survey
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
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)))
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# ------------------------------------------------------------ nodes
|
|
335
|
+
|
|
336
|
+
def cmd_nodes(_rest)
|
|
337
|
+
connect do |_net, sv|
|
|
338
|
+
tree = sv.tree(meta: false)
|
|
339
|
+
if structured?
|
|
340
|
+
emit(tree.map { |n| n.merge("apps" => n["apps"].map { |a| a.except("objects") }) })
|
|
341
|
+
elsif tree.empty?
|
|
342
|
+
@out.puts "(no other node is visible)"
|
|
343
|
+
else
|
|
344
|
+
tree.each do |n|
|
|
345
|
+
@out.puts node_line(n)
|
|
346
|
+
n["apps"].each do |a|
|
|
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)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
0
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------ objects
|
|
358
|
+
|
|
359
|
+
def cmd_objects(rest)
|
|
360
|
+
pattern = rest.shift || "**"
|
|
361
|
+
connect do |_net, sv|
|
|
362
|
+
paths = sv.objects(pattern)
|
|
363
|
+
metas = sv.metas(paths)
|
|
364
|
+
if structured?
|
|
365
|
+
emit(paths.map do |p|
|
|
366
|
+
h = { "path" => p, "methods" => metas[p] }
|
|
367
|
+
h["doc"] = sv.object_doc(p) if sv.object_doc(p)
|
|
368
|
+
h
|
|
369
|
+
end)
|
|
370
|
+
elsif paths.empty?
|
|
371
|
+
@out.puts "(no object matches #{pattern})"
|
|
372
|
+
else
|
|
373
|
+
paths.each { |p| object_entry(p, metas[p], sv.object_doc(p)) }
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
0
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# ------------------------------------------------------------ tree
|
|
380
|
+
|
|
381
|
+
def cmd_tree(_rest)
|
|
382
|
+
connect do |_net, sv|
|
|
383
|
+
tree = sv.tree
|
|
384
|
+
if structured?
|
|
385
|
+
emit(tree)
|
|
386
|
+
elsif tree.empty?
|
|
387
|
+
@out.puts "(no other node is visible)"
|
|
388
|
+
else
|
|
389
|
+
tree.each do |n|
|
|
390
|
+
@out.puts node_line(n)
|
|
391
|
+
n["apps"].each do |a|
|
|
392
|
+
@out.puts " #{paint(a['app'], :app)}"
|
|
393
|
+
status_block(a, 4)
|
|
394
|
+
a["objects"].each do |o|
|
|
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?
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
0
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------ watch
|
|
407
|
+
|
|
408
|
+
# Prints the changes until Ctrl-C (or --duration): nodes (Asterism's
|
|
409
|
+
# on_join / on_leave), applications and objects (their liveliness
|
|
410
|
+
# tokens), statuses (their puts) and profiles (their puts). The
|
|
411
|
+
# statuses there at the start come first.
|
|
412
|
+
def cmd_watch(_rest)
|
|
413
|
+
connect do |net, sv|
|
|
414
|
+
own = "#{net.node_id}/#{net.app}"
|
|
415
|
+
sv.statuses.sort.each { |path, st| event("status", path, status: st, now: true) }
|
|
416
|
+
net.on_join { |n| event("join", n, what: "node") }
|
|
417
|
+
net.on_leave { |n| event("leave", n, what: "node") }
|
|
418
|
+
net.session.liveliness_watch("asterism/**") do |key, alive|
|
|
419
|
+
# The tokens alive at the start come first, in one burst: not news.
|
|
420
|
+
next unless @watch_started
|
|
421
|
+
path = key.delete_prefix("asterism/")
|
|
422
|
+
next if path == own || path.start_with?("#{own}/")
|
|
423
|
+
what = { 2 => "app", 3 => "object" }[path.count("/") + 1]
|
|
424
|
+
event(alive ? "join" : "leave", path, what: what) if what
|
|
425
|
+
end
|
|
426
|
+
net.session.subscribe(Survey::STATUS_ALL) do |sample|
|
|
427
|
+
got = ::Asterism::Status.parse(sample.key, sample.payload)
|
|
428
|
+
event("status", "#{got[0]}/#{got[1]}", status: got[2]) if got && "#{got[0]}/#{got[1]}" != own
|
|
429
|
+
end
|
|
430
|
+
net.session.subscribe(Survey::PROFILES_ALL) do |sample|
|
|
431
|
+
node = sample.key.split("/")[1]
|
|
432
|
+
pr = ::Asterism::Profile.check(::Asterism::Codec.unpack(sample.payload))
|
|
433
|
+
event("profile", node, profile: pr) if pr && node != net.node_id
|
|
434
|
+
end
|
|
435
|
+
sleep 0.3
|
|
436
|
+
@watch_started = true
|
|
437
|
+
@err.puts "asterism: watching (Ctrl-C to stop)" unless structured?
|
|
438
|
+
deadline = @opts[:duration] && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @opts[:duration])
|
|
439
|
+
loop do
|
|
440
|
+
break if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
441
|
+
break unless net.connected?
|
|
442
|
+
sleep 0.05
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
0
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def event(kind, path, what: nil, status: nil, profile: nil, now: false)
|
|
449
|
+
t = Time.now
|
|
450
|
+
if structured?
|
|
451
|
+
h = { "t" => t.utc.strftime("%Y-%m-%dT%H:%M:%S.%LZ"), "event" => kind, "path" => path }
|
|
452
|
+
h["what"] = what if what
|
|
453
|
+
h["status"] = status if status
|
|
454
|
+
h["profile"] = profile if profile
|
|
455
|
+
h["now"] = true if now
|
|
456
|
+
@lock.synchronize { emit_line(h) }
|
|
457
|
+
else
|
|
458
|
+
@lock.synchronize do
|
|
459
|
+
text = event_text(t, kind, path, what, status, profile, now)
|
|
460
|
+
@out.puts text if text
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
@out.flush
|
|
464
|
+
end
|
|
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
|
+
|
|
507
|
+
# ------------------------------------------------------------ ping
|
|
508
|
+
|
|
509
|
+
# Round trips to PATH: the meta of an object (no method runs), the
|
|
510
|
+
# profile of a node, or with --call a method (with the ARGs given).
|
|
511
|
+
def cmd_ping(rest)
|
|
512
|
+
path = rest.shift or raise ArgumentError, "ping: which node or object? (asterism ping node/app/object)"
|
|
513
|
+
args = rest.map { Literal.parse(_1) }
|
|
514
|
+
chunks = path.split("/")
|
|
515
|
+
raise ArgumentError, "ping: PATH is node or node/app/object" unless [1, 3].include?(chunks.size)
|
|
516
|
+
raise ArgumentError, "ping: --call needs node/app/object" if @opts[:call] && chunks.size != 3
|
|
517
|
+
what = @opts[:call] ? "call #{@opts[:call]}" : (chunks.size == 3 ? "meta" : "profile")
|
|
518
|
+
times = []
|
|
519
|
+
connect do |_net, _sv|
|
|
520
|
+
ms = (@opts[:timeout] * 1000).round
|
|
521
|
+
@opts[:count].times do |i|
|
|
522
|
+
sleep @opts[:interval] if i.positive?
|
|
523
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
524
|
+
ok = begin
|
|
525
|
+
ping_once(path, chunks, args, ms)
|
|
526
|
+
true
|
|
527
|
+
rescue ::Asterism::RemoteError => e
|
|
528
|
+
@err.puts "asterism: #{path}: #{e.remote_kind || 'error'}: #{e.message}" if i.zero?
|
|
529
|
+
true # it answered
|
|
530
|
+
rescue ::Asterism::TimeoutError => e
|
|
531
|
+
@err.puts "asterism: #{e.message}"
|
|
532
|
+
false
|
|
533
|
+
end
|
|
534
|
+
dt = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000
|
|
535
|
+
times << dt if ok
|
|
536
|
+
@out.puts "#{path} #{what}: #{ok ? format('%.1f ms', dt) : 'no answer'}" unless structured?
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
if structured?
|
|
540
|
+
emit({ "path" => path, "what" => what, "count" => @opts[:count], "answered" => times.size,
|
|
541
|
+
"ms" => times.map { _1.round(2) } })
|
|
542
|
+
elsif times.any?
|
|
543
|
+
@out.puts format("%d of %d answered: min %.1f / avg %.1f / max %.1f ms", times.size, @opts[:count],
|
|
544
|
+
times.min, times.sum / times.size, times.max)
|
|
545
|
+
end
|
|
546
|
+
times.empty? ? 1 : 0
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def ping_once(path, chunks, args, ms)
|
|
550
|
+
if @opts[:call]
|
|
551
|
+
::Asterism[path, timeout_ms: ms].asterism_call(@opts[:call], *args)
|
|
552
|
+
elsif chunks.size == 3
|
|
553
|
+
raw = ::Asterism.meta(path, ms)
|
|
554
|
+
raise ::Asterism::Error, "malformed meta from #{path}" unless raw.is_a?(Hash)
|
|
555
|
+
else
|
|
556
|
+
::Asterism.profile_async(path, false, nil, ms).raw_value
|
|
557
|
+
end
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
# ------------------------------------------------------------ call
|
|
561
|
+
|
|
562
|
+
def cmd_call(rest)
|
|
563
|
+
path = rest.shift
|
|
564
|
+
method = rest.shift
|
|
565
|
+
raise ArgumentError, "call: asterism call node/app/object METHOD [ARG...]" unless path && method
|
|
566
|
+
::Asterism.split_path(path)
|
|
567
|
+
args = rest.map { Literal.parse(_1) }
|
|
568
|
+
kwargs = @opts[:kwargs] ? Literal.parse(@opts[:kwargs]) : {}
|
|
569
|
+
raise ArgumentError, "--kwargs: expected a JSON object or a Ruby Hash" unless kwargs.is_a?(Hash)
|
|
570
|
+
kwargs = kwargs.transform_keys(&:to_sym)
|
|
571
|
+
code = 0
|
|
572
|
+
connect do |_net, _sv|
|
|
573
|
+
px = ::Asterism[path, timeout: @opts[:timeout]]
|
|
574
|
+
begin
|
|
575
|
+
v = px.asterism_call(method, *args, **kwargs)
|
|
576
|
+
structured? ? emit_line({ "value" => json_safe(v) }) : @out.puts(v.inspect)
|
|
577
|
+
rescue ::Asterism::RemoteError => e
|
|
578
|
+
if structured?
|
|
579
|
+
emit_line({ "error" => { "kind" => e.remote_kind, "class" => e.remote_class,
|
|
580
|
+
"message" => e.remote_message } })
|
|
581
|
+
else
|
|
582
|
+
@err.puts "asterism: #{path}.#{method}: #{e.remote_kind || 'error'}: #{e.message}"
|
|
583
|
+
end
|
|
584
|
+
code = 1
|
|
585
|
+
rescue ::Asterism::TimeoutError => e
|
|
586
|
+
@err.puts "asterism: #{e.message}" \
|
|
587
|
+
"#{"\n (with a console's .tar, the router refuses a call its role does not allow; " \
|
|
588
|
+
'it looks the same as no answer)' if @opts[:credentials]}"
|
|
589
|
+
code = 1
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
code
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# ------------------------------------------------------------ mcp
|
|
596
|
+
|
|
597
|
+
def cmd_mcp(_rest)
|
|
598
|
+
begin
|
|
599
|
+
require_relative "cli/mcp"
|
|
600
|
+
rescue LoadError => e
|
|
601
|
+
raise unless e.message.include?("mcp")
|
|
602
|
+
@err.puts "asterism mcp needs the mcp gem: gem install mcp (#{e.message})"
|
|
603
|
+
return 1
|
|
604
|
+
end
|
|
605
|
+
connect(app_prefix: "mcp") do |net, sv|
|
|
606
|
+
MCPBridge.new(net, sv, allow: @opts[:allow], log: @err).run
|
|
607
|
+
end
|
|
608
|
+
0
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# ------------------------------------------------------------ text
|
|
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
|
+
|
|
625
|
+
def profile_text(pr)
|
|
626
|
+
return "" unless pr
|
|
627
|
+
parts = [pr["kind"], pr["host"]].compact.join("/")
|
|
628
|
+
name = pr["name"] ? " \"#{pr['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 }
|
|
641
|
+
end
|
|
642
|
+
|
|
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 }
|
|
647
|
+
end
|
|
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
|
+
|
|
684
|
+
# name(arg, arg) with the names the meta tells, else name(2) / name(*).
|
|
685
|
+
def signature(m)
|
|
686
|
+
return "#{m['name']}(#{m['args'].join(', ')}#{', *' if m['arity'].negative?})" if m["args"]
|
|
687
|
+
m["arity"].negative? ? "#{m['name']}(*)" : "#{m['name']}(#{m['arity']})"
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
# A value as JSON can hold it (bytes as a note).
|
|
691
|
+
def json_safe(v)
|
|
692
|
+
case v
|
|
693
|
+
when Hash then v.to_h { |k, x| [k.to_s, json_safe(x)] }
|
|
694
|
+
when Array then v.map { json_safe(_1) }
|
|
695
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
696
|
+
else v
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
require_relative "cli/nouns"
|