aspera-cli 4.27.2 → 4.27.4

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 (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +133 -0
  4. data/CONTRIBUTING.md +5 -2
  5. data/bin/ascli +3 -1
  6. data/docs/README.md +1006 -766
  7. data/lib/aspera/agent/base.rb +7 -2
  8. data/lib/aspera/agent/connect.rb +6 -8
  9. data/lib/aspera/agent/desktop.rb +2 -6
  10. data/lib/aspera/agent/direct.rb +52 -22
  11. data/lib/aspera/agent/node.rb +9 -8
  12. data/lib/aspera/agent/transferd.rb +2 -2
  13. data/lib/aspera/api/alee.rb +1 -1
  14. data/lib/aspera/api/aoc.rb +14 -12
  15. data/lib/aspera/api/ats.rb +1 -1
  16. data/lib/aspera/api/cos_node.rb +2 -2
  17. data/lib/aspera/api/faspex.rb +11 -7
  18. data/lib/aspera/api/httpgw.rb +38 -35
  19. data/lib/aspera/api/node.rb +39 -34
  20. data/lib/aspera/ascmd.rb +3 -1
  21. data/lib/aspera/ascp/installation.rb +63 -28
  22. data/lib/aspera/ascp/management.rb +1 -0
  23. data/lib/aspera/assert.rb +4 -0
  24. data/lib/aspera/cli/ascp_actions.rb +20 -41
  25. data/lib/aspera/cli/async_transfer_store.rb +12 -11
  26. data/lib/aspera/cli/bootstrapper.rb +14 -16
  27. data/lib/aspera/cli/command_line.rb +252 -0
  28. data/lib/aspera/cli/command_registry.rb +215 -37
  29. data/lib/aspera/cli/command_spec.rb +104 -15
  30. data/lib/aspera/cli/completion/ascli.bash +12 -0
  31. data/lib/aspera/cli/completion/ascli.fish +16 -0
  32. data/lib/aspera/cli/completion/ascli.zsh +19 -0
  33. data/lib/aspera/cli/context.rb +3 -0
  34. data/lib/aspera/cli/deprecation.rb +37 -0
  35. data/lib/aspera/cli/extended_value.rb +6 -3
  36. data/lib/aspera/cli/formatter.rb +94 -80
  37. data/lib/aspera/cli/gem_checker.rb +1 -1
  38. data/lib/aspera/cli/hints.rb +7 -6
  39. data/lib/aspera/cli/http.rb +22 -34
  40. data/lib/aspera/cli/info.rb +3 -0
  41. data/lib/aspera/cli/mcp_tool.rb +47 -83
  42. data/lib/aspera/cli/option_declarator.rb +33 -42
  43. data/lib/aspera/cli/option_registry.rb +69 -0
  44. data/lib/aspera/cli/option_types.rb +105 -0
  45. data/lib/aspera/cli/option_value.rb +278 -0
  46. data/lib/aspera/cli/options.schema.yaml +124 -15
  47. data/lib/aspera/cli/parser.rb +333 -862
  48. data/lib/aspera/cli/plugins/alee.rb +7 -4
  49. data/lib/aspera/cli/plugins/aoc.rb +545 -518
  50. data/lib/aspera/cli/plugins/ats.rb +59 -80
  51. data/lib/aspera/cli/plugins/base.rb +221 -265
  52. data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
  53. data/lib/aspera/cli/plugins/config.rb +263 -184
  54. data/lib/aspera/cli/plugins/console.rb +103 -39
  55. data/lib/aspera/cli/plugins/cos.rb +6 -23
  56. data/lib/aspera/cli/plugins/factory.rb +3 -0
  57. data/lib/aspera/cli/plugins/faspex5.rb +204 -182
  58. data/lib/aspera/cli/plugins/faspio.rb +6 -11
  59. data/lib/aspera/cli/plugins/httpgw.rb +8 -11
  60. data/lib/aspera/cli/plugins/mcp.rb +20 -55
  61. data/lib/aspera/cli/plugins/node.rb +300 -327
  62. data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
  63. data/lib/aspera/cli/plugins/preview.rb +96 -105
  64. data/lib/aspera/cli/plugins/server.rb +78 -53
  65. data/lib/aspera/cli/plugins/shares.rb +80 -131
  66. data/lib/aspera/cli/preset_actions.rb +44 -27
  67. data/lib/aspera/cli/preset_manager.rb +44 -19
  68. data/lib/aspera/cli/prompt.rb +36 -0
  69. data/lib/aspera/cli/result.rb +42 -36
  70. data/lib/aspera/cli/runner.rb +32 -59
  71. data/lib/aspera/cli/special_values.rb +5 -0
  72. data/lib/aspera/cli/sync_actions.rb +51 -46
  73. data/lib/aspera/cli/terminal_formatter.rb +9 -3
  74. data/lib/aspera/cli/transfer_actions.rb +14 -9
  75. data/lib/aspera/cli/transfer_agent.rb +34 -38
  76. data/lib/aspera/cli/transfer_progress.rb +290 -55
  77. data/lib/aspera/cli/vault_manager.rb +0 -17
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +5 -3
  80. data/lib/aspera/coverage.rb +1 -1
  81. data/lib/aspera/environment.rb +35 -5
  82. data/lib/aspera/faspex_gw.rb +2 -1
  83. data/lib/aspera/faspex_postproc.rb +1 -0
  84. data/lib/aspera/graphql.rb +5 -5
  85. data/lib/aspera/json_rpc/client.rb +5 -5
  86. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  87. data/lib/aspera/keychain/factory.rb +2 -1
  88. data/lib/aspera/keychain/one_password_api.rb +1 -1
  89. data/lib/aspera/link_header.rb +2 -2
  90. data/lib/aspera/log.rb +47 -27
  91. data/lib/aspera/markdown.rb +2 -0
  92. data/lib/aspera/mime.rb +25 -0
  93. data/lib/aspera/node_emulator.rb +759 -0
  94. data/lib/aspera/oauth/base.rb +37 -26
  95. data/lib/aspera/oauth/factory.rb +7 -3
  96. data/lib/aspera/oauth/generic.rb +1 -1
  97. data/lib/aspera/oauth/json_credentials.rb +34 -0
  98. data/lib/aspera/oauth/jwt.rb +4 -5
  99. data/lib/aspera/oauth/web.rb +9 -8
  100. data/lib/aspera/oauth.rb +1 -0
  101. data/lib/aspera/persistency_folder.rb +1 -3
  102. data/lib/aspera/preview/file_types.rb +4 -4
  103. data/lib/aspera/preview/generator.rb +11 -1
  104. data/lib/aspera/preview/options.schema.yaml +119 -0
  105. data/lib/aspera/preview/terminal.rb +4 -3
  106. data/lib/aspera/preview/utils.rb +9 -6
  107. data/lib/aspera/products/connect.rb +1 -1
  108. data/lib/aspera/rainbow.rb +7 -0
  109. data/lib/aspera/rest/aspera_errors.rb +72 -0
  110. data/lib/aspera/rest/call_error.rb +27 -0
  111. data/lib/aspera/rest/client.rb +523 -0
  112. data/lib/aspera/rest/error_analyzer.rb +113 -0
  113. data/lib/aspera/rest/list.rb +149 -0
  114. data/lib/aspera/rest/parameters.rb +55 -0
  115. data/lib/aspera/rest/util.rb +176 -0
  116. data/lib/aspera/rest.rb +7 -621
  117. data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
  118. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
  119. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
  120. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
  121. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
  122. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
  123. data/lib/aspera/schema/documentation.rb +13 -3
  124. data/lib/aspera/schema/reader.rb +12 -18
  125. data/lib/aspera/schema/registry.rb +23 -1
  126. data/lib/aspera/schema/validator.rb +92 -0
  127. data/lib/aspera/secret_hider.rb +36 -25
  128. data/lib/aspera/string_ext.rb +15 -0
  129. data/lib/aspera/temp_file_manager.rb +6 -5
  130. data/lib/aspera/transfer/parameters.rb +2 -0
  131. data/lib/aspera/transfer/spec.rb +1 -0
  132. data/lib/aspera/uri_reader.rb +11 -11
  133. data/lib/aspera/web_auth/index.html +147 -0
  134. data/lib/aspera/web_auth/server.rb +81 -0
  135. data.tar.gz.sig +0 -0
  136. metadata +43 -9
  137. metadata.gz.sig +0 -0
  138. data/lib/aspera/colors.rb +0 -79
  139. data/lib/aspera/node_simulator.rb +0 -344
  140. data/lib/aspera/preview/options.rb +0 -45
  141. data/lib/aspera/rest_call_error.rb +0 -25
  142. data/lib/aspera/rest_error_analyzer.rb +0 -111
  143. data/lib/aspera/rest_errors_aspera.rb +0 -58
  144. data/lib/aspera/rest_list.rb +0 -136
  145. data/lib/aspera/web_auth.rb +0 -211
@@ -11,16 +11,82 @@ 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
+ # command_path(words) - command path designated by command line words (aliases, arguments)
24
+ # all_paths - Array of all locally registered full paths
17
25
  # any? - true if at least one spec has been registered
18
26
  # validate! - cross-spec consistency checks; raises on violation
27
+ #
28
+ # Paths are always expressed in this registry's namespace: a path going through a
29
+ # mounted node (see MountSpec) is translated to the target registry transparently.
30
+ # Specs returned for mounted paths are the target's specs (their full_path is in the
31
+ # target namespace).
19
32
  class CommandRegistry
20
33
  # @param path [Array<Symbol>] full path to look up
21
34
  # @return [CommandSpec, nil]
22
35
  def [](path)
23
- @specs[Array(path)]
36
+ registry, local_path = resolve(path)
37
+ registry.equal?(self) ? @specs[local_path] : registry[local_path]
38
+ end
39
+
40
+ # Find the registry owning `path`, following mounts.
41
+ # A host child always takes precedence over a mounted child with the same id.
42
+ # @param path [Array<Symbol>] path in this registry's namespace
43
+ # @return [Array(CommandRegistry, Array<Symbol>)] owning registry and path in its namespace
44
+ def resolve(path)
45
+ path = Array(path)
46
+ path.each_index do |i|
47
+ prefix = path[0, i]
48
+ mount = @specs[prefix]&.mount
49
+ next if mount.nil? || @children_index[prefix]&.key?(path[i]) || !mount.accepts?(path[i])
50
+ return mount.registry.resolve(mount.at + path[i..])
51
+ end
52
+ [self, path]
53
+ end
54
+
55
+ # @param path [Array<Symbol>] path in this registry's namespace
56
+ # @return [Boolean] true if the node at path is declared in this registry (not mounted)
57
+ def local?(path)
58
+ resolve(path).first.equal?(self)
59
+ end
60
+
61
+ # @param path [Array<Symbol>] local path of a node
62
+ # @return [MountSpec, nil] the mount declared on that node
63
+ def mount_of(path)
64
+ @specs[Array(path)]&.mount
65
+ end
66
+
67
+ # @param path [Array<Symbol>] path in this registry's namespace
68
+ # @return [MountSpec, nil] the mount declared on the node at path, following mounts
69
+ def mount_at(path)
70
+ registry, local_path = resolve(path)
71
+ registry.mount_of(local_path)
72
+ end
73
+
74
+ # Children declared on the node itself, without the ones exposed by its mount.
75
+ # @param path [Array<Symbol>] path in this registry's namespace
76
+ # @return [Hash{Symbol => CommandSpec}]
77
+ def own_children_of(path)
78
+ registry, local_path = resolve(path)
79
+ return registry.own_children_of(local_path) unless registry.equal?(self)
80
+ @children_index[local_path] || {}
81
+ end
82
+
83
+ # Arguments read by the node at path, in order.
84
+ # For a child exposed by a mount, the mount's arguments come first (read by the host).
85
+ # @param path [Array<Symbol>] path in this registry's namespace
86
+ # @return [Array<ArgumentSpec>]
87
+ def arguments_at(path)
88
+ path = Array(path)
89
+ (path.empty? ? [] : mount_arguments(path)) + (self[path]&.arguments || [])
24
90
  end
25
91
 
26
92
  # Register a CommandSpec. Raises if the full_path is already registered.
@@ -40,14 +106,67 @@ module Aspera
40
106
 
41
107
  # Returns a Hash mapping each child id to its CommandSpec for all direct
42
108
  # children of `path`. Empty hash if no children are registered.
43
- # O(1) lookup via the children index built in register().
109
+ # For a mount node: mounted children (filtered) followed by local children,
110
+ # local ones overriding mounted ones with the same id.
44
111
  # @param path [Array<Symbol>] parent path ([] for root-level commands)
45
112
  # @return [Hash{Symbol => CommandSpec}]
46
113
  def children_of(path)
47
- @children_index[Array(path)] || {}
114
+ registry, local_path = resolve(path)
115
+ return registry.children_of(local_path) unless registry.equal?(self)
116
+ own = @children_index[local_path] || {}
117
+ mount = @specs[local_path]&.mount
118
+ return own if mount.nil?
119
+ mount.registry.children_of(mount.at).select { |id, _| mount.accepts?(id) }.merge(own)
120
+ end
121
+
122
+ # All leaf paths under path, in tree order, following mounts.
123
+ # A mount cycle (a sub-tree mounting one of its ancestors) is not expanded twice.
124
+ # @param path [Array<Symbol>] root of the listing ([] for all)
125
+ # @param expand_mounts [Boolean] `false`: a mount node is listed as a leaf, followed by its own children only
126
+ # @return [Array<Array<Symbol>>]
127
+ def leaf_paths(path = [], chain = [], expand_mounts: true)
128
+ children_of(path).keys.flat_map do |id|
129
+ child = path + [id]
130
+ key = subtree_key(child)
131
+ next [] if chain.include?(key)
132
+ if !expand_mounts && mount_at(child)
133
+ next [child] + own_children_of(child).keys.flat_map do |own_id|
134
+ own_child = child + [own_id]
135
+ children_of(own_child).empty? ? [own_child] : leaf_paths(own_child, chain + [key], expand_mounts: false)
136
+ end
137
+ end
138
+ children_of(child).empty? ? [child] : leaf_paths(child, chain + [key], expand_mounts: expand_mounts)
139
+ end
48
140
  end
49
141
 
50
- # @return [Array<Array<Symbol>>] all registered full paths
142
+ # Command path designated by words of a command line: sub-commands (or their aliases),
143
+ # each possibly followed by its positional arguments, e.g. `packages receive ALL` designates `packages receive`.
144
+ # Words after a leaf command are its arguments.
145
+ # @param words [Array<Symbol>] words following the plugin name
146
+ # @return [Array(Array<Symbol>, Symbol)] command path, and the first word that is neither a sub-command
147
+ # nor an expected argument (`nil` if none)
148
+ def command_path(words)
149
+ path = []
150
+ # Number of positional arguments still accepted by the node at path
151
+ args_left = 0
152
+ words.each do |word|
153
+ children = children_of(path)
154
+ break if children.empty?
155
+ id = children.key?(word) ? word : children.find { |_, c| Array(c.aliases).include?(word) }&.first
156
+ if id
157
+ path += [id]
158
+ arguments = arguments_at(path)
159
+ args_left = arguments.any?(&:multiple) ? Float::INFINITY : arguments.length
160
+ elsif args_left.positive?
161
+ args_left -= 1
162
+ else
163
+ return [path, word]
164
+ end
165
+ end
166
+ [path, nil]
167
+ end
168
+
169
+ # @return [Array<Array<Symbol>>] all locally registered full paths
51
170
  def all_paths
52
171
  @specs.keys
53
172
  end
@@ -77,60 +196,119 @@ module Aspera
77
196
  end
78
197
 
79
198
  # Cross-spec consistency checks.
80
- # @param plugin_class [Class, nil] when given, also verify that implicit action methods exist
199
+ # @param plugin_class [Class, nil] when given, also verify that methods referenced by Symbol
200
+ # (implicit and explicit actions, `setup:`, `condition:`, `lookup:`, mount `instance:`) exist
81
201
  # @raise [ArgumentError] on any violation
82
202
  # @return [self]
83
203
  def validate!(plugin_class: nil)
84
- # Rule: every non-root parent path that appears in the children index must have
85
- # a registered CommandSpec. A missing parent means commands_under(:x) was used
86
- # without a matching command :x declaration.
87
- @children_index.each_key do |parent_path|
88
- next if parent_path.empty? # root is never a CommandSpec
89
- unless @specs.key?(parent_path)
204
+ @children_index.each do |parent_path, children|
205
+ # Rule: every non-root parent path that appears in the children index must have
206
+ # a registered CommandSpec. A missing parent means commands_under(:x) was used
207
+ # without a matching command :x declaration.
208
+ unless parent_path.empty? || @specs.key?(parent_path)
90
209
  raise ArgumentError,
91
210
  "commands_under(#{parent_path.map(&:inspect).join(', ')}) used but #{parent_path.last.inspect} has no command declaration"
92
211
  end
212
+ # Rule: an alias designates a single command, and does not hide a sibling command
213
+ aliases = children.values.flat_map { |c| Array(c.aliases) }
214
+ conflicts = aliases.select { |a| children.key?(a) || aliases.count(a) > 1 }.uniq
215
+ raise ArgumentError, "#{parent_path.inspect}: alias conflicts with a sibling command or alias: #{conflicts.inspect}" unless conflicts.empty?
93
216
  end
94
217
 
95
218
  @specs.each_value do |spec|
96
219
  path = spec.full_path
97
220
 
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"
221
+ validate_arguments(path, spec.arguments, plugin_class)
222
+ # Rule: methods called on the plugin instance exist
223
+ if plugin_class
224
+ {setup: spec.setup, condition: spec.condition}.each do |attribute, method_name|
225
+ raise ArgumentError, "#{path.inspect}: no method #{method_name} on #{plugin_class} for #{attribute}:" unless method_name.nil? || instance_method?(plugin_class, method_name)
109
226
  end
110
227
  end
111
228
 
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"
229
+ if (mount = spec.mount)
230
+ # Rule: a mount needs an instance method, no action, and must point to existing target nodes
231
+ raise ArgumentError, "#{path.inspect}: mount requires instance:" if mount.instance.nil?
232
+ # Mount arguments precede the arguments of the mounted command: they cannot be optional
233
+ raise ArgumentError, "#{path.inspect}: mount arguments must be mandatory" unless mount.arguments.all?(&:mandatory)
234
+ validate_arguments(path, mount.arguments, plugin_class)
235
+ raise ArgumentError, "#{path.inspect}: mount and action: are exclusive" if spec.action
236
+ raise ArgumentError, "#{path.inspect}: mount at #{mount.at.inspect} not found in #{mount.plugin}" unless mount.at.empty? || mount.registry[mount.at]
237
+ target_ids = mount.registry.children_of(mount.at).keys
238
+ unknown = Array(mount.only) + Array(mount.except) - target_ids
239
+ raise ArgumentError, "#{path.inspect}: mount only/except unknown in #{mount.plugin}: #{unknown.inspect}" unless unknown.empty?
240
+ instance_defined = plugin_class.nil? || instance_method?(plugin_class, mount.instance)
241
+ raise ArgumentError, "#{path.inspect}: no method #{mount.instance} on #{plugin_class}" unless instance_defined
242
+ next
116
243
  end
117
244
 
118
- # Rule: leaf commands with no explicit action must have a matching instance method
119
- next if spec.action # explicit action: skip
120
245
  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}"
