aspera-cli 4.27.5 → 4.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. data/lib/aspera/web_auth/index.html +0 -147
@@ -20,7 +20,8 @@ module Aspera
20
20
  # own_children_of(path) - Hash{Symbol => CommandSpec} of direct children declared on the node, without mounted ones
21
21
  # arguments_at(path) - Array of ArgumentSpec read by the node at path (mount arguments first)
22
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)
23
+ # command_path(words) - command path designated by command line words (aliases, prefixes, arguments)
24
+ # command_position(words) - command path and number of its arguments, as read by the dispatcher
24
25
  # all_paths - Array of all locally registered full paths
25
26
  # any? - true if at least one spec has been registered
26
27
  # validate! - cross-spec consistency checks; raises on violation
@@ -139,7 +140,7 @@ module Aspera
139
140
  end
140
141
  end
141
142
 
142
- # Command path designated by words of a command line: sub-commands (or their aliases),
143
+ # Command path designated by words of a command line: sub-commands (or their aliases, or a unique prefix),
143
144
  # each possibly followed by its positional arguments, e.g. `packages receive ALL` designates `packages receive`.
144
145
  # Words after a leaf command are its arguments.
145
146
  # @param words [Array<Symbol>] words following the plugin name
@@ -152,7 +153,7 @@ module Aspera
152
153
  words.each do |word|
153
154
  children = children_of(path)
154
155
  break if children.empty?
155
- id = children.key?(word) ? word : children.find { |_, c| Array(c.aliases).include?(word) }&.first
156
+ id = child_id(children, word)
156
157
  if id
157
158
  path += [id]
158
159
  arguments = arguments_at(path)
@@ -166,6 +167,30 @@ module Aspera
166
167
  [path, nil]
167
168
  end
168
169
 
170
+ # Position reached by words of a command line, as read by the dispatcher: an intermediate command reads
171
+ # its positional arguments before its sub-command, a leaf command reads the following words as arguments.
172
+ # Unlike `command_path`, a word expected as argument is never taken as a sub-command.
173
+ # @param words [Array<Symbol>] words following the plugin name
174
+ # @return [Array(Array<Symbol>, Integer), nil] command path, and number of its positional arguments given;
175
+ # `nil` if a word is neither a sub-command nor an expected argument
176
+ def command_position(words)
177
+ path = []
178
+ given = 0
179
+ words.each do |word|
180
+ children = children_of(path)
181
+ arguments = arguments_at(path)
182
+ if given < arguments.length || (children.empty? && arguments.last&.multiple)
183
+ given += 1
184
+ else
185
+ id = child_id(children, word)
186
+ return nil if id.nil?
187
+ path += [id]
188
+ given = 0
189
+ end
190
+ end
191
+ [path, given]
192
+ end
193
+
169
194
  # @return [Array<Array<Symbol>>] all locally registered full paths
170
195
  def all_paths
171
196
  @specs.keys
@@ -265,6 +290,21 @@ module Aspera
265
290
 
266
291
  private
267
292
 
293
+ # As the parser: an exact name, else the unique name starting with word (e.g. `brow` for alias `browse` of `ls`).
294
+ # @param children [Hash{Symbol => CommandSpec}] children of a node
295
+ # @param word [Symbol] command line word
296
+ # @return [Symbol, nil] id of the child designated by word: id or alias, or unique prefix of one
297
+ def child_id(children, word)
298
+ # Name (id or alias) => id
299
+ names = children.each_with_object({}) do |(id, spec), h|
300
+ h[id] = id
301
+ Array(spec.aliases).each { |a| h[a] = id }
302
+ end
303
+ return names[word] if names.key?(word)
304
+ matching = names.keys.select { |name| name.to_s.start_with?(word.to_s) }
305
+ names[matching.first] if matching.length.eql?(1)
306
+ end
307
+
268
308
  # @return [Boolean] true if plugin_class defines instance method name (public or private)
269
309
  def instance_method?(plugin_class, name)
270
310
  plugin_class.method_defined?(name) || plugin_class.private_method_defined?(name)
@@ -24,6 +24,7 @@ module Aspera
24
24
  # Style: use Symbol for named methods; ->(){} for 1-liners; lambda do…end for 2–3 statements.
25
25
  # @!attribute allowed [Array<Symbol>, nil] Allowed Symbol values; when set, type is forced to Symbol and accept_list is applied
