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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +133 -0
- data/CONTRIBUTING.md +5 -2
- data/bin/ascli +3 -1
- data/docs/README.md +1006 -766
- data/lib/aspera/agent/base.rb +7 -2
- data/lib/aspera/agent/connect.rb +6 -8
- data/lib/aspera/agent/desktop.rb +2 -6
- data/lib/aspera/agent/direct.rb +52 -22
- data/lib/aspera/agent/node.rb +9 -8
- data/lib/aspera/agent/transferd.rb +2 -2
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +14 -12
- data/lib/aspera/api/ats.rb +1 -1
- data/lib/aspera/api/cos_node.rb +2 -2
- data/lib/aspera/api/faspex.rb +11 -7
- data/lib/aspera/api/httpgw.rb +38 -35
- data/lib/aspera/api/node.rb +39 -34
- data/lib/aspera/ascmd.rb +3 -1
- data/lib/aspera/ascp/installation.rb +63 -28
- data/lib/aspera/ascp/management.rb +1 -0
- data/lib/aspera/assert.rb +4 -0
- data/lib/aspera/cli/ascp_actions.rb +20 -41
- data/lib/aspera/cli/async_transfer_store.rb +12 -11
- data/lib/aspera/cli/bootstrapper.rb +14 -16
- data/lib/aspera/cli/command_line.rb +252 -0
- data/lib/aspera/cli/command_registry.rb +215 -37
- data/lib/aspera/cli/command_spec.rb +104 -15
- data/lib/aspera/cli/completion/ascli.bash +12 -0
- data/lib/aspera/cli/completion/ascli.fish +16 -0
- data/lib/aspera/cli/completion/ascli.zsh +19 -0
- data/lib/aspera/cli/context.rb +3 -0
- data/lib/aspera/cli/deprecation.rb +37 -0
- data/lib/aspera/cli/extended_value.rb +6 -3
- data/lib/aspera/cli/formatter.rb +94 -80
- data/lib/aspera/cli/gem_checker.rb +1 -1
- data/lib/aspera/cli/hints.rb +7 -6
- data/lib/aspera/cli/http.rb +22 -34
- data/lib/aspera/cli/info.rb +3 -0
- data/lib/aspera/cli/mcp_tool.rb +47 -83
- data/lib/aspera/cli/option_declarator.rb +33 -42
- data/lib/aspera/cli/option_registry.rb +69 -0
- data/lib/aspera/cli/option_types.rb +105 -0
- data/lib/aspera/cli/option_value.rb +278 -0
- data/lib/aspera/cli/options.schema.yaml +124 -15
- data/lib/aspera/cli/parser.rb +333 -862
- data/lib/aspera/cli/plugins/alee.rb +7 -4
- data/lib/aspera/cli/plugins/aoc.rb +545 -518
- data/lib/aspera/cli/plugins/ats.rb +59 -80
- data/lib/aspera/cli/plugins/base.rb +221 -265
- data/lib/aspera/cli/plugins/basic_auth.rb +2 -10
- data/lib/aspera/cli/plugins/config.rb +263 -184
- data/lib/aspera/cli/plugins/console.rb +103 -39
- data/lib/aspera/cli/plugins/cos.rb +6 -23
- data/lib/aspera/cli/plugins/factory.rb +3 -0
- data/lib/aspera/cli/plugins/faspex5.rb +204 -182
- data/lib/aspera/cli/plugins/faspio.rb +6 -11
- data/lib/aspera/cli/plugins/httpgw.rb +8 -11
- data/lib/aspera/cli/plugins/mcp.rb +20 -55
- data/lib/aspera/cli/plugins/node.rb +300 -327
- data/lib/aspera/cli/plugins/orchestrator.rb +152 -110
- data/lib/aspera/cli/plugins/preview.rb +96 -105
- data/lib/aspera/cli/plugins/server.rb +78 -53
- data/lib/aspera/cli/plugins/shares.rb +80 -131
- data/lib/aspera/cli/preset_actions.rb +44 -27
- data/lib/aspera/cli/preset_manager.rb +44 -19
- data/lib/aspera/cli/prompt.rb +36 -0
- data/lib/aspera/cli/result.rb +42 -36
- data/lib/aspera/cli/runner.rb +32 -59
- data/lib/aspera/cli/special_values.rb +5 -0
- data/lib/aspera/cli/sync_actions.rb +51 -46
- data/lib/aspera/cli/terminal_formatter.rb +9 -3
- data/lib/aspera/cli/transfer_actions.rb +14 -9
- data/lib/aspera/cli/transfer_agent.rb +34 -38
- data/lib/aspera/cli/transfer_progress.rb +290 -55
- data/lib/aspera/cli/vault_manager.rb +0 -17
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +5 -3
- data/lib/aspera/coverage.rb +1 -1
- data/lib/aspera/environment.rb +35 -5
- data/lib/aspera/faspex_gw.rb +2 -1
- data/lib/aspera/faspex_postproc.rb +1 -0
- data/lib/aspera/graphql.rb +5 -5
- data/lib/aspera/json_rpc/client.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +2 -2
- data/lib/aspera/keychain/factory.rb +2 -1
- data/lib/aspera/keychain/one_password_api.rb +1 -1
- data/lib/aspera/link_header.rb +2 -2
- data/lib/aspera/log.rb +47 -27
- data/lib/aspera/markdown.rb +2 -0
- data/lib/aspera/mime.rb +25 -0
- data/lib/aspera/node_emulator.rb +759 -0
- data/lib/aspera/oauth/base.rb +37 -26
- data/lib/aspera/oauth/factory.rb +7 -3
- data/lib/aspera/oauth/generic.rb +1 -1
- data/lib/aspera/oauth/json_credentials.rb +34 -0
- data/lib/aspera/oauth/jwt.rb +4 -5
- data/lib/aspera/oauth/web.rb +9 -8
- data/lib/aspera/oauth.rb +1 -0
- data/lib/aspera/persistency_folder.rb +1 -3
- data/lib/aspera/preview/file_types.rb +4 -4
- data/lib/aspera/preview/generator.rb +11 -1
- data/lib/aspera/preview/options.schema.yaml +119 -0
- data/lib/aspera/preview/terminal.rb +4 -3
- data/lib/aspera/preview/utils.rb +9 -6
- data/lib/aspera/products/connect.rb +1 -1
- data/lib/aspera/rainbow.rb +7 -0
- data/lib/aspera/rest/aspera_errors.rb +72 -0
- data/lib/aspera/rest/call_error.rb +27 -0
- data/lib/aspera/rest/client.rb +523 -0
- data/lib/aspera/rest/error_analyzer.rb +113 -0
- data/lib/aspera/rest/list.rb +149 -0
- data/lib/aspera/rest/parameters.rb +55 -0
- data/lib/aspera/rest/util.rb +176 -0
- data/lib/aspera/rest.rb +7 -621
- data/lib/aspera/schema/IBM Aspera Console-enhanced.yaml +1125 -0
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -20
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +1784 -0
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1230 -137
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +2395 -0
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +14 -13
- data/lib/aspera/schema/documentation.rb +13 -3
- data/lib/aspera/schema/reader.rb +12 -18
- data/lib/aspera/schema/registry.rb +23 -1
- data/lib/aspera/schema/validator.rb +92 -0
- data/lib/aspera/secret_hider.rb +36 -25
- data/lib/aspera/string_ext.rb +15 -0
- data/lib/aspera/temp_file_manager.rb +6 -5
- data/lib/aspera/transfer/parameters.rb +2 -0
- data/lib/aspera/transfer/spec.rb +1 -0
- data/lib/aspera/uri_reader.rb +11 -11
- data/lib/aspera/web_auth/index.html +147 -0
- data/lib/aspera/web_auth/server.rb +81 -0
- data.tar.gz.sig +0 -0
- metadata +43 -9
- metadata.gz.sig +0 -0
- data/lib/aspera/colors.rb +0 -79
- data/lib/aspera/node_simulator.rb +0 -344
- data/lib/aspera/preview/options.rb +0 -45
- data/lib/aspera/rest_call_error.rb +0 -25
- data/lib/aspera/rest_error_analyzer.rb +0 -111
- data/lib/aspera/rest_errors_aspera.rb +0 -58
- data/lib/aspera/rest_list.rb +0 -136
- data/lib/aspera/web_auth.rb +0 -211
|
@@ -9,6 +9,8 @@ require 'aspera/assert'
|
|
|
9
9
|
require 'aspera/yaml'
|
|
10
10
|
require 'digest'
|
|
11
11
|
require 'yaml'
|
|
12
|
+
require 'aspera/rainbow'
|
|
13
|
+
using Rainbow
|
|
12
14
|
|
|
13
15
|
module Aspera
|
|
14
16
|
module Cli
|
|
@@ -57,30 +59,38 @@ module Aspera
|
|
|
57
59
|
|
|
58
60
|
# Read and validate the YAML config file.
|
|
59
61
|
# Sets @config_presets and @checksum_on_disk.
|
|
62
|
+
# A file that cannot be read (YAML error, duplicate keys, access) is left untouched: it shall be fixed.
|
|
63
|
+
# A file with an unexpected structure is renamed for manual conversion, and a new one is created next time.
|
|
60
64
|
def read_config_file
|
|
61
65
|
Log.log.debug { "config file is: #{@config_file}".red }
|
|
62
66
|
if File.exist?(@config_file)
|
|
63
67
|
Log.log.debug { "loading #{@config_file}" }
|
|
64
|
-
|
|
68
|
+
begin
|
|
69
|
+
@config_presets = Yaml.safe_load(File.read(@config_file))
|
|
70
|
+
rescue Psych::SyntaxError
|
|
71
|
+
Log.log.error { "YAML error in config file: #{@config_file}" }
|
|
72
|
+
raise
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
raise Cli::Error, "Config file #{@config_file}: #{e.message}"
|
|
75
|
+
end
|
|
65
76
|
@checksum_on_disk = checksum
|
|
66
77
|
else
|
|
67
78
|
Log.log.warn { "No config file found. New configuration file: #{@config_file}" }
|
|
68
79
|
@config_presets = {Key::CONFIG => {Key::VERSION => 'new file'}}
|
|
69
80
|
# @checksum_on_disk remains nil: will be saved on first write
|
|
70
81
|
end
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
+
begin
|
|
83
|
+
validate_config_presets!
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
Log.log.debug { "-> #{e.class.name} : #{e}" }
|
|
86
|
+
if File.exist?(@config_file)
|
|
87
|
+
new_name = "#{@config_file}.pre#{Cli::VERSION}.manual_conversion_needed"
|
|
88
|
+
File.rename(@config_file, new_name)
|
|
89
|
+
Log.log.warn { "Renamed config file to #{new_name}." }
|
|
90
|
+
Log.log.warn('Manual Conversion is required. Next time, a new empty file will be created.')
|
|
91
|
+
end
|
|
92
|
+
raise Cli::Error, e.to_s
|
|
82
93
|
end
|
|
83
|
-
raise Cli::Error, e.to_s
|
|
84
94
|
end
|
|
85
95
|
|
|
86
96
|
# Save to disk only if content changed since last load/save.
|
|
@@ -113,8 +123,8 @@ module Aspera
|
|
|
113
123
|
Log.log.error do
|
|
114
124
|
"Default config name [#{default_name}] specified for plugin [#{plugin_name_sym}], but it does not exist in config file.\n" \
|
|
115
125
|
"Please fix: either create preset:\n" \
|
|
116
|
-
"#{Info::CMD_NAME} config
|
|
117
|
-
"or remove default:\n#{Info::CMD_NAME} config
|
|
126
|
+
"#{Info::CMD_NAME} config preset initialize #{default_name} @json:'{}'\n" \
|
|
127
|
+
"or remove default:\n#{Info::CMD_NAME} config preset unset #{Key::DEFAULTS} #{plugin_name_sym}"
|
|
118
128
|
end
|
|
119
129
|
raise Cli::Error, "No such preset: #{default_name}"
|
|
120
130
|
end
|
|
@@ -130,7 +140,11 @@ module Aspera
|
|
|
130
140
|
Aspera.assert(!include_path.include?(config_name), type: Cli::Error) { 'loop in include' }
|
|
131
141
|
include_path = include_path.clone
|
|
132
142
|
current = @config_presets
|
|
133
|
-
|
|
143
|
+
# A preset name may contain a dot (e.g. a host name): the longest name of an existing preset is used
|
|
144
|
+
parts = config_name.split(PRESET_DIG_SEPARATOR)
|
|
145
|
+
count = parts.length.downto(1).find { |n| @config_presets.key?(parts.first(n).join(PRESET_DIG_SEPARATOR)) } || 1
|
|
146
|
+
names = [parts.first(count).join(PRESET_DIG_SEPARATOR)] + parts.drop(count)
|
|
147
|
+
names.each do |name|
|
|
134
148
|
Aspera.assert_type(current, Hash, type: Cli::Error) { "sub key: #{include_path}" }
|
|
135
149
|
include_path.push(name)
|
|
136
150
|
current = current[name]
|
|
@@ -186,16 +200,27 @@ module Aspera
|
|
|
186
200
|
nil
|
|
187
201
|
end
|
|
188
202
|
|
|
189
|
-
# @
|
|
190
|
-
|
|
203
|
+
# @param create [Boolean] `true`: declare and create the global default preset if needed (to write in it)
|
|
204
|
+
# @return [String] name of the global default preset
|
|
205
|
+
def global_default_preset(create: true)
|
|
191
206
|
result = plugin_default_name(CONF_GLOBAL_SYM)
|
|
192
207
|
if result.nil?
|
|
193
208
|
result = Key::GLOBAL
|
|
194
|
-
|
|
209
|
+
if create
|
|
210
|
+
set_key(Key::DEFAULTS, CONF_GLOBAL_SYM, result)
|
|
211
|
+
# A default declaration requires the preset to exist
|
|
212
|
+
@config_presets[result] ||= {}
|
|
213
|
+
end
|
|
195
214
|
end
|
|
196
215
|
result
|
|
197
216
|
end
|
|
198
217
|
|
|
218
|
+
# @return [Boolean] true if `preset_name` is declared as the global default preset (does not create it)
|
|
219
|
+
def global_preset?(preset_name)
|
|
220
|
+
defaults = @config_presets[Key::DEFAULTS]
|
|
221
|
+
defaults.is_a?(Hash) && defaults[CONF_GLOBAL_SYM.to_s].eql?(preset_name)
|
|
222
|
+
end
|
|
223
|
+
|
|
199
224
|
# Set param in the global defaults preset
|
|
200
225
|
def set_global_default(key, value)
|
|
201
226
|
set_key(global_default_preset, key, value)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'aspera/assert'
|
|
5
|
+
|
|
6
|
+
module Aspera
|
|
7
|
+
module Cli
|
|
8
|
+
# Console input
|
|
9
|
+
module Prompt
|
|
10
|
+
# Prompt user for console input
|
|
11
|
+
# The prompt is displayed on `stderr`, like for sensitive input, so that `stdout` contains only results.
|
|
12
|
+
# @param prompt [String] prompt string to display
|
|
13
|
+
# @param sensitive [Boolean] whether to hide typed input
|
|
14
|
+
# @return [String] user input stripped of trailing newline
|
|
15
|
+
def prompt_user_input(prompt, sensitive: false)
|
|
16
|
+
return $stdin.getpass("#{prompt}> ") if sensitive
|
|
17
|
+
$stderr.print("#{prompt}> ")
|
|
18
|
+
line = $stdin.gets
|
|
19
|
+
Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
|
|
20
|
+
line.chomp
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Prompt user for input in a list of symbols
|
|
24
|
+
# @param prompt [String] prompt to display
|
|
25
|
+
# @param sym_list [Array] list of symbols to select from
|
|
26
|
+
# @return [Symbol] selected symbol
|
|
27
|
+
def prompt_user_input_in_list(prompt, sym_list)
|
|
28
|
+
loop do
|
|
29
|
+
input = prompt_user_input(prompt).to_sym
|
|
30
|
+
return input if sym_list.any? { |a| a.eql?(input) }
|
|
31
|
+
$stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/aspera/cli/result.rb
CHANGED
|
@@ -20,9 +20,11 @@ module Aspera
|
|
|
20
20
|
class Result
|
|
21
21
|
attr_reader :data, :fields
|
|
22
22
|
|
|
23
|
-
# @param data [Object
|
|
24
|
-
# @param fields [
|
|
23
|
+
# @param data [nil, Object] The result data
|
|
24
|
+
# @param fields [nil, Array<String>, Proc] Specification of fields to include
|
|
25
25
|
def initialize(data: nil, fields: nil)
|
|
26
|
+
Aspera.assert_type(fields, NilClass, Array, Proc)
|
|
27
|
+
Aspera.assert_array_all(fields, String) if fields.is_a?(Array)
|
|
26
28
|
@data = data
|
|
27
29
|
@fields = fields
|
|
28
30
|
end
|
|
@@ -39,18 +41,9 @@ module Aspera
|
|
|
39
41
|
# Apply field filtering once for formats that need it
|
|
40
42
|
filtered_data = formatter.filter_list_on_fields(@data)
|
|
41
43
|
case formatter.format_type
|
|
42
|
-
when :
|
|
43
|
-
|
|
44
|
-
when
|
|
45
|
-
Nagios.process(@data)
|
|
46
|
-
when :ruby
|
|
47
|
-
formatter.display_message(:data, PP.pp(filtered_data, +''))
|
|
48
|
-
when :json
|
|
49
|
-
formatter.display_message(:data, JSON.generate(filtered_data))
|
|
50
|
-
when :jsonpp
|
|
51
|
-
formatter.display_message(:data, JSON.pretty_generate(filtered_data))
|
|
52
|
-
when :yaml
|
|
53
|
-
formatter.display_message(:data, YAML.dump(filtered_data))
|
|
44
|
+
when :nagios then Nagios.process(@data)
|
|
45
|
+
when :text then formatter.display_message(:data, @data.to_s)
|
|
46
|
+
when *STRUCTURED_FORMATS then display_structured(formatter, filtered_data)
|
|
54
47
|
when :image
|
|
55
48
|
if @data.nil?
|
|
56
49
|
formatter.display_message(:data, formatter.special_format('null (no image)'))
|
|
@@ -82,10 +75,30 @@ module Aspera
|
|
|
82
75
|
# here, data is the image blob
|
|
83
76
|
formatter.display_message(:data, Preview::Terminal.build(data, **formatter.image_options))
|
|
84
77
|
end
|
|
85
|
-
else
|
|
86
|
-
Aspera.error_unexpected_value(formatter.format_type) { 'format' }
|
|
78
|
+
else Aspera.error_unexpected_value(formatter.format_type) { 'format' }
|
|
87
79
|
end
|
|
88
80
|
end
|
|
81
|
+
|
|
82
|
+
# Formats rendering the data structure itself
|
|
83
|
+
STRUCTURED_FORMATS = %i[ruby json jsonpp yaml].freeze
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Display a value in one of STRUCTURED_FORMATS
|
|
88
|
+
# @param formatter [Formatter] The formatter to use
|
|
89
|
+
# @param value [Object] Value to render
|
|
90
|
+
# @return [nil]
|
|
91
|
+
def display_structured(formatter, value)
|
|
92
|
+
text =
|
|
93
|
+
case formatter.format_type
|
|
94
|
+
when :ruby then PP.pp(value, +'')
|
|
95
|
+
when :json then JSON.generate(value)
|
|
96
|
+
when :jsonpp then JSON.pretty_generate(value)
|
|
97
|
+
when :yaml then YAML.dump(value)
|
|
98
|
+
else Aspera.error_unexpected_value(formatter.format_type) { 'structured format' }
|
|
99
|
+
end
|
|
100
|
+
formatter.display_message(:data, text)
|
|
101
|
+
end
|
|
89
102
|
end
|
|
90
103
|
|
|
91
104
|
# Special result types - each has its own class
|
|
@@ -101,8 +114,11 @@ module Aspera
|
|
|
101
114
|
|
|
102
115
|
def format(formatter)
|
|
103
116
|
case formatter.format_type
|
|
104
|
-
when :text, :nagios
|
|
117
|
+
when :text, :nagios
|
|
105
118
|
formatter.display_message(:data, @data.to_s)
|
|
119
|
+
when *STRUCTURED_FORMATS
|
|
120
|
+
# An empty list, or no value
|
|
121
|
+
display_structured(formatter, @data.eql?(:empty) ? [] : nil)
|
|
106
122
|
when :table, :csv
|
|
107
123
|
if @data.eql?(:nothing)
|
|
108
124
|
Log.log.debug('no result expected')
|
|
@@ -137,8 +153,10 @@ module Aspera
|
|
|
137
153
|
def format(formatter)
|
|
138
154
|
data = formatter.hide_secrets? ? formatter.hide_secrets_in_string(@data) : @data
|
|
139
155
|
case formatter.format_type
|
|
140
|
-
when :text, :nagios
|
|
156
|
+
when :text, :nagios
|
|
141
157
|
formatter.display_message(:data, data)
|
|
158
|
+
when *STRUCTURED_FORMATS
|
|
159
|
+
display_structured(formatter, data)
|
|
142
160
|
when :table, :csv
|
|
143
161
|
formatter.display_message(:info, data)
|
|
144
162
|
else
|
|
@@ -166,14 +184,8 @@ module Aspera
|
|
|
166
184
|
case formatter.format_type
|
|
167
185
|
when :text, :nagios
|
|
168
186
|
formatter.display_message(:data, data.to_s)
|
|
169
|
-
when
|
|
170
|
-
formatter
|
|
171
|
-
when :json
|
|
172
|
-
formatter.display_message(:data, JSON.generate(data))
|
|
173
|
-
when :jsonpp
|
|
174
|
-
formatter.display_message(:data, JSON.pretty_generate(data))
|
|
175
|
-
when :yaml
|
|
176
|
-
formatter.display_message(:data, YAML.dump(data))
|
|
187
|
+
when *STRUCTURED_FORMATS
|
|
188
|
+
display_structured(formatter, data)
|
|
177
189
|
when :table, :csv
|
|
178
190
|
formatter.display_message(:data, data)
|
|
179
191
|
else
|
|
@@ -228,7 +240,7 @@ module Aspera
|
|
|
228
240
|
end
|
|
229
241
|
|
|
230
242
|
# Object list result (Array of Hash)
|
|
231
|
-
# @note The
|
|
243
|
+
# @note The `total` parameter is used to display pagination information (e.g., "Items: 10/100")
|
|
232
244
|
class ObjectList < Result
|
|
233
245
|
attr_reader :total
|
|
234
246
|
|
|
@@ -276,14 +288,8 @@ module Aspera
|
|
|
276
288
|
formatter.display_table(@data.map { |i| {@name => i} }, [@name])
|
|
277
289
|
when :text
|
|
278
290
|
formatter.display_message(:data, @data.join("\n"))
|
|
279
|
-
when
|
|
280
|
-
formatter
|
|
281
|
-
when :json
|
|
282
|
-
formatter.display_message(:data, JSON.generate(@data))
|
|
283
|
-
when :jsonpp
|
|
284
|
-
formatter.display_message(:data, JSON.pretty_generate(@data))
|
|
285
|
-
when :yaml
|
|
286
|
-
formatter.display_message(:data, YAML.dump(@data))
|
|
291
|
+
when *STRUCTURED_FORMATS
|
|
292
|
+
display_structured(formatter, @data)
|
|
287
293
|
else
|
|
288
294
|
super
|
|
289
295
|
end
|
|
@@ -299,7 +305,7 @@ module Aspera
|
|
|
299
305
|
# @param id_result [String] Key in the result Hash used as item identifier
|
|
300
306
|
# @param fields [Object] Fields hint passed to the Result constructor (non-bulk only, when not :default)
|
|
301
307
|
# @param bfail [Boolean] When true, re-raise errors; when false, capture them as a status string
|
|
302
|
-
# @yieldparam item [Object] Each item in
|
|
308
|
+
# @yieldparam item [Object] Each item in `items`
|
|
303
309
|
# @yieldreturn [Hash, Array, nil] REST response; a Hash replaces the default id-keyed result
|
|
304
310
|
# @return [Result::ObjectList, Result::SingleObject]
|
|
305
311
|
def bulk(items, is_bulk:, command:, id_result: 'id', fields: :default, bfail: true)
|
data/lib/aspera/cli/runner.rb
CHANGED
|
@@ -166,7 +166,7 @@ module Aspera
|
|
|
166
166
|
rescue Cli::SchemaRequest => e; exception_info = {e: e, t: 'Schema'}
|
|
167
167
|
rescue Cli::Error => e; exception_info = {e: e, t: 'Tool', usage: true}
|
|
168
168
|
rescue Transfer::Error => e; exception_info = {e: e, t: 'Transfer'}
|
|
169
|
-
rescue
|
|
169
|
+
rescue Rest::CallError => e; exception_info = {e: e, t: 'Rest'}
|
|
170
170
|
rescue SocketError => e; exception_info = {e: e, t: 'Network'}
|
|
171
171
|
rescue StandardError => e; exception_info = {e: e, t: "Other(#{e.class.name})", debug: true}
|
|
172
172
|
rescue Interrupt => e; exception_info = {e: e, t: 'Interruption', debug: true}
|
|
@@ -228,29 +228,19 @@ module Aspera
|
|
|
228
228
|
Result::Text.new(usage_text(plugin: plugin))
|
|
229
229
|
end
|
|
230
230
|
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
case k.to_sym
|
|
243
|
-
when :level then Log.instance.level = v.to_sym
|
|
244
|
-
when :type then Log.instance.logger_type = v.to_sym
|
|
245
|
-
when :format then Log.instance.formatter = v
|
|
246
|
-
when :secrets then SecretHider.instance.log_secrets = BoolValue.true?(v)
|
|
247
|
-
else Aspera.error_unexpected_value(k) { 'log sub-option (level, type, format, secrets)' }
|
|
248
|
-
end
|
|
231
|
+
# `on_set` callback of the `log` option (dot-notation sub-properties): `level`, `type`, `format`, `secrets`
|
|
232
|
+
# @param value [Hash] sub-properties to set
|
|
233
|
+
def option_log=(value)
|
|
234
|
+
Aspera.assert_type(value, Hash)
|
|
235
|
+
value.each do |k, v|
|
|
236
|
+
case k.to_sym
|
|
237
|
+
when :level then Log.instance.level = v.to_sym
|
|
238
|
+
when :type then Log.instance.logger_type = v.to_sym
|
|
239
|
+
when :format then Log.instance.formatter = v
|
|
240
|
+
when :secrets then SecretHider.instance.log_secrets = Parser.get_from_list(v.to_s, 'log secrets', BoolValue::ALL)
|
|
241
|
+
else Aspera.error_unexpected_value(k) { 'log sub-option (level, type, format, secrets)' }
|
|
249
242
|
end
|
|
250
|
-
when :get
|
|
251
|
-
return {level: Log.instance.level, type: Log.instance.logger_type, format: Log.instance.formatter, secrets: SecretHider.instance.log_secrets}
|
|
252
243
|
end
|
|
253
|
-
nil
|
|
254
244
|
end
|
|
255
245
|
|
|
256
246
|
private
|
|
@@ -279,25 +269,11 @@ module Aspera
|
|
|
279
269
|
registry = plugin.class.command_registry
|
|
280
270
|
cmds = registry.children_of(path)
|
|
281
271
|
label = plugin.class.name.split('::').last.downcase
|
|
282
|
-
# Build label with positional argument slots inserted after each
|
|
272
|
+
# Build label with positional argument slots inserted after each command
|
|
283
273
|
# e.g. [:access_keys, :do, :download] -> "node access_keys do <access_key_id> download"
|
|
284
|
-
path.
|
|
285
|
-
label += " #{
|
|
286
|
-
|
|
287
|
-
seg_spec = registry[seg_path]
|
|
288
|
-
if seg_spec && registry.children_of(seg_path).any? && seg_spec.arguments
|
|
289
|
-
seg_spec.arguments.each do |arg_spec|
|
|
290
|
-
label += " <#{arg_spec.name}>"
|
|
291
|
-
end
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
if cmds.none?
|
|
295
|
-
leaf_spec = registry[path]
|
|
296
|
-
leaf_spec&.arguments&.each do |arg_spec|
|
|
297
|
-
argument = arg_spec.mandatory ? "<#{arg_spec.name}>" : "[#{arg_spec.name}]"
|
|
298
|
-
argument += '...' if arg_spec.multiple
|
|
299
|
-
label += " #{argument}"
|
|
300
|
-
end
|
|
274
|
+
path.each_index do |i|
|
|
275
|
+
label += " #{path[i]}"
|
|
276
|
+
registry.arguments_at(path[0..i]).each { |arg_spec| label += " #{arg_spec.syntax}" }
|
|
301
277
|
end
|
|
302
278
|
if cmds.any?
|
|
303
279
|
# Intermediate node: show current command + description, then list subcommands
|
|
@@ -314,7 +290,7 @@ module Aspera
|
|
|
314
290
|
spec = registry[path]
|
|
315
291
|
lines << "\nCOMMAND: #{label}"
|
|
316
292
|
lines << " #{spec.description}" if spec&.description
|
|
317
|
-
display_args =
|
|
293
|
+
display_args = registry.arguments_at(path)
|
|
318
294
|
# transfer_paths commands use --sources for the file list; default is positional args (@args)
|
|
319
295
|
if spec&.transfer_paths
|
|
320
296
|
file_desc = if spec.transfer_paths == :receive
|
|
@@ -372,9 +348,7 @@ module Aspera
|
|
|
372
348
|
# Create command line manager with arguments
|
|
373
349
|
@context.options = Parser.new(Info::CMD_NAME, @argv)
|
|
374
350
|
ExtendedValue.instance.on(EXTEND_ARGS) { |v| @context.options.args_as_extended(v) }
|
|
375
|
-
|
|
376
|
-
Formatter.declare_options(@context.options)
|
|
377
|
-
@context.formatter.bind_options(@context.options)
|
|
351
|
+
@context.formatter.declare_options(@context.options)
|
|
378
352
|
# Compare $0 with expected name
|
|
379
353
|
current_prog_name = File.basename($PROGRAM_NAME)
|
|
380
354
|
Aspera.assert(current_prog_name.eql?(Info::CMD_NAME), type: :warn) { "Please use '#{Info::CMD_NAME}' instead of '#{current_prog_name}'" }
|
|
@@ -395,15 +369,13 @@ module Aspera
|
|
|
395
369
|
@context.config = Plugins::Config.new(context: @context)
|
|
396
370
|
@context.man_header = true
|
|
397
371
|
# Sync cache_tokens from Config into the OAuth persist_mgr (now that option is parsed)
|
|
398
|
-
OAuth::Factory.instance.persist_mgr = @context.persistency if @context.
|
|
372
|
+
OAuth::Factory.instance.persist_mgr = @context.persistency if @context.options.get_option(:cache_tokens)
|
|
399
373
|
# Email service: depends on options declared by Config
|
|
400
374
|
@context.mailer = Mailer.new(@context.options, @context.main_folder)
|
|
401
375
|
# Secret finder: depends on options (:secret) and presets, both set by Bootstrapper
|
|
402
376
|
@context.secret_finder = SecretFinder.new(@context.options, @context.presets)
|
|
403
377
|
# The TransferAgent plugin may use the @preset parser
|
|
404
378
|
@context.transfer = TransferAgent.new(@context)
|
|
405
|
-
# Add commands for config plugin after all options have been added
|
|
406
|
-
@context.config.add_manual_header(false)
|
|
407
379
|
@context.validate
|
|
408
380
|
# Set banner when all environment is created so that additional extended value modifiers are known, e.g. @preset
|
|
409
381
|
end
|
|
@@ -450,32 +422,33 @@ module Aspera
|
|
|
450
422
|
Log.log.debug('declare_global_options')
|
|
451
423
|
@context.options.declare(:help, description: 'Show this message', allowed: Type::NONE, short: 'h') do
|
|
452
424
|
@option_help = true
|
|
453
|
-
@context.
|
|
425
|
+
@context.help_requested = true
|
|
454
426
|
end
|
|
455
427
|
@context.options.declare(:show_config, description: 'Display parameters used for the provided action', allowed: Type::NONE) { @option_show_config = true }
|
|
456
428
|
@context.options.declare(:version, description: 'Display version', allowed: Type::NONE, short: 'v') { @context.formatter.display_message(:data, Cli::VERSION); Process.exit(0) } # rubocop:disable Style/Semicolon
|
|
457
429
|
@context.options.declare(
|
|
458
430
|
:ui, description: 'Method to start browser',
|
|
459
|
-
allowed: USER_INTERFACES,
|
|
460
|
-
|
|
431
|
+
allowed: USER_INTERFACES, default: Environment.instance.url_method,
|
|
432
|
+
on_set: Environment.instance.method(:url_method=)
|
|
461
433
|
)
|
|
462
434
|
@context.options.declare(
|
|
463
435
|
:invalid_characters, description: 'Replacement character and invalid filename characters',
|
|
464
|
-
|
|
436
|
+
default: Environment.instance.file_illegal_characters,
|
|
437
|
+
on_set: Environment.instance.method(:file_illegal_characters=)
|
|
465
438
|
)
|
|
466
|
-
@context.options.declare(:log_level, description: 'Log level', allowed: Log::LEVELS,
|
|
467
|
-
@context.options.declare(:log_format, description: 'Log formatter', allowed: [Proc, Logger::Formatter, String],
|
|
468
|
-
@context.options.declare(:logger, description: 'Logging method', allowed: Log::LOG_TYPES,
|
|
469
|
-
@context.options.declare(:log, description: 'Logging options (dot-notation: level, type, format, secrets)',
|
|
439
|
+
@context.options.declare(:log_level, description: 'Log level', allowed: Log::LEVELS, default: Log.instance.level, on_set: Log.instance.method(:level=))
|
|
440
|
+
@context.options.declare(:log_format, description: 'Log formatter', allowed: [Proc, Logger::Formatter, String], on_set: Log.instance.method(:formatter=))
|
|
441
|
+
@context.options.declare(:logger, description: 'Logging method', allowed: Log::LOG_TYPES, on_set: Log.instance.method(:logger_type=))
|
|
442
|
+
@context.options.declare(:log, description: 'Logging options (dot-notation: level, type, format, secrets)', on_set: method(:option_log=), schema: Schema::Registry::LOG_OPTIONS)
|
|
470
443
|
@context.options.declare(:lock_port, description: 'Prevent dual execution of a command, e.g. in cron', allowed: Type::INTEGER)
|
|
471
444
|
@context.options.declare(:once_only, description: 'Process only new items (some commands)', allowed: Type::BOOLEAN, default: false)
|
|
472
|
-
@context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Type::BOOLEAN,
|
|
473
|
-
@context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Type::BOOLEAN,
|
|
474
|
-
@context.options.declare(:temp_folder, description: 'Temporary folder',
|
|
445
|
+
@context.options.declare(:log_secrets, description: 'Show passwords in logs', allowed: Type::BOOLEAN, default: SecretHider.instance.log_secrets, on_set: SecretHider.instance.method(:log_secrets=))
|
|
446
|
+
@context.options.declare(:clean_temp, description: 'Cleanup temporary files on exit', allowed: Type::BOOLEAN, default: TempFileManager.instance.cleanup_on_exit, on_set: TempFileManager.instance.method(:cleanup_on_exit=))
|
|
447
|
+
@context.options.declare(:temp_folder, description: 'Temporary folder', default: TempFileManager.instance.global_temp, on_set: TempFileManager.instance.method(:global_temp=))
|
|
475
448
|
@context.options.declare(:pid_file, description: 'Write process identifier to file, delete on exit')
|
|
476
449
|
@context.options.declare(
|
|
477
450
|
:parser, description: 'Default parser for structured parameters and options',
|
|
478
|
-
|
|
451
|
+
on_set: ExtendedValue.instance.method(:default_decoder=),
|
|
479
452
|
allowed: ExtendedValue::DEFAULT_DECODERS,
|
|
480
453
|
default: ExtendedValue::DEFAULT_DECODERS.first
|
|
481
454
|
)
|
|
@@ -5,10 +5,15 @@ module Aspera
|
|
|
5
5
|
# base class for plugins modules
|
|
6
6
|
module SpecialValues
|
|
7
7
|
# special values
|
|
8
|
+
# `INIT` to unitialize with all current IDs
|
|
8
9
|
INIT = 'INIT'
|
|
10
|
+
# `ALL` means "all ids"
|
|
9
11
|
ALL = 'ALL'
|
|
12
|
+
# `DEF` for the default list
|
|
10
13
|
DEF = 'DEF'
|
|
14
|
+
# `END` of arguments
|
|
11
15
|
EOA = 'END'
|
|
16
|
+
# `LATEST`
|
|
12
17
|
LATEST = 'LATEST'
|
|
13
18
|
end
|
|
14
19
|
end
|
|
@@ -16,29 +16,63 @@ module Aspera
|
|
|
16
16
|
%w[Syncd Error Confl Pconf] +
|
|
17
17
|
(23..24).map { |i| "P(#{i})" }).freeze
|
|
18
18
|
# Positional arguments shared by sync transfer commands (push/pull/bidi) and sync admin commands.
|
|
19
|
-
PATH_AND_INFO_ARGS = [{name: :path
|
|
20
|
-
# Names of the leaf commands registered under any `sync admin` node.
|
|
21
|
-
ADMIN_COMMANDS = %i[find status meta counters file_info overview query].freeze
|
|
19
|
+
PATH_AND_INFO_ARGS = [{name: :path}, {name: :sync_info, type: Hash, mandatory: false, default: {}}].freeze
|
|
22
20
|
# When a plugin class includes SyncActions, register the :sql option
|
|
23
|
-
# in that class's DSL registry so Base#initialize auto-declares it
|
|
21
|
+
# in that class's DSL registry so Base#initialize auto-declares it,
|
|
22
|
+
# and add the class-level DSL methods of ClassMethods.
|
|
24
23
|
class << self
|
|
25
24
|
def included(base)
|
|
26
25
|
base.option(:sql, description: 'SQL suffix appended to sqlite3 queries for admin subcommands (e.g. WHERE clause)')
|
|
26
|
+
base.extend(ClassMethods)
|
|
27
27
|
end
|
|
28
|
+
end
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
#
|
|
32
|
-
# @param
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
# Class-level DSL methods added to the plugin class that includes SyncActions.
|
|
31
|
+
module ClassMethods
|
|
32
|
+
# DSL class method: declare the `sync admin` node and its 7 leaf commands.
|
|
33
|
+
# @param parent [Symbol, Array<Symbol>] path relative to current scope, e.g. :admin
|
|
34
|
+
def sync_admin_commands(parent)
|
|
35
|
+
commands_under(parent, description: 'Manage sync database (admin operations)') do
|
|
36
|
+
command(
|
|
37
|
+
:find, description: 'Find sync database files', arguments: [{name: :path}],
|
|
38
|
+
action: lambda do |path:, **|
|
|
39
|
+
dbs = Sync::Operations.list_db_files(path)
|
|
40
|
+
Result::ObjectList.new(dbs.keys.map { |n| {name: n, path: dbs[n]} })
|
|
41
|
+
end
|
|
42
|
+
)
|
|
43
|
+
command(
|
|
44
|
+
:status, description: 'Show sync session status', arguments: PATH_AND_INFO_ARGS,
|
|
45
|
+
action: ->(path:, sync_info: {}, **) { Result::SingleObject.new(Sync::Operations.admin_status(async_info_from_args(path: path, sync_info: sync_info))) }
|
|
46
|
+
)
|
|
47
|
+
command(
|
|
48
|
+
:meta, description: 'Show sync session metadata', arguments: PATH_AND_INFO_ARGS,
|
|
49
|
+
action: lambda do |path:, sync_info: {}, **|
|
|
50
|
+
require 'aspera/sync/database'
|
|
51
|
+
Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).meta(options.get_option(:sql)))
|
|
52
|
+
end
|
|
53
|
+
)
|
|
54
|
+
command(
|
|
55
|
+
:counters, description: 'Show sync counters', arguments: PATH_AND_INFO_ARGS,
|
|
56
|
+
action: lambda do |path:, sync_info: {}, **|
|
|
57
|
+
require 'aspera/sync/database'
|
|
58
|
+
Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).counters(options.get_option(:sql)))
|
|
59
|
+
end
|
|
60
|
+
)
|
|
61
|
+
command(:file_info, description: 'Show per-file sync state', arguments: PATH_AND_INFO_ARGS, action: :action_sync_admin_file_info)
|
|
62
|
+
command(
|
|
63
|
+
:overview, description: 'Show sync database overview', arguments: PATH_AND_INFO_ARGS,
|
|
64
|
+
action: lambda do |path:, sync_info: {}, **|
|
|
65
|
+
require 'aspera/sync/database'
|
|
66
|
+
Result::ObjectList.new(db_from_args(path: path, sync_info: sync_info).overview, fields: %w[table name type])
|
|
67
|
+
end
|
|
68
|
+
)
|
|
69
|
+
command(
|
|
70
|
+
:query, description: 'Execute a raw SQL query', arguments: PATH_AND_INFO_ARGS,
|
|
71
|
+
action: lambda do |path:, sync_info: {}, **|
|
|
72
|
+
require 'aspera/sync/database'
|
|
73
|
+
Result.auto(db_from_args(path: path, sync_info: sync_info).execute(options.get_option(:sql, mandatory: true)))
|
|
74
|
+
end
|
|
75
|
+
)
|
|
42
76
|
end
|
|
43
77
|
end
|
|
44
78
|
end
|
|
@@ -119,25 +153,6 @@ module Aspera
|
|
|
119
153
|
Sync::Database.new(Sync::Operations.session_db_file(sync_info))
|
|
120
154
|
end
|
|
121
155
|
|
|
122
|
-
def action_sync_admin_status(path:, sync_info: {}, **)
|
|
123
|
-
Result::SingleObject.new(Sync::Operations.admin_status(async_info_from_args(path: path, sync_info: sync_info)))
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def action_sync_admin_find(path:, **)
|
|
127
|
-
dbs = Sync::Operations.list_db_files(path)
|
|
128
|
-
Result::ObjectList.new(dbs.keys.map { |n| {name: n, path: dbs[n]} })
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def action_sync_admin_meta(path:, sync_info: {}, **)
|
|
132
|
-
require 'aspera/sync/database'
|
|
133
|
-
Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).meta(options.get_option(:sql)))
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def action_sync_admin_counters(path:, sync_info: {}, **)
|
|
137
|
-
require 'aspera/sync/database'
|
|
138
|
-
Result::SingleObject.new(db_from_args(path: path, sync_info: sync_info).counters(options.get_option(:sql)))
|
|
139
|
-
end
|
|
140
|
-
|
|
141
156
|
def action_sync_admin_file_info(path:, sync_info: {}, **)
|
|
142
157
|
require 'aspera/sync/database'
|
|
143
158
|
result = db_from_args(path: path, sync_info: sync_info).file_info(options.get_option(:sql))
|
|
@@ -147,16 +162,6 @@ module Aspera
|
|
|
147
162
|
Result::ObjectList.new(result, fields: %w[sstate record_id f_meta_path message])
|
|
148
163
|
end
|
|
149
164
|
|
|
150
|
-
def action_sync_admin_overview(path:, sync_info: {}, **)
|
|
151
|
-
require 'aspera/sync/database'
|
|
152
|
-
Result::ObjectList.new(db_from_args(path: path, sync_info: sync_info).overview, fields: %w[table name type])
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
def action_sync_admin_query(path:, sync_info: {}, **)
|
|
156
|
-
require 'aspera/sync/database'
|
|
157
|
-
Result.auto(db_from_args(path: path, sync_info: sync_info).execute(options.get_option(:sql, mandatory: true)))
|
|
158
|
-
end
|
|
159
|
-
|
|
160
165
|
# Execute a sync transfer for a given direction.
|
|
161
166
|
# @param direction [Symbol] one of Sync::Operations::DIRECTIONS (:push, :pull, :bidi)
|
|
162
167
|
# @param path [String] local or remote path (depends on direction)
|
|
@@ -4,6 +4,8 @@ require 'aspera/environment'
|
|
|
4
4
|
require 'aspera/formatter_interface'
|
|
5
5
|
require 'aspera/markdown'
|
|
6
6
|
require 'aspera/assert'
|
|
7
|
+
require 'aspera/rainbow'
|
|
8
|
+
using Rainbow
|
|
7
9
|
|
|
8
10
|
module Aspera
|
|
9
11
|
module Cli
|
|
@@ -11,9 +13,13 @@ module Aspera
|
|
|
11
13
|
# @see FormatterInterface
|
|
12
14
|
# @see MarkdownFormatter (in build/lib/doc_helper.rb)
|
|
13
15
|
module TerminalFormatter
|
|
14
|
-
HINT = 'HINT:'.bg_green.gray.blink.freeze
|
|
15
16
|
include FormatterInterface
|
|
16
17
|
|
|
18
|
+
# Prefix for hint messages (evaluated on each call, as colors can be enabled or disabled by option)
|
|
19
|
+
def hint
|
|
20
|
+
'HINT:'.bg(:green).white.blink
|
|
21
|
+
end
|
|
22
|
+
|
|
17
23
|
# Format boolean with colored symbol (+/- or Y/ )
|
|
18
24
|
def tick(yes)
|
|
19
25
|
result =
|
|
@@ -29,7 +35,7 @@ module Aspera
|
|
|
29
35
|
# Format special values with colors (dim for empty, reverse for others)
|
|
30
36
|
def special_format(what)
|
|
31
37
|
result = "<#{what}>"
|
|
32
|
-
return %w[null empty].any? { |s| what.include?(s) } ? result.
|
|
38
|
+
return %w[null empty].any? { |s| what.include?(s) } ? result.faint : result.inverse
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
# Prepare table row for terminal display (word wrap arrays)
|
|
@@ -59,7 +65,7 @@ module Aspera
|
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
62
|
-
module_function :tick, :special_format, :check_row, :markdown_text
|
|
68
|
+
module_function :hint, :tick, :special_format, :check_row, :markdown_text
|
|
63
69
|
end
|
|
64
70
|
end
|
|
65
71
|
end
|