246
+ action = spec.action
247
+ if action.nil?
248
+ next unless plugin_class
249
+ # Rule: leaf commands with no explicit action must have a matching instance method
250
+ action = CommandSpec.action_method(path)
251
+ unless instance_method?(plugin_class, action)
252
+ raise ArgumentError,
253
+ "#{path.inspect}: no action: and no method #{action} on #{plugin_class}"
254
+ end
127
255
  end
256
+ action = plugin_class.instance_method(action) if action.is_a?(Symbol) && plugin_class && instance_method?(plugin_class, action)
257
+ # Rule: the action receives the whole dispatch context as keywords (setup results, arguments),
258
+ # so it must accept any keyword (`**`): a lambda or method with fixed arity would raise ArgumentError.
259
+ # A non-lambda Proc ignores extra keywords.
260
+ next if action.is_a?(Symbol) || (action.is_a?(Proc) && !action.lambda?)
261
+ raise ArgumentError, "#{path.inspect}: action must accept any keyword (**), parameters: #{action.parameters.inspect}" unless action.parameters.any? { |kind, _| kind.eql?(:keyrest) }
128
262
  end
129
263
  self
130
264
  end
131
265
 
132
266
  private
133
267
 
268
+ # @return [Boolean] true if plugin_class defines instance method name (public or private)
269
+ def instance_method?(plugin_class, name)
270
+ plugin_class.method_defined?(name) || plugin_class.private_method_defined?(name)
271
+ end
272
+
273
+ # Consistency of the positional arguments of a node, in reading order.
274
+ # @param path [Array<Symbol>] node path, for error messages
275
+ # @param arg_specs [Array<ArgumentSpec>] arguments of the node (or of its mount)
276
+ # @param plugin_class [Class, nil] when given, verify that `lookup:` methods exist
277
+ # @raise [ArgumentError] on any violation
278
+ def validate_arguments(path, arg_specs, plugin_class)
279
+ previous = nil
280
+ Array(arg_specs).each do |arg|
281
+ where = "#{path.inspect}: argument #{arg.name}"
282
+ # Rule: an argument following an optional one, or one that takes all remaining arguments, is never read reliably
283
+ raise ArgumentError, "#{where}: mandatory after optional #{previous.name}" if previous && arg.mandatory && !previous.mandatory
284
+ raise ArgumentError, "#{where}: after #{previous.name}, which takes all remaining arguments" if previous&.multiple.eql?(true)
285
+ unless arg.lookup.nil?
286
+ # Rule: the percent-selector lookup is only used for identifiers
287
+ raise ArgumentError, "#{where}: lookup: requires type: :identifier" unless arg.type.eql?(:identifier)
288
+ raise ArgumentError, "#{where}: no method #{arg.lookup} on #{plugin_class} for lookup:" if arg.lookup.is_a?(Symbol) && plugin_class && !instance_method?(plugin_class, arg.lookup)
289
+ end
290
+ previous = arg
291
+ end
292
+ end
293
+
294
+ # @param path [Array<Symbol>] non-empty path in this registry's namespace
295
+ # @return [Array<ArgumentSpec>] arguments of the mount exposing the last segment of path, if any
296
+ def mount_arguments(path)
297
+ registry, parent = resolve(path[0..-2])
298
+ return registry.send(:mount_arguments, parent + [path.last]) unless registry.equal?(self)
299
+ mount = @specs[parent]&.mount
300
+ return [] if mount.nil? || @children_index[parent]&.key?(path.last) || !mount.accepts?(path.last)
301
+ mount.arguments
302
+ end
303
+
304
+ # Identity of the sub-tree exposed at path: the mount point for a mount node, else the owning node.
305
+ # @return [Array(Integer, Array<Symbol>)]
306
+ def subtree_key(path)
307
+ registry, local_path = resolve(path)
308
+ mount = registry.mount_of(local_path)
309
+ mount ? [mount.registry.object_id, mount.at] : [registry.object_id, local_path]
310
+ end
311
+
134
312
  def initialize