26
26
  # @!attribute interactive [Boolean] When true, prompts for this argument (only) when no CLI args are provided
27
+ # @!attribute raw [Boolean] When true, the value is not evaluated as extended value (e.g. `@env:`)
27
28
  ArgumentSpec = Struct.new(
28
29
  :name,
29
30
  :description,
@@ -36,6 +37,7 @@ module Aspera
36
37
  :lookup,
37
38
  :allowed,
38
39
  :interactive,
40
+ :raw,
39
41
  keyword_init: true
40
42
  ) do
41
43
  def initialize(**kwargs)
@@ -43,6 +45,7 @@ module Aspera
43
45
  kwargs[:multiple] = false if kwargs[:multiple].nil?
44
46
  kwargs[:bulk] = false if kwargs[:bulk].nil?
45
47
  kwargs[:interactive] = false if kwargs[:interactive].nil?
48
+ kwargs[:raw] = false if kwargs[:raw].nil?
46
49
  kwargs[:type] = String unless kwargs.key?(:type) || kwargs[:allowed]
47
50
  super
48
51
  end
@@ -2,11 +2,45 @@
2
2
  # Activate: eval "$(ascli config completion bash)"
3
3
 
4
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}") )
5
+ local line=${COMP_LINE:0:COMP_POINT}
6
+ # Shell words up to the cursor: COMP_WORDS is also split on COMP_WORDBREAKS (e.g. `=`, `:`), join the parts not separated by blanks
7
+ local -a words=()
8
+ local i word blanks rest=$line
9
+ for ((i = 0; i <= COMP_CWORD; i++)); do
10
+ blanks=${rest%%[![:blank:]]*}
11
+ rest=${rest#"$blanks"}
12
+ if ((i == COMP_CWORD)); then word=$rest; else word=${COMP_WORDS[i]}; fi
13
+ if ((i == 0)) || [[ -n $blanks ]]; then
14
+ words+=("$word")
15
+ else
16
+ words[${#words[@]} - 1]+=$word
17
+ fi
18
+ rest=${rest#"$word"}
19
+ done
20
+ local cur=${words[${#words[@]} - 1]}
21
+ # Readline replaces only the text after the last word break character
22
+ local prefix=${cur%"${line##*["$COMP_WORDBREAKS"]}"}
23
+ # words[0] is the program name itself: pass the words before the cursor, then the word at cursor
24
+ local output directive entry
25
+ output=$(ascli config completion candidates -- "${words[@]:1:${#words[@]}-2}" "$cur" 2>/dev/null)
26
+ directive=${output##*$'\n'}
27
+ output=${output%"$directive"}
28
+ COMPREPLY=()
29
+ while IFS= read -r entry; do
30
+ # Remove the description
31
+ word=${entry%%$'\t'*}
32
+ [[ -n $word && $word == "$cur"* ]] && COMPREPLY+=("${word#"$prefix"}")
33
+ done <<<"$output"
34
+ case $directive in
35
+ :files)
36
+ compopt -o filenames 2>/dev/null
37
+ while IFS= read -r word; do COMPREPLY+=("$word"); done < <(compgen -f -- "${cur#"$prefix"}")
38
+ ;;
39
+ ":hint "*)
40
+ # Two entries: bash displays them, but inserts nothing
41
+ COMPREPLY=("${directive#:hint }" " ")
42
+ ;;
43
+ esac
10
44
  }
11
45
 
12
46
  complete -F _ascli_complete ascli
@@ -5,12 +5,20 @@
5
5
  # Remove completions of a previous activation
6
6
  complete --command ascli --erase
7
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 '(
8
+ # Candidates proposed by ascli, as lines `word<TAB>description`, as expected by fish
9
+ function __ascli_complete
10
+ # Words before the cursor (ascli itself excluded), then the word at cursor
14
11
  set -l words (commandline -opc)
15
- ascli config completion words $words[2..] 2>/dev/null
16
- )'
12
+ set -l current (commandline -ct)
13
+ set -l lines (ascli config completion candidates -- $words[2..] "$current" 2>/dev/null)
14
+ set -q lines[1]; or return
15
+ # Last line is the directive: `:files`, `:hint <text>` (cannot be displayed without being inserted), or `:`
16
+ set -l directive $lines[-1]
17
+ set -e lines[-1]
18
+ test "$directive" = :files; and __fish_complete_path "$current"
19
+ # printf without argument would output an empty line
20
+ set -q lines[1]; and printf '%s\n' $lines
21
+ end
22
+
23
+ # File completion only where proposed by ascli
24
+ complete --command ascli --no-files --arguments '(__ascli_complete)'
@@ -0,0 +1,41 @@
1
+ # PowerShell completion for ascli
2
+ # Activate, in $PROFILE: ascli config completion powershell | Out-String | Invoke-Expression
3
+ # Or save as a file with extension .ps1, and dot-source it in $PROFILE
4
+
5
+ & {
6
+ # Output of ascli, per words typed: it depends only on the version of ascli, so it is kept for the session
7
+ $cache = @{}
8
+ # Also applies to ascli.cmd and ascli.bat, and to a path to them: PowerShell matches the name without folder and extension
9
+ Register-ArgumentCompleter -Native -CommandName ascli -ScriptBlock {
10
+ param($wordToComplete, $commandAst, $cursorPosition)
11
+ # Run ascli as typed, e.g. .\ascli.cmd when not in PATH
12
+ $command = $commandAst.GetCommandName()
13
+ # CommandElements[0] is the command itself; pass only the words typed before the cursor, unquoted (e.g. '@:')
14
+ $words = @($commandAst.CommandElements | Select-Object -Skip 1 |
15
+ Where-Object { $_.Extent.EndOffset -lt $cursorPosition } |
16
+ ForEach-Object { if ($_ -is [System.Management.Automation.Language.StringConstantExpressionAst]) { $_.Value } else { $_.Extent.Text } })
17
+ # Only the beginning of the word at cursor matters: `-` (option names), `--name.` (keys) or `--name=` (values), candidates are filtered here.
18
+ # Other words give the same candidates: `.` is passed, as Windows PowerShell does not pass an empty argument.
19
+ $current = if ($wordToComplete -match '^--[^=]*[=.]') { $Matches[0] } elseif ($wordToComplete.StartsWith('-')) { '-' } else { '.' }
20
+ $key = (@($command) + $words + $current) -join ' '
21
+ if (-not $cache.ContainsKey($key)) {
22
+ $cache[$key] = @(& $command config completion candidates -- @words $current 2>$null)
23
+ }
24
+ $lines = $cache[$key]
25
+ # Directive `:files`: no result, so that PowerShell completes file paths
26
+ if ($lines.Count -eq 0 -or $lines[-1] -eq ':files') { return }
27
+ # Lines `word<TAB>description`, then the directive
28
+ $results = @($lines | Select-Object -First ($lines.Count - 1) | ForEach-Object {
29
+ $word, $description = $_ -split "`t", 2
30
+ if ($word -and $word.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) {
31
+ [System.Management.Automation.CompletionResult]::new($word, $word, 'ParameterValue', $(if ($description) { $description } else { $word }))
32
+ }
33
+ })
34
+ if ($results.Count -gt 0) { return $results }
35
+ # No result: one that does not change the word (a space for an empty word), so that PowerShell does not complete file paths.
36
+ # It shows the hint in the list, if any (e.g. `<String>`)
37
+ $text = if ($wordToComplete) { $wordToComplete } else { ' ' }
38
+ $hint = if ($lines[-1] -like ':hint *') { $lines[-1].Substring(6) } else { $text }
39
+ [System.Management.Automation.CompletionResult]::new($text, $hint, 'ParameterValue', $hint)
40
+ }.GetNewClosure()
41
+ }
@@ -4,10 +4,26 @@
4
4
  # Or save as file `_ascli` in a folder of $fpath
5
5
 
6
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
7
+ local -a lines values opts
8
+ local directive line entry
9
+ # words[1] is 'ascli' itself: pass the words before the cursor, then the word at cursor
10
+ lines=("${(@f)$(ascli config completion candidates -- "${(@)words[2,CURRENT-1]}" "$PREFIX" 2>/dev/null)}")
11
+ directive=${lines[-1]}
12
+ # Candidates `word<TAB>description` to `word:description`, as expected by _describe
13
+ for line in "${(@)lines[1,-2]}"; do
14
+ [[ -n $line ]] || continue
15
+ entry=${${line%%$'\t'*}//:/\\:}
16
+ [[ $line == *$'\t'* ]] && entry+=":${line#*$'\t'}"
17
+ if [[ $line == -* ]]; then opts+=("$entry"); else values+=("$entry"); fi
18
+ done
19
+ case $directive in
20
+ :files) _files ;;
21
+ ":hint "*) _message -r "${directive#:hint }" ;;
22
+ *)
23
+ # Options are listed after the other candidates
24
+ _alternative 'values:value: _describe -t values value values' 'options:option: _describe -t options option opts'
25
+ ;;
26
+ esac
11
27
  }
12
28
 
13
29
  if [[ "${funcstack[1]}" == _ascli ]]; then
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/info'
4
+ require 'aspera/cli/parser'
5
+ require 'aspera/cli/plugins/factory'
6
+ require 'aspera/cli/special_values'
7
+ require 'aspera/environment'
8
+ require 'aspera/schema/documentation'
9
+ require 'aspera/schema/registry'
10
+
11
+ module Aspera
12
+ module Cli
13
+ # Mixin for Config plugin: shell completion scripts, and the words they propose
14
+ module CompletionActions
15
+ # Shells with a completion script in COMPLETION_FOLDER: shell => extension of script
16
+ COMPLETION_SHELLS = {bash: 'bash', zsh: 'zsh', fish: 'fish', powershell: 'ps1'}.freeze
17
+ COMPLETION_FOLDER = File.expand_path('completion', __dir__)
18
+ # Completion of the word at cursor
19
+ # @!attribute candidates [Array<Array(String, String)>] words, with their description (or `nil`)
20
+ # @!attribute hint [String, nil] displayed when a value is expected, but cannot be proposed (e.g. `<String>`)
21
+ # @!attribute files [Boolean] local files can be completed
22
+ Completion = Struct.new(:candidates, :hint, :files, keyword_init: true)
23
+ # Structure built with `@:` (see `Parser#args_as_extended`) being typed
24
+ # @!attribute marker [String] end marker
25
+ # @!attribute assignments [Integer] number of assignments given
26
+ # @!attribute option [Option, nil] option token whose value is built, `nil` for a positional argument
27
+ Collecting = Struct.new(:marker, :assignments, :option, keyword_init: true)
28
+ # Value starting a structure built with `@:`: the optional text after `:` is the end marker
29
+ REGEX_EXTEND_ARGS = /\A@:(.*)\z/
30
+ # Maximum length of a hint listing the keys of a structure
31
+ HINT_MAX_LENGTH = 72
32
+ private_constant :COMPLETION_SHELLS, :COMPLETION_FOLDER, :Completion, :Collecting, :REGEX_EXTEND_ARGS, :HINT_MAX_LENGTH
33
+
34
+ # List words that can follow the given words, one per line.
35
+ # When a value is expected but cannot be proposed, its type is listed (e.g. `<String>`).
36
+ # @param words [Array<String>, nil] words typed before the cursor, after the command name
37
+ def action_completion_words(words: nil, **)
38
+ completion = completion(Array(words), '')
39
+ completion.candidates.each { |candidate| puts candidate.first }
40
+ puts completion.hint unless completion.hint.nil?
41
+ Process.exit(0)
42
+ end
43
+
44
+ # Complete the word at cursor, for completion scripts: candidates, one per line: `word`, or `word<TAB>description`,
45
+ # not filtered by the word at cursor. Then a directive for the shell: `:files` (also complete local files),
46
+ # `:hint <text>` (display text, nothing to insert), or `:`.
47
+ # @param words [Array<String>, nil] words typed before the cursor, after the command name, then the word at cursor
48
+ def action_completion_candidates(words: nil, **)
49
+ words = Array(words)
50
+ completion = completion(words[0..-2], words.last.to_s)
51
+ completion.candidates.each { |word, description| puts description.nil? ? word : "#{word}\t#{description}" }
52
+ directive =
53
+ if completion.files then 'files'
54
+ elsif completion.hint then "hint #{completion.hint}"
55
+ end
56
+ puts ":#{directive}"
57
+ Process.exit(0)
58
+ end
59
+
60
+ private
61
+
62
+ # @param words [Array<String>] words typed before the cursor, after the command name
63
+ # @param current [String] word at cursor, up to the cursor
64
+ # @return [Completion]
65
+ def completion(words, current)
66
+ words = completion_join_bash_split(words)
67
+ # `--opt=value`: complete the value of the option, with this prefix
68
+ prefix = current.start_with?(Option::PREFIX) && current.include?(Option::VALUE_SEP) ? current[0..current.index(Option::VALUE_SEP)] : ''
69
+ words += [prefix] unless prefix.empty?
70
+ plugin_class, positional, waiting, collecting = completion_context(words)
71
+ return Completion.new(candidates: []) if positional.nil?
72
+ result =
73
+ if prefix.empty? && (keys = completion_option_keys(current))
74
+ keys
75
+ elsif prefix.empty? && current.start_with?('-')
76
+ Completion.new(candidates: completion_options(options.declared_options.keys))
77
+ elsif !waiting.nil?
78
+ completion_option_values(waiting)
79
+ elsif !collecting.nil?
80
+ completion_collecting(collecting, plugin_class, positional)
81
+ elsif plugin_class.nil?
82
+ # Level 0: plugin names and global options
83
+ Completion.new(candidates: Plugins::Factory.instance.plugin_list.map { |p| [p.to_s, completion_plugin_description(p)] } + completion_options(options.declared_options.keys))
84
+ else
85
+ completion_command(plugin_class, positional[1..])
86
+ end
87
+ result.candidates = result.candidates.map { |word, description| ["#{prefix}#{word}", description] }
88
+ result
89
+ end
90
+
91
+ # @param words [Array<String>] words typed before the cursor
92
+ # @return [Array(Class, Array<String>, Option, Collecting)] plugin class (`nil` at root), and result of `completion_split`;
93
+ # `[]` if the first positional word is not a plugin
94
+ def completion_context(words)
95
+ positional, waiting, collecting = completion_split(words)
96
+ return [nil, positional, waiting, collecting] if positional.empty?
97
+ plugin_sym = completion_plugin(positional.first)
98
+ return [] if plugin_sym.nil?
99
+ begin
100
+ plugin_class = Plugins::Factory.instance.plugin_class(plugin_sym)
101
+ rescue StandardError
102
+ return []
103
+ end
104
+ plugin_class.declare_options(options)
105
+ # Options of the plugin may take a value: split again
106
+ [plugin_class, *completion_split(words)]
107
+ end
108
+
109
+ # As the runner: an exact name, else the unique plugin name starting with word
110
+ # @param word [String] first positional word
111
+ # @return [Symbol, nil] plugin designated by word
112
+ def completion_plugin(word)
113
+ plugins = Plugins::Factory.instance.plugin_list
114
+ return word.to_sym if plugins.include?(word.to_sym)
115
+ matching = plugins.select { |p| p.to_s.start_with?(word) }
116
+ matching.first if matching.length.eql?(1)
117
+ end
118
+
119
+ # @param plugin_class [Class] plugin
120
+ # @param words [Array<String>] positional words after the plugin name
121
+ # @return [Completion] sub-commands, or next argument
122
+ def completion_command(plugin_class, words)
123
+ registry = plugin_class.command_registry
124
+ path, given = registry.command_position(words.map(&:to_sym))
125
+ return Completion.new(candidates: []) if path.nil?
126
+ children = registry.children_of(path)
127
+ next_argument = completion_next_argument(registry, path, given)
128
+ return completion_argument(next_argument) unless next_argument.nil?
129
+ # Source files of an upload follow the declared arguments
130
+ return Completion.new(candidates: [], files: true) if children.empty? && registry[path]&.transfer_paths.eql?(:send)
131
+ # Commands, each followed by its aliases, with the same description
132
+ candidates = children.flat_map do |id, spec|
133
+ description = completion_description(spec.description)
134
+ [id, *Array(spec.aliases)].map { |name| [name.to_s, description] }
135
+ end
136
+ # Options of plugin only right after the plugin name
137
+ candidates.concat(completion_options(plugin_class.option_sources.each_key.flat_map { |src| src.option_specs.keys }.uniq)) if words.empty?
138
+ Completion.new(candidates: candidates)
139
+ end
140
+
141
+ # @param registry [CommandRegistry] registry of plugin
142
+ # @param path [Array<Symbol>] command path
143
+ # @param given [Integer] number of arguments given to the command
144
+ # @return [ArgumentSpec, nil] argument expected after the given ones
145
+ def completion_next_argument(registry, path, given)
146
+ arguments = registry.arguments_at(path)
147
+ return arguments[given] if given < arguments.length
148
+ arguments.last if registry.children_of(path).empty? && arguments.last&.multiple
149
+ end
150
+
151
+ # In a structure built with `@:`: its end marker once an assignment is given, else the expected keys
152
+ # @param collecting [Collecting] structure being typed
153
+ # @param plugin_class [Class, nil] plugin
154
+ # @param positional [Array<String>] positional words, the plugin name first
155
+ # @return [Completion]
156
+ def completion_collecting(collecting, plugin_class, positional)
157
+ return Completion.new(candidates: [[collecting.marker, 'End of structure built with @:']]) if collecting.assignments.positive?
158
+ schema =
159
+ if collecting.option
160
+ completion_option(collecting.option)&.schema
161
+ elsif plugin_class
162
+ # The positional argument built is the last positional word: `@:`
163
+ registry = plugin_class.command_registry
164
+ path, given = registry.command_position(positional[1..-2].map(&:to_sym))
165
+ completion_next_argument(registry, path, given)&.schema unless path.nil?
166
+ end
167
+ Completion.new(candidates: [], hint: completion_hint('path=value', schema))
168
+ end
169
+
170
+ # Bash splits `--opt=value` in words `--opt`, `=` and `value`: join them back.
171
+ # @param words [Array<String>] words typed before the cursor
172
+ # @return [Array<String>] words, with options and their value joined
173
+ def completion_join_bash_split(words)
174
+ result = []
175
+ join_next = false
176
+ words.each do |word|
177
+ if join_next
178
+ result[-1] += word
179
+ join_next = false
180
+ elsif word.eql?(Option::VALUE_SEP) && result.last&.start_with?(Option::PREFIX) && !result.last.include?(Option::VALUE_SEP)
181
+ result[-1] += word
182
+ join_next = true
183
+ else
184
+ result.push(word)
185
+ end
186
+ end
187
+ result
188
+ end
189
+
190
+ # Split words as the command line parser does: an option without inline value takes the next word as value,
191
+ # unless it is a flag. An option not declared yet (e.g. of a plugin) takes a value.
192
+ # A value `@:` (of option, or of positional argument) collects the next positional words, up to its end marker.
193
+ # @param words [Array<String>] words typed before the cursor
194
+ # @return [Array(Array<String>, Option, Collecting)] positional words, the last option if it is waiting for its value,
195
+ # and the structure built with `@:` if its end marker is not given
196
+ def completion_split(words)
197
+ positional = []
198
+ waiting = nil
199
+ collecting = nil
200
+ stop = false
201
+ words.each do |word|
202
+ if !stop && word.eql?(Option::STOP)
203
+ stop = true
204
+ waiting = nil
205
+ elsif !stop && Option.option?(word)
206
+ tok = Option.parse(word)
207
+ # `--opt=` at end of line (from bash split): value is being typed
208
+ waiting = (tok.inline? && !tok.inline_value.empty?) || completion_option(tok)&.flag? ? nil : tok
209
+ collecting = completion_collecting_start(tok.inline_value, tok) || collecting
210
+ elsif waiting
211
+ collecting = completion_collecting_start(word, waiting) || collecting
212
+ waiting = nil
213
+ elsif collecting
214
+ # Assignment, or end marker
215
+ collecting.assignments += 1
216
+ collecting = nil if word.eql?(collecting.marker)
217
+ else
218
+ positional.push(word)
219
+ collecting = completion_collecting_start(word, nil)
220
+ end
221
+ end
222
+ [positional, waiting, collecting]
223
+ end
224
+
225
+ # @param value [String, nil] value of option or argument
226
+ # @param option [Option, nil] option token, `nil` for a positional argument
227
+ # @return [Collecting, nil] structure started by the value, if it is `@:`
228
+ def completion_collecting_start(value, option)
229
+ match = REGEX_EXTEND_ARGS.match(value.to_s)
230
+ return if match.nil?
231
+ Collecting.new(marker: match[1].empty? ? SpecialValues::EOA : match[1], assignments: 0, option: option)
232
+ end
233
+
234
+ # @param tok [Option] option token
235
+ # @return [OptionValue, nil] declared option designated by the token
236
+ def completion_option(tok)
237
+ return options.declared_options.values.find { |o| o.short.eql?(tok.short_char) } if tok.short_char
238
+ options.declared_options[tok.name.to_sym]
239
+ end
240
+
241
+ # @param names [Array<Symbol>] names of declared options
242
+ # @return [Array<Array(String, String)>] command line names of options, except deprecated ones, and their description
243
+ def completion_options(names)
244
+ names.filter_map do |name|
245
+ option = options.declared_options[name]
246
+ next if option.nil? || option.deprecation
247
+ [Parser.option_name_to_line(name), completion_description(option.description)]
248
+ end
249
+ end
250
+
251
+ # @param tok [Option] option token waiting for its value
252
+ # @return [Completion] allowed values of the option, or its type as hint
253
+ def completion_option_values(tok)
254
+ option = completion_option(tok)
255
+ return Completion.new(candidates: []) if option.nil?
256
+ return completion_key_value(option.schema, tok.dot_path) unless tok.dot_path.nil?
257
+ return Completion.new(candidates: option.values.map(&:to_s).uniq.map { |v| [v, nil] }) if option.values&.any?
258
+ types = option.types&.reject { |t| t.eql?(NilClass) }
259
+ Completion.new(candidates: [], hint: completion_hint(types&.any? ? types.map(&:name).join('|') : option.allowed_info || 'String', option.schema))
260
+ end
261
+
262
+ # Keys of an option with dotted notation, from its schema, e.g. `--ts.ta` gives `--ts.target_rate_kbps`
263
+ # @param current [String] word at cursor
264
+ # @return [Completion, nil] keys at the level of the word, `nil` if the word is not a dotted option
265
+ def completion_option_keys(current)
266
+ match = current.match(/\A--([^=.]+)\.([^=]*)\z/)
267
+ return if match.nil?
268
+ option = options.declared_options[Parser.option_line_to_name(match[1]).to_sym]
269
+ parents = match[2].split(DotContainer::SEPARATOR, -1)[0..-2]
270
+ candidates = completion_schema_rows(option&.schema).filter_map do |row|
271
+ path = row['name'].split(DotContainer::SEPARATOR)
272
+ next if row['name'].include?('[]') || !path[0..-2].eql?(parents)
273
+ ["--#{match[1]}.#{row['name']}", completion_description(row['description']) || completion_type(row['type'])]
274
+ end
275
+ Completion.new(candidates: candidates)
276
+ end
277
+
278
+ # @param schema [String, nil] schema of structure
279
+ # @param dot_path [Array<String>] key in structure
280
+ # @return [Completion] allowed values of the key, or its type as hint
281
+ def completion_key_value(schema, dot_path)
282
+ row = completion_schema_rows(schema).find { |r| r['name'].eql?(dot_path.join(DotContainer::SEPARATOR)) }
283
+ return Completion.new(candidates: []) if row.nil?
284
+ return Completion.new(candidates: row['enum'].compact.map { |v| [v.to_s, nil] }) if row['enum']
285
+ return Completion.new(candidates: [['true', nil], ['false', nil]]) if row['type'].eql?('boolean')
286
+ Completion.new(candidates: [], hint: ["<#{completion_type(row['type'])}>", completion_description(row['description'])].compact.join(' '))
287
+ end
288
+
289
+ # @param type [String] expected type, e.g. `Hash`
290
+ # @param schema [String, nil] schema of value
291
+ # @return [String] type, followed by the top level keys of the schema, if any: required ones first, followed by `*`
292
+ def completion_hint(type, schema)
293
+ hint = "<#{type}>"
294
+ top = completion_schema_rows(schema).reject { |r| r['name'].match?(/[.\[]/) }
295
+ required, optional = top.partition { |r| r['required'] }
296
+ names = required.map { |r| "#{r['name']}*" } + optional.map { |r| r['name'] }
297
+ names.each_with_index do |name, index|
298
+ separator = index.zero? ? ' ' : ', '
299
+ # stdout is read by the shell, not a terminal, but the hint is displayed on the terminal: check the locale only
300
+ return "#{hint}#{separator}#{Environment.char('…', unicode: Environment.unicode_environment?)}" if hint.length + separator.length + name.length > HINT_MAX_LENGTH
301
+ hint = "#{hint}#{separator}#{name}"
302
+ end
303
+ hint
304
+ end
305
+
306
+ # @param schema [String, nil] name of schema in registry
307
+ # @return [Array<Hash>] fields of the schema (see `Schema::Reader#to_rows`), empty if none
308
+ def completion_schema_rows(schema)
309
+ return [] if schema.nil?
310
+ Schema::Registry.instance.reader(schema).to_rows
311
+ rescue StandardError
312
+ []
313
+ end
314
+
315
+ # @param type [String] JSON type(s) of a schema field, e.g. `integer` or `string, null`
316
+ # @return [String] type, as displayed in help, e.g. `Integer`
317
+ def completion_type(type)
318
+ type.to_s.split(', ').reject { |t| t.eql?('null') }.map { |t| Schema::Documentation::JSON_TYPE_TO_DOC.fetch(t, t) }.join('|')
319
+ end
320
+
321
+ # @param arg_spec [ArgumentSpec] expected argument
322
+ # @return [Completion] allowed values of the argument, or its type (and keys of its schema) as hint
323
+ def completion_argument(arg_spec)
324
+ return Completion.new(candidates: arg_spec.allowed.map { |v| [v.to_s, nil] }) if arg_spec.allowed
325
+ type_str =
326
+ case arg_spec.type
327
+ when :identifier then 'identifier'
328
+ when Array then arg_spec.type.map(&:name).join('|')
329
+ when Class then arg_spec.type.name
330
+ when nil then 'String'
331
+ else arg_spec.type.to_s
332
+ end
333
+ Completion.new(candidates: [], hint: completion_hint(type_str, arg_spec.schema))
334
+ end
335
+
336
+ # @param plugin_sym [Symbol] plugin name
337
+ # @return [String, nil] name of the application of the plugin
338
+ def completion_plugin_description(plugin_sym)
339
+ Plugins::Factory.instance.plugin_class(plugin_sym).application_name
340
+ rescue StandardError
341
+ nil
342
+ end
343
+
344
+ # @param text [String, nil] description
345
+ # @return [String, nil] first line, without final period, as displayed by the shell
346
+ def completion_description(text)
347
+ line = text.to_s.lines.first.to_s.strip.tr("\t", ' ').delete_suffix('.')
348
+ line.empty? ? nil : line
349
+ end
350
+
351
+ # Display the completion script for the given shell.
352
+ # Raw output, not formatted, so that it can be evaluated by the shell.
353
+ # @param shell [Symbol] One of `COMPLETION_SHELLS`
354
+ def completion_script(shell)
355
+ $stdout.write(File.read(File.join(COMPLETION_FOLDER, "#{Info::CMD_NAME}.#{COMPLETION_SHELLS[shell]}")))
356
+ Process.exit(0)
357
+ end
358
+ end
359
+ end
360
+ end
@@ -2,6 +2,48 @@
2
2
 
3
3
  module Aspera
4
4
  module Cli
5
+ # Exit status of the process, by category of error
6
+ module ExitCode
7
+ SUCCESS = 0
8
+ ERROR = 1
9
+ USAGE = 2
10
+ AUTH = 3
11
+ NOT_FOUND = 4
12
+ TRANSFER = 5
13
+ NETWORK = 6
14
+ API = 7
15
+ # 128 + SIGINT, as shells do
16
+ INTERRUPTED = 130
17
+ # For help and documentation
18
+ DESCRIPTIONS = {
19
+ SUCCESS => 'Success',
20
+ ERROR => 'Other error',
21
+ USAGE => 'Command line error: unknown command or option, invalid or missing argument',
22
+ AUTH => 'Authentication or authorization failure: SSH, HTTP 401 or 403, OAuth grant',
23
+ NOT_FOUND => 'Resource not found, or several resources match the identifier, HTTP 404',
24
+ TRANSFER => 'Transfer failure',
25
+ NETWORK => 'Network or TLS failure: name resolution, connection, timeout, certificate',
26
+ API => 'Other error returned by a remote API',
27
+ INTERRUPTED => 'Interrupted by user (Ctrl-C)'
28
+ }.freeze
29
+ # OAuth 2.0 error codes of a refused authentication (RFC 6749, RFC 6750)
30
+ OAUTH_AUTH_ERRORS = %w[invalid_client invalid_grant unauthorized_client access_denied invalid_token insufficient_scope].freeze
31
+ private_constant :OAUTH_AUTH_ERRORS
32
+
33
+ class << self
34
+ # @param error [Rest::CallError] Failed API call
35
+ # @return [Integer] Exit code for the HTTP status, or OAuth error
36
+ def for_call_error(error)
37
+ return AUTH if error.data.is_a?(Hash) && OAUTH_AUTH_ERRORS.include?(error.data['error'])
38
+ case error.response&.code
39
+ when '401', '403' then AUTH
40
+ when '404' then NOT_FOUND
41
+ else API
42
+ end
43
+ end
44
+ end
45
+ end
46
+
5
47
  # CLI base exception
6
48
  class Error < StandardError; end
7
49