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.
Files changed (130) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +50 -0
  4. data/bin/ascli +2 -1
  5. data/docs/README.md +804 -746
  6. data/lib/aspera/agent/connect.rb +6 -4
  7. data/lib/aspera/agent/desktop.rb +2 -2
  8. data/lib/aspera/agent/direct.rb +3 -1
  9. data/lib/aspera/agent/node.rb +3 -3
  10. data/lib/aspera/api/alee.rb +1 -1
  11. data/lib/aspera/api/aoc.rb +14 -12
  12. data/lib/aspera/api/ats.rb +1 -1
  13. data/lib/aspera/api/cos_node.rb +2 -2
  14. data/lib/aspera/api/faspex.rb +9 -7
  15. data/lib/aspera/api/httpgw.rb +37 -33
  16. data/lib/aspera/api/node.rb +38 -33
  17. data/lib/aspera/ascmd.rb +3 -1
  18. data/lib/aspera/ascp/installation.rb +62 -27
  19. data/lib/aspera/ascp/management.rb +1 -0
  20. data/lib/aspera/assert.rb +4 -0
  21. data/lib/aspera/cli/ascp_actions.rb +20 -41
  22. data/lib/aspera/cli/async_transfer_store.rb +2 -2
  23. data/lib/aspera/cli/bootstrapper.rb +11 -15
  24. data/lib/aspera/cli/command_line.rb +252 -0
  25. data/lib/aspera/cli/command_registry.rb +149 -33
  26. data/lib/aspera/cli/command_spec.rb +103 -14
  27. data/lib/aspera/cli/completion/ascli.bash +12 -0
  28. data/lib/aspera/cli/completion/ascli.fish +16 -0
  29. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  30. data/lib/aspera/cli/context.rb +3 -0
  31. data/lib/aspera/cli/deprecation.rb +37 -0
  32. data/lib/aspera/cli/extended_value.rb +2 -0
  33. data/lib/aspera/cli/formatter.rb +87 -75
  34. data/lib/aspera/cli/gem_checker.rb +1 -1
  35. data/lib/aspera/cli/hints.rb +7 -6
  36. data/lib/aspera/cli/http.rb +21 -21
  37. data/lib/aspera/cli/info.rb +3 -0
  38. data/lib/aspera/cli/mcp_tool.rb +47 -83
  39. data/lib/aspera/cli/option_declarator.rb +33 -42
  40. data/lib/aspera/cli/option_registry.rb +69 -0
  41. data/lib/aspera/cli/option_types.rb +103 -0
  42. data/lib/aspera/cli/option_value.rb +281 -0
  43. data/lib/aspera/cli/options.schema.yaml +38 -5
  44. data/lib/aspera/cli/parser.rb +307 -848
  45. data/lib/aspera/cli/plugins/alee.rb +7 -4
  46. data/lib/aspera/cli/plugins/aoc.rb +430 -380
  47. data/lib/aspera/cli/plugins/ats.rb +58 -73
  48. data/lib/aspera/cli/plugins/base.rb +190 -240
  49. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  50. data/lib/aspera/cli/plugins/config.rb +244 -178
  51. data/lib/aspera/cli/plugins/console.rb +102 -38
  52. data/lib/aspera/cli/plugins/cos.rb +6 -23
  53. data/lib/aspera/cli/plugins/factory.rb +3 -0
  54. data/lib/aspera/cli/plugins/faspex5.rb +176 -173
  55. data/lib/aspera/cli/plugins/faspio.rb +5 -10
  56. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  57. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  58. data/lib/aspera/cli/plugins/node.rb +277 -311
  59. data/lib/aspera/cli/plugins/orchestrator.rb +90 -77
  60. data/lib/aspera/cli/plugins/preview.rb +79 -90
  61. data/lib/aspera/cli/plugins/server.rb +76 -50
  62. data/lib/aspera/cli/plugins/shares.rb +68 -116
  63. data/lib/aspera/cli/preset_actions.rb +17 -10
  64. data/lib/aspera/cli/preset_manager.rb +12 -2
  65. data/lib/aspera/cli/prompt.rb +35 -0
  66. data/lib/aspera/cli/result.rb +13 -18
  67. data/lib/aspera/cli/runner.rb +31 -54
  68. data/lib/aspera/cli/special_values.rb +5 -0
  69. data/lib/aspera/cli/sync_actions.rb +41 -37
  70. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  71. data/lib/aspera/cli/transfer_actions.rb +0 -6
  72. data/lib/aspera/cli/transfer_agent.rb +29 -35
  73. data/lib/aspera/cli/vault_manager.rb +0 -17
  74. data/lib/aspera/cli/version.rb +1 -1
  75. data/lib/aspera/cli/wizard.rb +4 -2
  76. data/lib/aspera/coverage.rb +1 -0
  77. data/lib/aspera/environment.rb +6 -0
  78. data/lib/aspera/faspex_gw.rb +2 -1
  79. data/lib/aspera/faspex_postproc.rb +1 -0
  80. data/lib/aspera/graphql.rb +5 -5
  81. data/lib/aspera/json_rpc/client.rb +5 -5
  82. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  83. data/lib/aspera/keychain/one_password_api.rb +1 -1
  84. data/lib/aspera/link_header.rb +2 -2
  85. data/lib/aspera/log.rb +22 -25
  86. data/lib/aspera/markdown.rb +2 -0
  87. data/lib/aspera/mime.rb +25 -0
  88. data/lib/aspera/node_simulator.rb +1 -0
  89. data/lib/aspera/oauth/base.rb +35 -25
  90. data/lib/aspera/oauth/factory.rb +1 -0
  91. data/lib/aspera/oauth/generic.rb +1 -1
  92. data/lib/aspera/oauth/jwt.rb +1 -1
  93. data/lib/aspera/oauth/web.rb +9 -8
  94. data/lib/aspera/preview/file_types.rb +4 -4
  95. data/lib/aspera/preview/generator.rb +7 -0
  96. data/lib/aspera/preview/options.rb +4 -4
  97. data/lib/aspera/preview/terminal.rb +4 -3
  98. data/lib/aspera/preview/utils.rb +9 -6
  99. data/lib/aspera/products/connect.rb +1 -1
  100. data/lib/aspera/rainbow.rb +7 -0
  101. data/lib/aspera/rest/aspera_errors.rb +60 -0
  102. data/lib/aspera/rest/call_error.rb +27 -0
  103. data/lib/aspera/rest/client.rb +514 -0
  104. data/lib/aspera/rest/error_analyzer.rb +113 -0
  105. data/lib/aspera/rest/list.rb +143 -0
  106. data/lib/aspera/rest/parameters.rb +55 -0
  107. data/lib/aspera/rest/util.rb +176 -0
  108. data/lib/aspera/rest.rb +7 -621
  109. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  110. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  111. data/lib/aspera/schema/documentation.rb +13 -3
  112. data/lib/aspera/schema/registry.rb +18 -1
  113. data/lib/aspera/schema/validator.rb +92 -0
  114. data/lib/aspera/secret_hider.rb +36 -25
  115. data/lib/aspera/string_ext.rb +15 -0
  116. data/lib/aspera/temp_file_manager.rb +6 -5
  117. data/lib/aspera/transfer/parameters.rb +2 -0
  118. data/lib/aspera/transfer/spec.rb +1 -0
  119. data/lib/aspera/uri_reader.rb +11 -11
  120. data/lib/aspera/web_auth/index.html +147 -0
  121. data/lib/aspera/web_auth/server.rb +81 -0
  122. data.tar.gz.sig +0 -0
  123. metadata +39 -7
  124. metadata.gz.sig +0 -0
  125. data/lib/aspera/colors.rb +0 -79
  126. data/lib/aspera/rest_call_error.rb +0 -25
  127. data/lib/aspera/rest_error_analyzer.rb +0 -111
  128. data/lib/aspera/rest_errors_aspera.rb +0 -58
  129. data/lib/aspera/rest_list.rb +0 -136
  130. 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 (O(1))