135
313
  # Keyed by Array<Symbol> full path
136
314
  @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
@@ -21,7 +23,7 @@ module Aspera
21
23
  # Proc/lambda → called via instance_exec(field, value, **ctx, &lookup).
22
24
  # Style: use Symbol for named methods; ->(){} for 1-liners; lambda do…end for 2–3 statements.
23
25
  # @!attribute allowed [Array<Symbol>, nil] Allowed Symbol values; when set, type is forced to Symbol and accept_list is applied
24
- # @!attribute interactive [Boolean] When true, sets ask_missing_mandatory before resolving so interactive prompting is triggered when no CLI args are provided
26
+ # @!attribute interactive [Boolean] When true, prompts for this argument (only) when no CLI args are provided
25
27
  ArgumentSpec = Struct.new(
26
28
  :name,
27
29
  :description,
@@ -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
 
@@ -0,0 +1,12 @@
1
+ # Bash completion for ascli
2
+ # Activate: eval "$(ascli config completion bash)"
3
+
4
+ _ascli_complete() {
5
+ local cur="${COMP_WORDS[COMP_CWORD]}"
6
+ # COMP_WORDS[0] is the program name itself; pass only the words typed before the cursor
7
+ local candidates
8
+ candidates=$(ascli config completion words "${COMP_WORDS[@]:1:COMP_CWORD-1}" 2>/dev/null)
9
+ COMPREPLY=( $(compgen -W "${candidates}" -- "${cur}") )
10
+ }
11
+
12
+ complete -F _ascli_complete ascli
@@ -0,0 +1,16 @@
1
+ # Fish completion for ascli
2
+ # Activate: ascli config completion fish | source
3
+ # Or save as file ~/.config/fish/completions/ascli.fish
4
+
5
+ # Remove completions of a previous activation
6
+ complete --command ascli --erase
7
+
8
+ # Disable file completion entirely — ascli manages its own argument tree
9
+ complete --command ascli --no-files
10
+
11
+ # Dynamic completion: pass all words already typed (excluding 'ascli' itself)
12
+ # to `ascli config completion words` and return the result as completions.
13
+ complete --command ascli --arguments '(
14
+ set -l words (commandline -opc)
15
+ ascli config completion words $words[2..] 2>/dev/null
16
+ )'
@@ -0,0 +1,19 @@
1
+ #compdef ascli
2
+ # Zsh completion for ascli
3
+ # Activate (after compinit): eval "$(ascli config completion zsh)"
4
+ # Or save as file `_ascli` in a folder of $fpath
5
+
6
+ _ascli() {
7
+ local -a candidates
8
+ # words[1] is 'ascli' itself; pass only the words typed before the cursor
9
+ candidates=(${(f)"$(ascli config completion words "${(@)words[2,CURRENT-1]}" 2>/dev/null)"})
10
+ compadd -a candidates
11
+ }
12
+
13
+ if [[ "${funcstack[1]}" == _ascli ]]; then
14
+ # Autoloaded from $fpath: this file is the body of function _ascli
15
+ _ascli "$@"
16
+ else
17
+ # Evaluated or sourced
18
+ compdef _ascli ascli
19
+ fi
@@ -36,6 +36,8 @@ module Aspera
36
36
  attr_accessor :progress_bar
