aspera-cli 4.27.2 → 4.27.3
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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +50 -0
- data/bin/ascli +2 -1
- data/docs/README.md +804 -746
- data/lib/aspera/agent/connect.rb +6 -4
- data/lib/aspera/agent/desktop.rb +2 -2
- data/lib/aspera/agent/direct.rb +3 -1
- data/lib/aspera/agent/node.rb +3 -3
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +14 -12
- data/lib/aspera/api/ats.rb +1 -1
- data/lib/aspera/api/cos_node.rb +2 -2
- data/lib/aspera/api/faspex.rb +9 -7
- data/lib/aspera/api/httpgw.rb +37 -33
- data/lib/aspera/api/node.rb +38 -33
- data/lib/aspera/ascmd.rb +3 -1
- data/lib/aspera/ascp/installation.rb +62 -27
- data/lib/aspera/ascp/management.rb +1 -0
- data/lib/aspera/assert.rb +4 -0
- data/lib/aspera/cli/ascp_actions.rb +20 -41
- data/lib/aspera/cli/async_transfer_store.rb +2 -2
- data/lib/aspera/cli/bootstrapper.rb +11 -15
- data/lib/aspera/cli/command_line.rb +252 -0
- data/lib/aspera/cli/command_registry.rb +149 -33
- data/lib/aspera/cli/command_spec.rb +103 -14
- data/lib/aspera/cli/completion/ascli.bash +12 -0
- data/lib/aspera/cli/completion/ascli.fish +16 -0
- data/lib/aspera/cli/completion/ascli.zsh +19 -0
- data/lib/aspera/cli/context.rb +3 -0
- data/lib/aspera/cli/deprecation.rb +37 -0
- data/lib/aspera/cli/extended_value.rb +2 -0
- data/lib/aspera/cli/formatter.rb +87 -75
- data/lib/aspera/cli/gem_checker.rb +1 -1
- data/lib/aspera/cli/hints.rb +7 -6
- data/lib/aspera/cli/http.rb +21 -21
- data/lib/aspera/cli/info.rb +3 -0
- data/lib/aspera/cli/mcp_tool.rb +47 -83
- data/lib/aspera/cli/option_declarator.rb +33 -42
- data/lib/aspera/cli/option_registry.rb +69 -0
- data/lib/aspera/cli/option_types.rb +103 -0
- data/lib/aspera/cli/option_value.rb +281 -0
- data/lib/aspera/cli/options.schema.yaml +38 -5
- data/lib/aspera/cli/parser.rb +307 -848
- data/lib/aspera/cli/plugins/alee.rb +7 -4
- data/lib/aspera/cli/plugins/aoc.rb +430 -380
- data/lib/aspera/cli/plugins/ats.rb +58 -73
- data/lib/aspera/cli/plugins/base.rb +190 -240
- data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
- data/lib/aspera/cli/plugins/config.rb +244 -178
- data/lib/aspera/cli/plugins/console.rb +102 -38
- data/lib/aspera/cli/plugins/cos.rb +6 -23
- data/lib/aspera/cli/plugins/factory.rb +3 -0
- data/lib/aspera/cli/plugins/faspex5.rb +176 -173
- data/lib/aspera/cli/plugins/faspio.rb +5 -10
- data/lib/aspera/cli/plugins/httpgw.rb +8 -11
- data/lib/aspera/cli/plugins/mcp.rb +20 -55
- data/lib/aspera/cli/plugins/node.rb +277 -311
- data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
- data/lib/aspera/cli/plugins/preview.rb +79 -90
- data/lib/aspera/cli/plugins/server.rb +76 -50
- data/lib/aspera/cli/plugins/shares.rb +68 -116
- data/lib/aspera/cli/preset_actions.rb +17 -10
- data/lib/aspera/cli/preset_manager.rb +12 -2
- data/lib/aspera/cli/prompt.rb +35 -0
- data/lib/aspera/cli/result.rb +13 -18
- data/lib/aspera/cli/runner.rb +31 -54
- data/lib/aspera/cli/special_values.rb +5 -0
- data/lib/aspera/cli/sync_actions.rb +41 -37
- data/lib/aspera/cli/terminal_formatter.rb +9 -3
- data/lib/aspera/cli/transfer_actions.rb +0 -6
- data/lib/aspera/cli/transfer_agent.rb +29 -35
- data/lib/aspera/cli/vault_manager.rb +0 -17
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +4 -2
- data/lib/aspera/coverage.rb +1 -0
- data/lib/aspera/environment.rb +6 -0
- data/lib/aspera/faspex_gw.rb +2 -1
- data/lib/aspera/faspex_postproc.rb +1 -0
- data/lib/aspera/graphql.rb +5 -5
- data/lib/aspera/json_rpc/client.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +1 -1
- data/lib/aspera/keychain/one_password_api.rb +1 -1
- data/lib/aspera/link_header.rb +2 -2
- data/lib/aspera/log.rb +22 -25
- data/lib/aspera/markdown.rb +2 -0
- data/lib/aspera/mime.rb +25 -0
- data/lib/aspera/node_simulator.rb +1 -0
- data/lib/aspera/oauth/base.rb +35 -25
- data/lib/aspera/oauth/factory.rb +1 -0
- data/lib/aspera/oauth/generic.rb +1 -1
- data/lib/aspera/oauth/jwt.rb +1 -1
- data/lib/aspera/oauth/web.rb +9 -8
- data/lib/aspera/preview/file_types.rb +4 -4
- data/lib/aspera/preview/generator.rb +7 -0
- data/lib/aspera/preview/options.rb +4 -4
- data/lib/aspera/preview/terminal.rb +4 -3
- data/lib/aspera/preview/utils.rb +9 -6
- data/lib/aspera/products/connect.rb +1 -1
- data/lib/aspera/rainbow.rb +7 -0
- data/lib/aspera/rest/aspera_errors.rb +60 -0
- data/lib/aspera/rest/call_error.rb +27 -0
- data/lib/aspera/rest/client.rb +514 -0
- data/lib/aspera/rest/error_analyzer.rb +113 -0
- data/lib/aspera/rest/list.rb +143 -0
- data/lib/aspera/rest/parameters.rb +55 -0
- data/lib/aspera/rest/util.rb +176 -0
- data/lib/aspera/rest.rb +7 -621
- data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
- data/lib/aspera/schema/documentation.rb +13 -3
- data/lib/aspera/schema/registry.rb +18 -1
- data/lib/aspera/schema/validator.rb +92 -0
- data/lib/aspera/secret_hider.rb +36 -25
- data/lib/aspera/string_ext.rb +15 -0
- data/lib/aspera/temp_file_manager.rb +6 -5
- data/lib/aspera/transfer/parameters.rb +2 -0
- data/lib/aspera/transfer/spec.rb +1 -0
- data/lib/aspera/uri_reader.rb +11 -11
- data/lib/aspera/web_auth/index.html +147 -0
- data/lib/aspera/web_auth/server.rb +81 -0
- data.tar.gz.sig +0 -0
- metadata +39 -7
- metadata.gz.sig +0 -0
- data/lib/aspera/colors.rb +0 -79
- data/lib/aspera/rest_call_error.rb +0 -25
- data/lib/aspera/rest_error_analyzer.rb +0 -111
- data/lib/aspera/rest_errors_aspera.rb +0 -58
- data/lib/aspera/rest_list.rb +0 -136
- data/lib/aspera/web_auth.rb +0 -211
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/cli/error'
|
|
4
|
+
require 'aspera/dot_container'
|
|
5
|
+
require 'aspera/log'
|
|
6
|
+
require 'aspera/assert'
|
|
7
|
+
|
|
8
|
+
module Aspera
|
|
9
|
+
module Cli
|
|
10
|
+
# Positional (non-option) command line token.
|
|
11
|
+
class Argument
|
|
12
|
+
# @return [String] the raw argument value
|
|
13
|
+
attr_reader :value
|
|
14
|
+
# @return [Option, nil] option token claiming this token as its value (`--opt value` form)
|
|
15
|
+
attr_accessor :owner
|
|
16
|
+
# @return [Boolean] `true` once used, as positional argument or as option value
|
|
17
|
+
attr_accessor :consumed
|
|
18
|
+
|
|
19
|
+
def initialize(value)
|
|
20
|
+
@value = value
|
|
21
|
+
@owner = nil
|
|
22
|
+
@consumed = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Boolean] `true` if available as positional argument
|
|
26
|
+
def positional? = !@consumed && @owner.nil?
|
|
27
|
+
|
|
28
|
+
# @return [String] raw argument value
|
|
29
|
+
def to_s = @value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Option command line token (long or short form).
|
|
33
|
+
class Option
|
|
34
|
+
# @return [String] raw token as it appeared in argv (e.g. "--log-level=debug", "-Pval")
|
|
35
|
+
attr_reader :raw
|
|
36
|
+
# @return [String, nil] option name with underscores (e.g. "log_level"), nil for short options
|
|
37
|
+
attr_reader :name
|
|
38
|
+
# @return [String, nil] single-char short option letter (e.g. "P"), nil for long options
|
|
39
|
+
attr_reader :short_char
|
|
40
|
+
# @return [Array<String>, nil] sub-keys for dot-path notation (e.g. ["field"] for --custom.field)
|
|
41
|
+
attr_reader :dot_path
|
|
42
|
+
# @return [String, nil] value given in the same token (`--opt=val` or `-Oval`)
|
|
43
|
+
attr_reader :inline_value
|
|
44
|
+
# @return [Argument, nil] next token, claimed as value when there is no inline value (`--opt val` or `-O val`)
|
|
45
|
+
attr_accessor :value_token
|
|
46
|
+
# @return [Boolean] `true` once applied to a declared option
|
|
47
|
+
attr_accessor :consumed
|
|
48
|
+
# @return [Symbol, nil] option this token was resolved to, when `name` is an abbreviation
|
|
49
|
+
attr_accessor :abbreviation_of
|
|
50
|
+
|
|
51
|
+
def initialize(raw:, name: nil, short_char: nil, dot_path: nil, inline_value: nil)
|
|
52
|
+
@raw = raw
|
|
53
|
+
@name = name
|
|
54
|
+
@short_char = short_char
|
|
55
|
+
@dot_path = dot_path
|
|
56
|
+
@inline_value = inline_value
|
|
57
|
+
@value_token = nil
|
|
58
|
+
@consumed = false
|
|
59
|
+
@abbreviation_of = nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @return [Boolean] `true` if value is in the same token
|
|
63
|
+
def inline? = !@inline_value.nil?
|
|
64
|
+
|
|
65
|
+
# @return [String, nil] inline value, or value of next token
|
|
66
|
+
def value = @inline_value || @value_token&.value
|
|
67
|
+
|
|
68
|
+
# @return [String] raw token, followed by its separate value if any
|
|
69
|
+
def to_s = @value_token.nil? ? @raw : "#{@raw} #{@value_token.value}"
|
|
70
|
+
|
|
71
|
+
class << self
|
|
72
|
+
# @param token [String] command line token
|
|
73
|
+
# @return [Boolean] `true` if token is an option, i.e. not `-`, `--`, or a negative number
|
|
74
|
+
def option?(token)
|
|
75
|
+
token.match?(/\A-\D/) && !token.eql?(STOP)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Build an Option from a raw token
|
|
79
|
+
# @param raw [String] e.g. "--log-level=debug", "--custom.field", "-P", "-Pval"
|
|
80
|
+
# @return [Option]
|
|
81
|
+
def parse(raw)
|
|
82
|
+
if raw.start_with?(PREFIX)
|
|
83
|
+
name_raw, value = raw.delete_prefix(PREFIX).split(VALUE_SEP, 2)
|
|
84
|
+
root, *dot_path = name_raw.to_s.split(DotContainer::SEPARATOR)
|
|
85
|
+
new(raw: raw, name: root.to_s.gsub(NAME_SEP_LINE, NAME_SEP_SYMBOL), dot_path: dot_path.empty? ? nil : dot_path, inline_value: value)
|
|
86
|
+
else
|
|
87
|
+
new(raw: raw, short_char: raw[1], inline_value: raw.length > 2 ? raw[2..] : nil)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Option name separator on command line (e.g. `--option-name`, the `-` between words)
|
|
93
|
+
NAME_SEP_LINE = '-'
|
|
94
|
+
# Option name separator in code/symbol (e.g. `:option_name`, the `_` between words)
|
|
95
|
+
NAME_SEP_SYMBOL = '_'
|
|
96
|
+
# Separator between option name and its inline value (e.g. `--opt=val`, the `=`)
|
|
97
|
+
VALUE_SEP = '='
|
|
98
|
+
# Long-option prefix (e.g. `--opt`)
|
|
99
|
+
PREFIX = '--'
|
|
100
|
+
# When alone, stops option processing: following tokens are positional arguments
|
|
101
|
+
STOP = '--'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Command line split in tokens, in original order.
|
|
105
|
+
# Tokens are never removed, only marked as consumed, so that positions are kept.
|
|
106
|
+
#
|
|
107
|
+
# An option without inline value claims the next token as its value (`--opt val`, `-O val`),
|
|
108
|
+
# unless that token looks like an option.
|
|
109
|
+
# If the option turns out to be a flag, the claimed token is given back to positional arguments.
|
|
110
|
+
class CommandLine
|
|
111
|
+
# @param argv [Array<String>] command line arguments
|
|
112
|
+
def initialize(argv)
|
|
113
|
+
# @type [Array<Option, Argument>]
|
|
114
|
+
@tokens = []
|
|
115
|
+
# Option token being applied (used by `@:` extended value)
|
|
116
|
+
@current_option = nil
|
|
117
|
+
# When set, only positional arguments after this token are available
|
|
118
|
+
@arguments_after = nil
|
|
119
|
+
process_options = true
|
|
120
|
+
argv.each do |value|
|
|
121
|
+
if process_options && value.eql?(Option::STOP)
|
|
122
|
+
process_options = false
|
|
123
|
+
elsif process_options && Option.option?(value)
|
|
124
|
+
@tokens.push(Option.parse(value))
|
|
125
|
+
else
|
|
126
|
+
argument = Argument.new(value)
|
|
127
|
+
previous = @tokens.last
|
|
128
|
+
if process_options && previous.is_a?(Option) && !previous.inline? && previous.value_token.nil?
|
|
129
|
+
previous.value_token = argument
|
|
130
|
+
argument.owner = previous
|
|
131
|
+
end
|
|
132
|
+
@tokens.push(argument)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
Log.log.trace1 { "arguments=#{pending_arguments},options=#{pending_options}" }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Array<Option>] option tokens whose name was resolved as an abbreviation
|
|
139
|
+
def abbreviated_option_tokens
|
|
140
|
+
@tokens.select { |t| t.is_a?(Option) && !t.abbreviation_of.nil? }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @return [Array<Option>] option tokens not applied yet
|
|
144
|
+
def pending_option_tokens
|
|
145
|
+
@tokens.select { |t| t.is_a?(Option) && !t.consumed }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Mark option token as applied, and get its value.
|
|
149
|
+
# @param tok [Option] option token
|
|
150
|
+
# @param takes_value [Boolean] `false` for flags: claimed token is given back to positional arguments
|
|
151
|
+
# @return [String, nil] value, or `nil` for flags
|
|
152
|
+
# @raise [BadArgument] if option takes a value and none was given
|
|
153
|
+
def consume(tok, takes_value:)
|
|
154
|
+
tok.consumed = true
|
|
155
|
+
return release(tok) unless takes_value
|
|
156
|
+
return tok.inline_value if tok.inline?
|
|
157
|
+
Aspera.assert(!tok.value_token.nil?, type: BadArgument) { "Option #{tok.raw} requires a value" }
|
|
158
|
+
tok.value_token.consumed = true
|
|
159
|
+
tok.value_token.value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Execute block with `tok` as current option
|
|
163
|
+
# @param tok [Option] option token being applied
|
|
164
|
+
def with_current_option(tok)
|
|
165
|
+
@current_option = tok
|
|
166
|
+
yield
|
|
167
|
+
ensure
|
|
168
|
+
@current_option = nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Execute block with only positional arguments after current option available, if any
|
|
172
|
+
def with_arguments_after_current_option
|
|
173
|
+
@arguments_after = @current_option
|
|
174
|
+
yield
|
|
175
|
+
ensure
|
|
176
|
+
@arguments_after = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# @return [Array<String>] values of available positional arguments
|
|
180
|
+
def pending_arguments
|
|
181
|
+
positional_tokens.map(&:value)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# @return [Array<String>] options not applied yet, with their value if separate
|
|
185
|
+
def pending_options
|
|
186
|
+
pending_option_tokens.map(&:to_s)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Consume positional arguments.
|
|
190
|
+
# @param multiple [false, true, String] consumption mode:
|
|
191
|
+
# false — consume exactly one token
|
|
192
|
+
# true — consume all remaining tokens
|
|
193
|
+
# String — consume up to the marker token (marker is consumed too, not returned), or all if absent
|
|
194
|
+
# @return [Array<String>] consumed values
|
|
195
|
+
def shift_arguments(multiple)
|
|
196
|
+
arg_tokens = positional_tokens
|
|
197
|
+
selected =
|
|
198
|
+
case multiple
|
|
199
|
+
when false then arg_tokens.first(1)
|
|
200
|
+
when true then arg_tokens
|
|
201
|
+
when String
|
|
202
|
+
index = arg_tokens.index { |t| t.value.eql?(multiple) }
|
|
203
|
+
arg_tokens[index].consumed = true unless index.nil?
|
|
204
|
+
arg_tokens.take(index || arg_tokens.length)
|
|
205
|
+
else Aspera.error_unexpected_value(multiple) { 'multiple' }
|
|
206
|
+
end
|
|
207
|
+
selected.each { |t| t.consumed = true }
|
|
208
|
+
selected.map(&:value)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Add an argument before the available positional arguments
|
|
212
|
+
# @param value [String] argument value
|
|
213
|
+
def unshift_argument(value)
|
|
214
|
+
index = @tokens.index { |t| t.is_a?(Argument) && t.positional? } || @tokens.length
|
|
215
|
+
@tokens.insert(index, Argument.new(value))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Consume all long options with a value, whether applied or not.
|
|
219
|
+
# @yieldparam tok [Option] long option token with a value
|
|
220
|
+
def each_long_option_with_value
|
|
221
|
+
@tokens.each do |tok|
|
|
222
|
+
next unless tok.is_a?(Option) && tok.short_char.nil? && !tok.value.nil?
|
|
223
|
+
yield(tok)
|
|
224
|
+
tok.consumed = true
|
|
225
|
+
tok.value_token&.consumed = true
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
# Give back claimed token to positional arguments.
|
|
232
|
+
# @param tok [Option] flag option token
|
|
233
|
+
# @return [nil]
|
|
234
|
+
def release(tok)
|
|
235
|
+
value_token = tok.value_token
|
|
236
|
+
return if value_token.nil?
|
|
237
|
+
index = @tokens.index { |t| t.equal?(value_token) }
|
|
238
|
+
Aspera.assert(@tokens[index + 1..].none? { |t| t.is_a?(Argument) && t.consumed && t.owner.nil? }) do
|
|
239
|
+
"Flag #{tok.raw} declared after following positional arguments were used"
|
|
240
|
+
end
|
|
241
|
+
value_token.owner = nil
|
|
242
|
+
tok.value_token = nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# @return [Array<Argument>] available positional arguments, in order
|
|
246
|
+
def positional_tokens
|
|
247
|
+
start = @arguments_after.nil? ? 0 : @tokens.index { |t| t.equal?(@arguments_after) } + 1
|
|
248
|
+
@tokens[start..].select { |t| t.is_a?(Argument) && t.positional? }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
@@ -11,16 +11,81 @@ module Aspera
|
|
|
11
11
|
# register(spec) - store a CommandSpec; raises on duplicate full_path
|
|
12
12
|
# register_option(spec) - store an OptionSpec by name
|
|
13
13
|
# option_specs - Hash{Symbol => OptionSpec} of all registered options
|
|
14
|
-
# [](path) - retrieve a CommandSpec by full path
|
|
15
|
-
# children_of(path) - Hash{Symbol => CommandSpec} of direct children (
|
|
16
|
-
#
|
|
14
|
+
# [](path) - retrieve a CommandSpec by full path (follows mounts)
|
|
15
|
+
# children_of(path) - Hash{Symbol => CommandSpec} of direct children (follows mounts)
|
|
16
|
+
# resolve(path) - [registry, path] owning the spec at path (follows mounts)
|
|
17
|
+
# local?(path) - true if path is owned by this registry (not reached through a mount)
|
|
18
|
+
# mount_of(path) - MountSpec of the local node at path, if any
|
|
19
|
+
# mount_at(path) - MountSpec of the node at path, if any (follows mounts)
|
|
20
|
+
# own_children_of(path) - Hash{Symbol => CommandSpec} of direct children declared on the node, without mounted ones
|
|
21
|
+
# arguments_at(path) - Array of ArgumentSpec read by the node at path (mount arguments first)
|
|
22
|
+
# leaf_paths - Array of all leaf paths (follows mounts, or stops at mount nodes)
|
|
23
|
+
# all_paths - Array of all locally registered full paths
|
|
17
24
|
# any? - true if at least one spec has been registered
|
|
18
25
|
# validate! - cross-spec consistency checks; raises on violation
|
|
26
|
+
#
|
|
27
|
+
# Paths are always expressed in this registry's namespace: a path going through a
|
|
28
|
+
# mounted node (see MountSpec) is translated to the target registry transparently.
|
|
29
|
+
# Specs returned for mounted paths are the target's specs (their full_path is in the
|
|
30
|
+
# target namespace).
|
|
19
31
|
class CommandRegistry
|
|
20
32
|
# @param path [Array<Symbol>] full path to look up
|
|
21
33
|
# @return [CommandSpec, nil]
|
|
22
34
|
def [](path)
|
|
23
|
-
|
|
35
|
+
registry, local_path = resolve(path)
|
|
36
|
+
registry.equal?(self) ? @specs[local_path] : registry[local_path]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Find the registry owning `path`, following mounts.
|
|
40
|
+
# A host child always takes precedence over a mounted child with the same id.
|
|
41
|
+
# @param path [Array<Symbol>] path in this registry's namespace
|
|
42
|
+
# @return [Array(CommandRegistry, Array<Symbol>)] owning registry and path in its namespace
|
|
43
|
+
def resolve(path)
|
|
44
|
+
path = Array(path)
|
|
45
|
+
path.each_index do |i|
|
|
46
|
+
prefix = path[0, i]
|
|
47
|
+
mount = @specs[prefix]&.mount
|
|
48
|
+
next if mount.nil? || @children_index[prefix]&.key?(path[i]) || !mount.accepts?(path[i])
|
|
49
|
+
return mount.registry.resolve(mount.at + path[i..])
|
|
50
|
+
end
|
|
51
|
+
[self, path]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param path [Array<Symbol>] path in this registry's namespace
|
|
55
|
+
# @return [Boolean] true if the node at path is declared in this registry (not mounted)
|
|
56
|
+
def local?(path)
|
|
57
|
+
resolve(path).first.equal?(self)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param path [Array<Symbol>] local path of a node
|
|
61
|
+
# @return [MountSpec, nil] the mount declared on that node
|
|
62
|
+
def mount_of(path)
|
|
63
|
+
@specs[Array(path)]&.mount
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @param path [Array<Symbol>] path in this registry's namespace
|
|
67
|
+
# @return [MountSpec, nil] the mount declared on the node at path, following mounts
|
|
68
|
+
def mount_at(path)
|
|
69
|
+
registry, local_path = resolve(path)
|
|
70
|
+
registry.mount_of(local_path)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Children declared on the node itself, without the ones exposed by its mount.
|
|
74
|
+
# @param path [Array<Symbol>] path in this registry's namespace
|
|
75
|
+
# @return [Hash{Symbol => CommandSpec}]
|
|
76
|
+
def own_children_of(path)
|
|
77
|
+
registry, local_path = resolve(path)
|
|
78
|
+
return registry.own_children_of(local_path) unless registry.equal?(self)
|
|
79
|
+
@children_index[local_path] || {}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Arguments read by the node at path, in order.
|
|
83
|
+
# For a child exposed by a mount, the mount's arguments come first (read by the host).
|
|
84
|
+
# @param path [Array<Symbol>] path in this registry's namespace
|
|
85
|
+
# @return [Array<ArgumentSpec>]
|
|
86
|
+
def arguments_at(path)
|
|
87
|
+
path = Array(path)
|
|
88
|
+
(path.empty? ? [] : mount_arguments(path)) + (self[path]&.arguments || [])
|
|
24
89
|
end
|
|
25
90
|
|
|
26
91
|
# Register a CommandSpec. Raises if the full_path is already registered.
|
|
@@ -40,14 +105,40 @@ module Aspera
|
|
|
40
105
|
|
|
41
106
|
# Returns a Hash mapping each child id to its CommandSpec for all direct
|
|
42
107
|
# children of `path`. Empty hash if no children are registered.
|
|
43
|
-
#
|
|
108
|
+
# For a mount node: mounted children (filtered) followed by local children,
|
|
109
|
+
# local ones overriding mounted ones with the same id.
|
|
44
110
|
# @param path [Array<Symbol>] parent path ([] for root-level commands)
|
|
45
111
|
# @return [Hash{Symbol => CommandSpec}]
|
|
46
112
|
def children_of(path)
|
|
47
|
-
|
|
113
|
+
registry, local_path = resolve(path)
|
|
114
|
+
return registry.children_of(local_path) unless registry.equal?(self)
|
|
115
|
+
own = @children_index[local_path] || {}
|
|
116
|
+
mount = @specs[local_path]&.mount
|
|
117
|
+
return own if mount.nil?
|
|
118
|
+
mount.registry.children_of(mount.at).select { |id, _| mount.accepts?(id) }.merge(own)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# All leaf paths under path, in tree order, following mounts.
|
|
122
|
+
# A mount cycle (a sub-tree mounting one of its ancestors) is not expanded twice.
|
|
123
|
+
# @param path [Array<Symbol>] root of the listing ([] for all)
|
|
124
|
+
# @param expand_mounts [Boolean] `false`: a mount node is listed as a leaf, followed by its own children only
|
|
125
|
+
# @return [Array<Array<Symbol>>]
|
|
126
|
+
def leaf_paths(path = [], chain = [], expand_mounts: true)
|
|
127
|
+
children_of(path).keys.flat_map do |id|
|
|
128
|
+
child = path + [id]
|
|
129
|
+
key = subtree_key(child)
|
|
130
|
+
next [] if chain.include?(key)
|
|
131
|
+
if !expand_mounts && mount_at(child)
|
|
132
|
+
next [child] + own_children_of(child).keys.flat_map do |own_id|
|
|
133
|
+
own_child = child + [own_id]
|
|
134
|
+
children_of(own_child).empty? ? [own_child] : leaf_paths(own_child, chain + [key], expand_mounts: false)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
children_of(child).empty? ? [child] : leaf_paths(child, chain + [key], expand_mounts: expand_mounts)
|
|
138
|
+
end
|
|
48
139
|
end
|
|
49
140
|
|
|
50
|
-
# @return [Array<Array<Symbol>>] all registered full paths
|
|
141
|
+
# @return [Array<Array<Symbol>>] all locally registered full paths
|
|
51
142
|
def all_paths
|
|
52
143
|
@specs.keys
|
|
53
144
|
end
|
|
@@ -95,42 +186,67 @@ module Aspera
|
|
|
95
186
|
@specs.each_value do |spec|
|
|
96
187
|
path = spec.full_path
|
|
97
188
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
189
|
+
if (mount = spec.mount)
|
|
190
|
+
# Rule: a mount needs an instance method, no action, and must point to existing target nodes
|
|
191
|
+
raise ArgumentError, "#{path.inspect}: mount requires instance:" if mount.instance.nil?
|
|
192
|
+
# Mount arguments precede the arguments of the mounted command: they cannot be optional
|
|
193
|
+
raise ArgumentError, "#{path.inspect}: mount arguments must be mandatory" unless mount.arguments.all?(&:mandatory)
|
|
194
|
+
raise ArgumentError, "#{path.inspect}: mount and action: are exclusive" if spec.action
|
|
195
|
+
raise ArgumentError, "#{path.inspect}: mount at #{mount.at.inspect} not found in #{mount.plugin}" unless mount.at.empty? || mount.registry[mount.at]
|
|
196
|
+
target_ids = mount.registry.children_of(mount.at).keys
|
|
197
|
+
unknown = Array(mount.only) + Array(mount.except) - target_ids
|
|
198
|
+
raise ArgumentError, "#{path.inspect}: mount only/except unknown in #{mount.plugin}: #{unknown.inspect}" unless unknown.empty?
|
|
199
|
+
instance_defined = plugin_class.nil? || instance_method?(plugin_class, mount.instance)
|
|
200
|
+
raise ArgumentError, "#{path.inspect}: no method #{mount.instance} on #{plugin_class}" unless instance_defined
|
|
201
|
+
next
|
|
110
202
|
end
|
|
111
203
|
|
|
112
|
-
# Rule: delegate_instance requires delegates_to
|
|
113
|
-
if spec.delegate_instance && spec.delegates_to.nil?
|
|
114
|
-
raise ArgumentError,
|
|
115
|
-
"#{path.inspect}: delegate_instance requires delegates_to to be set"
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Rule: leaf commands with no explicit action must have a matching instance method
|
|
119
|
-
next if spec.action # explicit action: skip
|
|
120
204
|
next if @children_index[path]&.any? # intermediate node: skip
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
205
|
+
action = spec.action
|
|
206
|
+
if action.nil?
|
|
207
|
+
next unless plugin_class
|
|
208
|
+
# Rule: leaf commands with no explicit action must have a matching instance method
|
|
209
|
+
action = CommandSpec.action_method(path)
|
|
210
|
+
unless instance_method?(plugin_class, action)
|
|
211
|
+
raise ArgumentError,
|
|
212
|
+
"#{path.inspect}: no action: and no method #{action} on #{plugin_class}"
|
|
213
|
+
end
|
|
127
214
|
end
|
|
215
|
+
action = plugin_class.instance_method(action) if action.is_a?(Symbol) && plugin_class && instance_method?(plugin_class, action)
|
|
216
|
+
# Rule: the action receives the whole dispatch context as keywords (setup results, arguments),
|
|
217
|
+
# so it must accept any keyword (`**`): a lambda or method with fixed arity would raise ArgumentError.
|
|
218
|
+
# A non-lambda Proc ignores extra keywords.
|
|
219
|
+
next if action.is_a?(Symbol) || (action.is_a?(Proc) && !action.lambda?)
|
|
220
|
+
raise ArgumentError, "#{path.inspect}: action must accept any keyword (**), parameters: #{action.parameters.inspect}" unless action.parameters.any? { |kind, _| kind.eql?(:keyrest) }
|
|
128
221
|
end
|
|
129
222
|
self
|
|
130
223
|
end
|
|
131
224
|
|
|
132
225
|
private
|
|
133
226
|
|
|
227
|
+
# @return [Boolean] true if plugin_class defines instance method name (public or private)
|
|
228
|
+
def instance_method?(plugin_class, name)
|
|
229
|
+
plugin_class.method_defined?(name) || plugin_class.private_method_defined?(name)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @param path [Array<Symbol>] non-empty path in this registry's namespace
|
|
233
|
+
# @return [Array<ArgumentSpec>] arguments of the mount exposing the last segment of path, if any
|
|
234
|
+
def mount_arguments(path)
|
|
235
|
+
registry, parent = resolve(path[0..-2])
|
|
236
|
+
return registry.send(:mount_arguments, parent + [path.last]) unless registry.equal?(self)
|
|
237
|
+
mount = @specs[parent]&.mount
|
|
238
|
+
return [] if mount.nil? || @children_index[parent]&.key?(path.last) || !mount.accepts?(path.last)
|
|
239
|
+
mount.arguments
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Identity of the sub-tree exposed at path: the mount point for a mount node, else the owning node.
|
|
243
|
+
# @return [Array(Integer, Array<Symbol>)]
|
|
244
|
+
def subtree_key(path)
|
|
245
|
+
registry, local_path = resolve(path)
|
|
246
|
+
mount = registry.mount_of(local_path)
|
|
247
|
+
mount ? [mount.registry.object_id, mount.at] : [registry.object_id, local_path]
|
|
248
|
+
end
|
|
249
|
+
|
|
134
250
|
def initialize
|
|
135
251
|
# Keyed by Array<Symbol> full path
|
|
136
252
|
@specs = {}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'aspera/schema/registry'
|
|
4
|
+
require 'aspera/cli/option_types'
|
|
4
5
|
|
|
5
6
|
module Aspera
|
|
6
7
|
module Cli
|
|
@@ -10,7 +11,8 @@ module Aspera
|
|
|
10
11
|
#
|
|
11
12
|
# @!attribute name [Symbol] Name used in help and error messages
|
|
12
13
|
# @!attribute description [String] User-facing description
|
|
13
|
-
# @!attribute type [Class, Array<Class>, :identifier] Validated type; :identifier triggers instance_identifier
|
|
14
|
+
# @!attribute type [Class, Array<Class>, :identifier, nil] Validated type; :identifier triggers instance_identifier.
|
|
15
|
+
# Default: String (unless allowed:); explicit nil accepts any value
|
|
14
16
|
# @!attribute mandatory [Boolean] Default true; optional args must come after all mandatory ones
|
|
15
17
|
# @!attribute multiple [Boolean, String] true: consume all remaining; String: consume until named marker
|
|
16
18
|
# @!attribute default [Object, nil] Default value when mandatory: false and no argument provided
|
|
@@ -41,8 +43,20 @@ module Aspera
|
|
|
41
43
|
kwargs[:multiple] = false if kwargs[:multiple].nil?
|
|
42
44
|
kwargs[:bulk] = false if kwargs[:bulk].nil?
|
|
43
45
|
kwargs[:interactive] = false if kwargs[:interactive].nil?
|
|
46
|
+
kwargs[:type] = String unless kwargs.key?(:type) || kwargs[:allowed]
|
|
44
47
|
super
|
|
45
48
|
end
|
|
49
|
+
|
|
50
|
+
# Syntax of argument for help, e.g. `<name>`, `[<name>]`, `<paths...>`, `<account:Hash>`.
|
|
51
|
+
# Type is shown only when the argument is not free text.
|
|
52
|
+
# @return [String]
|
|
53
|
+
def syntax
|
|
54
|
+
token = allowed ? allowed.join('|') : name.to_s
|
|
55
|
+
token += '...' if multiple
|
|
56
|
+
types = Array(type).grep(Class)
|
|
57
|
+
token += ":#{types.map(&:name).join('|')}" unless allowed || types.empty? || types.include?(String)
|
|
58
|
+
mandatory ? "<#{token}>" : "[<#{token}>]"
|
|
59
|
+
end
|
|
46
60
|
end
|
|
47
61
|
|
|
48
62
|
# Declares an option referenced by name from command declarations.
|
|
@@ -54,11 +68,14 @@ module Aspera
|
|
|
54
68
|
# @!attribute allowed [Array, nil] Allowed values (forwarded to options.declare)
|
|
55
69
|
# @!attribute default [Object, nil] Default value
|
|
56
70
|
# @!attribute short [String, nil] Single-character short form (e.g. 'x')
|
|
57
|
-
# @!attribute
|
|
58
|
-
#
|
|
59
|
-
# -
|
|
60
|
-
# -
|
|
61
|
-
#
|
|
71
|
+
# @!attribute on_set [Symbol, Proc, #call, nil] Called with the new value each time the value is set
|
|
72
|
+
# (for a flag: called without argument when the flag is found)
|
|
73
|
+
# - Symbol: method of the plugin instance
|
|
74
|
+
# - Proc: executed with instance_exec on the plugin instance
|
|
75
|
+
# - other: object responding to `call`, e.g. `Log.instance.method(:level=)`
|
|
76
|
+
# - nil: no callback, the value is read with `get_option`
|
|
77
|
+
# @!attribute shorthand [String, nil] For a `Hash` option: a `String` value is stored as `{shorthand => value}`
|
|
78
|
+
# @!attribute deprecation [Hash, nil] Forwarded to options.declare as deprecation: `{last:, message:}`
|
|
62
79
|
# @!attribute schema [String, nil] JSON schema name; also derives description when nil
|
|
63
80
|
OptionSpec = Struct.new(
|
|
64
81
|
:name,
|
|
@@ -66,43 +83,114 @@ module Aspera
|
|
|
66
83
|
:allowed,
|
|
67
84
|
:default,
|
|
68
85
|
:short,
|
|
69
|
-
:
|
|
86
|
+
:on_set,
|
|
87
|
+
:shorthand,
|
|
70
88
|
:deprecation,
|
|
71
89
|
:schema,
|
|
72
90
|
keyword_init: true
|
|
73
|
-
)
|
|
91
|
+
) do
|
|
92
|
+
# Declare this option on a parser, resolving the `on_set` callback.
|
|
93
|
+
# @param parser [Parser] Parser to declare the option on
|
|
94
|
+
# @param target [Object, nil] Object for Symbol and Proc `on_set` callbacks (plugin instance); nil: such callbacks are not bound
|
|
95
|
+
# @return [void]
|
|
96
|
+
def declare_on(parser, target: nil)
|
|
97
|
+
parser.declare(
|
|
98
|
+
name,
|
|
99
|
+
description: description,
|
|
100
|
+
short: short,
|
|
101
|
+
allowed: allowed,
|
|
102
|
+
default: default,
|
|
103
|
+
on_set: resolved_on_set(target),
|
|
104
|
+
shorthand: shorthand,
|
|
105
|
+
deprecation: deprecation,
|
|
106
|
+
schema: schema
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# @param target [Object, nil] Object for Symbol and Proc `on_set` callbacks
|
|
113
|
+
# @return [#call, nil] `on_set` callback for `Parser#declare`
|
|
114
|
+
def resolved_on_set(target)
|
|
115
|
+
case on_set
|
|
116
|
+
when Symbol then target&.method(on_set)
|
|
117
|
+
when Proc
|
|
118
|
+
return if target.nil?
|
|
119
|
+
proc_on_set = on_set
|
|
120
|
+
->(*value) { target.instance_exec(*value, &proc_on_set) }
|
|
121
|
+
else on_set
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Declares that a command node exposes a sub-tree of another plugin class.
|
|
127
|
+
# The mounted children appear in the host registry (dispatch, --help, completion)
|
|
128
|
+
# as if they were declared by the host; host children with the same id take precedence.
|
|
129
|
+
#
|
|
130
|
+
# @!attribute plugin [Class] Target plugin class (subclass of Plugins::Base)
|
|
131
|
+
# @!attribute at [Array<Symbol>] Path in the target registry whose children are mounted ([] = root)
|
|
132
|
+
# @!attribute instance [Symbol] Host instance method called with **ctx, returning the target plugin
|
|
133
|
+
# instance, or [instance, ctx] where ctx seeds the target dispatch
|
|
134
|
+
# (it replaces what the setups of `at` and its ancestors would provide)
|
|
135
|
+
# @!attribute only [Array<Symbol>, nil] Restrict mounted children to these ids
|
|
136
|
+
# @!attribute except [Array<Symbol>, nil] Exclude these ids from mounted children
|
|
137
|
+
# @!attribute arguments [Array<ArgumentSpec>] Arguments read by the host after the mounted command, before its own
|
|
138
|
+
# arguments; resolved values are passed to `instance`
|
|
139
|
+
# (e.g. `packages ls <package_id> <path>`)
|
|
140
|
+
MountSpec = Struct.new(
|
|
141
|
+
:plugin,
|
|
142
|
+
:at,
|
|
143
|
+
:instance,
|
|
144
|
+
:only,
|
|
145
|
+
:except,
|
|
146
|
+
:arguments,
|
|
147
|
+
keyword_init: true
|
|
148
|
+
) do
|
|
149
|
+
def initialize(**kwargs)
|
|
150
|
+
kwargs[:at] = Array(kwargs[:at]).freeze
|
|
151
|
+
kwargs[:arguments] = Array(kwargs[:arguments]).map { |a| a.is_a?(Hash) ? ArgumentSpec.new(**a) : a }.freeze
|
|
152
|
+
super
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [CommandRegistry] registry of the target plugin class
|
|
156
|
+
def registry
|
|
157
|
+
plugin.command_registry
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @param id [Symbol] id of a child of `at` in the target registry
|
|
161
|
+
# @return [Boolean] true if this child is exposed by the mount
|
|
162
|
+
def accepts?(id)
|
|
163
|
+
(only.nil? || only.include?(id)) && !except&.include?(id)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
74
166
|
|
|
75
167
|
# Declares a single command node in the flat registry.
|
|
76
168
|
#
|
|
77
169
|
# @!attribute id [Symbol] Unique identifier within its parent's namespace
|
|
78
170
|
# @!attribute parent [Symbol, Array<Symbol>, nil] Full path to parent; nil for root commands
|
|
79
171
|
# @!attribute description [String] User-facing help text
|
|
80
|
-
# @!attribute options [Array<Symbol>] Option names consumed by this command
|
|
81
172
|
# @!attribute arguments [Array<ArgumentSpec>] Positional arguments, in order.
|
|
82
173
|
# The first ArgumentSpec with type: :identifier is treated as the instance
|
|
83
174
|
# identifier for intermediate nodes (consumed in Phase A) and leaf nodes.
|
|
84
175
|
# @!attribute action [Symbol, Proc, nil] Instance method (Symbol) or inline block (Proc) called when this is a leaf command
|
|
85
176
|
# @!attribute setup [Symbol, nil] Instance method called before dispatching to children; returns Hash merged into ctx
|
|
86
|
-
# @!attribute delegates_to [Symbol, Array<Symbol>, nil] Re-enter the command tree at this path
|
|
87
|
-
# @!attribute delegate_instance [Symbol, nil] Instance method returning a different plugin object
|
|
88
177
|
# @!attribute aliases [Array<Symbol>, nil] Alternative names accepted for this command (each resolves to this command's id)
|
|
89
178
|
# @!attribute transfer_paths [:send, :receive, nil] File-list resolution delegated to TransferAgent; mutually exclusive with arguments
|
|
90
179
|
# @!attribute condition [Symbol, nil] Instance method returning Boolean; if false command is hidden from dispatch
|
|
91
180
|
# @!attribute query_schema [String, nil] Schema path for --query help; when set, the runner hints `--query=help`
|
|
181
|
+
# @!attribute mount [MountSpec, Hash, nil] Expose children of another plugin's registry under this node
|
|
92
182
|
CommandSpec = Struct.new(
|
|
93
183
|
:id,
|
|
94
184
|
:parent,
|
|
95
185
|
:description,
|
|
96
|
-
:options,
|
|
97
186
|
:arguments,
|
|
98
187
|
:action,
|
|
99
188
|
:setup,
|
|
100
|
-
:delegates_to,
|
|
101
|
-
:delegate_instance,
|
|
102
189
|
:aliases,
|
|
103
190
|
:transfer_paths,
|
|
104
191
|
:condition,
|
|
105
192
|
:query_schema,
|
|
193
|
+
:mount,
|
|
106
194
|
keyword_init: true
|
|
107
195
|
) do
|
|
108
196
|
def initialize(**kwargs)
|
|
@@ -112,6 +200,7 @@ module Aspera
|
|
|
112
200
|
a.is_a?(Hash) ? ArgumentSpec.new(**a) : a
|
|
113
201
|
end
|
|
114
202
|
end
|
|
203
|
+
kwargs[:mount] = MountSpec.new(**kwargs[:mount]) if kwargs[:mount].is_a?(Hash)
|
|
115
204
|
super
|
|
116
205
|
end
|
|
117
206
|
|