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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- 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 =
|
|
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
|
|
6
|
-
# COMP_WORDS
|
|
7
|
-
local
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
#
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
data/lib/aspera/cli/error.rb
CHANGED
|
@@ -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
|
|