16
- # all_paths - Array of all registered full paths
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
- @specs[Array(path)]
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
- # O(1) lookup via the children index built in register().
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
- @children_index[Array(path)] || {}
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
- # Rule: delegates_to must point to a known path when present (non-empty array or symbol)
99
- if spec.delegates_to
100
- dt_path =
101
- case spec.delegates_to
102
- when Symbol then [spec.delegates_to]
103
- when Array then spec.delegates_to
104
- end
105
- # An empty array [] means re-enter the root - always valid
106
- unless dt_path.empty? || @specs.key?(dt_path)
107
- raise ArgumentError,
108
- "#{path.inspect}: delegates_to #{dt_path.inspect} points to unknown path"
109
- end
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
- next if spec.delegates_to # delegated: skip
122
- next unless plugin_class
123
- implicit_method = CommandSpec.action_method(path)
124
- unless plugin_class.method_defined?(implicit_method) || plugin_class.private_method_defined?(implicit_method)
125
- raise ArgumentError,
126
- "#{path.inspect}: no action: and no method #{implicit_method} on #{plugin_class}"
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 handler [Symbol, Hash, nil]
58
- # - Symbol: resolved to {o: <plugin instance>, m: <symbol>} at runtime (Category B)
59
- # - Hash: {o: <object>, m: <method>} used as-is (Category A: singletons / class constants)
60
- # - nil: option stores its value locally (no delegation)
61
- # @!attribute deprecation [String, nil] Forwarded to options.declare as deprecation:
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
- :handler, # kept as-is: this is the option accessor delegation, not a command action
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