37
37
  # Optional: nil when no PAC script is configured
38
38
  attr_accessor :pac_executor
39
+ # `true` when help is requested (`-h`)
40
+ attr_accessor :help_requested
39
41
 
40
42
  # Initialize all members to nil, so that they are defined and can be validated later
41
43
  # @return [nil]
@@ -43,6 +45,7 @@ module Aspera
43
45
  MEMBERS.each { |i| instance_variable_set(:"@#{i}", nil) }
44
46
  @progress_bar = nil
45
47
  @pac_executor = nil
48
+ @help_requested = false
46
49
  end
47
50
 
48
51
  # Validate that all mandatory members are non-nil (detect bootstrap bugs)
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/version'
4
+ require 'aspera/assert'
5
+
6
+ module Aspera
7
+ module Cli
8
+ # Deprecation of a feature (e.g. an option).
9
+ # @!attribute last [String] Last released version supporting the feature without deprecation, e.g. `4.25.0`
10
+ # @!attribute message [String] What to use instead, e.g. `use --out.level`
11
+ Deprecation = Struct.new(:last, :message, keyword_init: true) do
12
+ def initialize(**kwargs)
13
+ super
14
+ Aspera.assert_type(last, String) { 'deprecation last' }
15
+ Aspera.assert(Gem::Version.correct?(last) && Gem::Version.new(last) < Gem::Version.new(VERSION)) do
16
+ "deprecation: last (#{last}) must be a released version, before #{VERSION}"
17
+ end
18
+ Aspera.assert_type(message, String) { 'deprecation message' }
19
+ end
20
+
21
+ # @return [String] e.g. `deprecated after 4.25.0: use --out.level`
22
+ def to_s = "deprecated after #{last}: #{message}"
23
+
24
+ class << self
25
+ # @param value [Deprecation, Hash, nil] Deprecation, or its attributes: `{last:, message:}`
26
+ # @return [Deprecation, nil]
27
+ def create(value)
28
+ case value
29
+ when nil, Deprecation then value
30
+ when Hash then new(**value)
31
+ else Aspera.error_unexpected_value(value.class) { 'deprecation, expect Hash {last:, message:}' }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -12,6 +12,8 @@ require 'base64'
12
12
  require 'zlib'
13
13
  require 'csv'
14
14
  require 'singleton'
15
+ require 'aspera/rainbow'
16
+ using Rainbow
15
17
 
16
18
  module Aspera
17
19
  module Cli
@@ -75,7 +77,7 @@ module Aspera
75
77
  case mode
76
78
  when '' then $stdin.read
77
79
  when 'bin' then $stdin.binmode.read
78
- when 'chomp' then $stdin.chomp
80
+ when 'chomp' then $stdin.read.chomp
79
81
  else raise BadArgument, "`stdin` supports only: '', 'bin' or 'chomp'"
80
82
  end
81
83
  end
@@ -116,10 +118,11 @@ module Aspera
116
118
  end
117
119
 
118
120
  # Update the Regex to match an extended value based on @handlers
121
+ # Anchored on the whole value (`\A`, `\z`), not on lines: a multi-line value is extended only if it starts with a modifier.
119
122
  def update_regex
120
123
  handler_regex = "#{MARKER_START}(#{modifiers.join('|')})#{MARKER_END}"
121
- @regex_single = Regexp.new("^#{handler_regex}(.*)$", Regexp::MULTILINE)
122
- @regex_extend = Regexp.new("^(.*)#{handler_regex}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)$", Regexp::MULTILINE)
124
+ @regex_single = Regexp.new("\\A#{handler_regex}(.*)\\z", Regexp::MULTILINE)
125
+ @regex_extend = Regexp.new("\\A(.*)#{handler_regex}([^#{MARKER_IN_END}]*)#{MARKER_IN_END}(.*)\\z", Regexp::MULTILINE)
123
126
  end
124
127
 
125
128
  public