wisco 0.3.6 → 0.3.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc712af30ea2647f51bf72fac2bcf59f57105efcd8382d831afcadceee912f5c
4
- data.tar.gz: b49f08a071f353521d09469452e8508e31b78f4108545b6e723083e816ecc67d
3
+ metadata.gz: 942295e665d5ecea09078c736425531bb9846bfe29db26223bf52034ff4221b0
4
+ data.tar.gz: c56480bf18d42ac7797dd2b46f9d50146788f1590e673f4056bbe0ce55580347
5
5
  SHA512:
6
- metadata.gz: 4029281c6717dc8eeef9b9b3d86018998c5846dea82d5703d10229b84bf990c8beb6d8c73928315804878b8c9ab6a5bc541b9e46b34ddef7d601c2590e354331
7
- data.tar.gz: 7a4161111ce339ffe212cacdd08365f9464b79d02a729b492e04925ed2d2a9f10687fdcd79d0eef0fcadc321cf37c9a5fb05eca57f63f3d7da747b3438204e14
6
+ metadata.gz: 797b1ac153469a31d1edd1781510bac1ac7c05a7f7b2545de34a48d40540a79fcbffd189c851b85d830f9ca63424a5f9006f1a85915bdd9bd6da01f9fe2da392
7
+ data.tar.gz: 7133a41ae9da79285993f77ccd32e65ff49c3b3c0d203140c1f3c128bdfa8657c8cd882d435ab180b1bd51811103e3c51f16f8c49ee67150388e11a5062e1448
@@ -4,6 +4,7 @@ require 'workato/cli/exec_command'
4
4
  require_relative '../config'
5
5
  require_relative '../connector'
6
6
  require_relative '../path_utils'
7
+ require_relative '../exec_script'
7
8
 
8
9
  module Wisco
9
10
  module Commands
@@ -54,7 +55,7 @@ module Wisco
54
55
  next
55
56
  end
56
57
 
57
- input_files = resolve_input_files(input, fixtures_dir)
58
+ input_files = resolve_input_files(input, fixtures_dir, debug: debug)
58
59
 
59
60
  if input_files.empty?
60
61
  if %w[pick_lists methods].include?(section)
@@ -71,19 +72,32 @@ module Wisco
71
72
  end
72
73
 
73
74
  input_files.each do |input_file|
74
- execute_one(section, key, input_file, fixtures_dir,
75
- connector_full_path, connection, pagination: pagination, verbose: verbose,
76
- extended: extended, closure: closure, config_fields: config_fields,
77
- continue: continue, extended_input_schema: extended_input_schema,
78
- extended_output_schema: extended_output_schema, debug: debug)
75
+ if File.extname(input_file).downcase == '.rb'
76
+ execute_ruby_script(section, key, input_file, fixtures_dir, target_dir,
77
+ connector_full_path, connection, config,
78
+ pagination: pagination, verbose: verbose,
79
+ extended: extended, closure: closure, config_fields: config_fields,
80
+ continue: continue, extended_input_schema: extended_input_schema,
81
+ extended_output_schema: extended_output_schema, debug: debug)
82
+ else
83
+ execute_one(section, key, input_file, fixtures_dir,
84
+ connector_full_path, connection, pagination: pagination, verbose: verbose,
85
+ extended: extended, closure: closure, config_fields: config_fields,
86
+ continue: continue, extended_input_schema: extended_input_schema,
87
+ extended_output_schema: extended_output_schema, debug: debug)
88
+ end
79
89
  end
80
90
  end
81
91
  end
82
92
 
83
93
  # Resolve the list of input files to execute.
84
94
  # If an explicit input filename/path is given, use that (relative to fixtures_dir).
85
- # Otherwise glob execute_* in fixtures_dir and exclude files still containing the sentinel.
86
- def resolve_input_files(input, fixtures_dir)
95
+ # Otherwise glob execute_*.{json,rb} in fixtures_dir and exclude files still containing
96
+ # their respective sentinel.
97
+ #
98
+ # In debug mode, prints which candidate files were found and which were
99
+ # skipped (because they still contain a sentinel comment).
100
+ def resolve_input_files(input, fixtures_dir, debug: false)
87
101
  if input
88
102
  path = File.absolute_path?(input) ? input : File.join(fixtures_dir, input)
89
103
  unless File.exist?(path)
@@ -92,13 +106,28 @@ module Wisco
92
106
  end
93
107
  [path]
94
108
  else
95
- Dir.glob(File.join(fixtures_dir, 'execute_*')).select do |f|
96
- File.file?(f) && !file_has_sentinel?(f)
109
+ candidates = Dir.glob(File.join(fixtures_dir, 'execute_*.{json,rb}')).select { |f| File.file?(f) }
110
+ warn "[exec] candidate files: #{candidates.map { |f| File.basename(f) }}" if debug
111
+ candidates.reject do |f|
112
+ if file_has_sentinel?(f)
113
+ warn "[exec] skipped (sentinel): #{File.basename(f)}" if debug
114
+ true
115
+ else
116
+ false
117
+ end
97
118
  end
98
119
  end
99
120
  end
100
121
 
122
+ # Returns true if the file should be skipped. Each input format has its own
123
+ # sentinel convention:
124
+ # .json — first line exactly equals Wisco::Commands::Fixtures::SENTINEL
125
+ # .rb — first non-blank line matches `# WISCO_SKIP`
101
126
  def file_has_sentinel?(path)
127
+ if File.extname(path).downcase == '.rb'
128
+ return Wisco::ExecScript.sentinel?(path)
129
+ end
130
+
102
131
  first_line = begin
103
132
  File.open(path, &:readline).chomp
104
133
  rescue StandardError
@@ -223,6 +252,7 @@ module Wisco
223
252
  cmd = Workato::CLI::ExecCommand.new(path: exec_path, options: options)
224
253
  cmd.call
225
254
  rescue StandardError => e
255
+ FileUtils.rm_f(output_file)
226
256
  File.write(error_file, "#{e.class}: #{e.message}\n\n#{e.backtrace.join("\n")}\n")
227
257
  Wisco::TerminalOutput.emit_error("Error executing #{section}.#{key} with #{input_file ? File.basename(input_file) : 'no input'}: #{e.message}")
228
258
  Wisco::TerminalOutput.emit_error(" Details written to: #{error_file}")
@@ -237,6 +267,118 @@ module Wisco
237
267
  File.write(output_file, pretty + "\n")
238
268
  puts " Written: #{output_file}"
239
269
  end
270
+
271
+ # Handles `execute_*.rb` scripts: evaluates the script to produce dynamic
272
+ # input, writes it to <subdir>/input.json, runs the connector item via
273
+ # ExecCommand, and writes <subdir>/output.json or <subdir>/error.txt.
274
+ # The subdirectory is named after the script (without .rb), e.g.
275
+ # execute_input.rb -> execute_input/.
276
+ def execute_ruby_script(section, key, script_path, fixtures_dir, target_dir,
277
+ connector_full_path, connection, config,
278
+ pagination: true, verbose: true, debug: false,
279
+ extended: true, closure: nil, config_fields: nil, continue: nil,
280
+ extended_input_schema: nil, extended_output_schema: nil)
281
+ subdir = File.join(fixtures_dir, File.basename(script_path, '.rb'))
282
+ FileUtils.mkdir_p(subdir)
283
+ input_file = File.join(subdir, 'input.json')
284
+ output_file = File.join(subdir, 'output.json')
285
+ error_file = File.join(subdir, 'error.txt')
286
+
287
+ # Step 1: evaluate the script to obtain dynamic input
288
+ connection_name = config['connection'] || 'default'
289
+
290
+ begin
291
+ generated = Wisco::ExecScript.evaluate(
292
+ script_path: script_path,
293
+ connector_full_path: connector_full_path,
294
+ target_dir: target_dir,
295
+ connection_name: connection_name
296
+ )
297
+ rescue StandardError => e
298
+ FileUtils.rm_f(input_file)
299
+ FileUtils.rm_f(output_file)
300
+ File.write(error_file, "#{e.class}: #{e.message}\n\n#{Array(e.backtrace).join("\n")}\n")
301
+ Wisco::TerminalOutput.emit_error("Error evaluating #{File.basename(script_path)}: #{e.message}")
302
+ Wisco::TerminalOutput.emit_error(" Details written to: #{error_file}")
303
+ return
304
+ end
305
+
306
+ File.write(input_file, JSON.pretty_generate(generated) + "\n")
307
+ puts " Generated: #{input_file}"
308
+
309
+ # Step 2: build ExecCommand options (mirrors execute_one)
310
+ use_args = %w[pick_lists methods].include?(section)
311
+ exec_path = if use_args
312
+ "#{section}.#{key}"
313
+ elsif section == 'triggers'
314
+ pagination ? "#{section}.#{key}.poll" : "#{section}.#{key}.poll_page"
315
+ else
316
+ "#{section}.#{key}.execute"
317
+ end
318
+
319
+ options = { connector: connector_full_path, output: output_file }
320
+ options[:connection] = connection if connection
321
+ options[:verbose] = verbose
322
+ if use_args
323
+ options[:args] = input_file
324
+ else
325
+ options[:input] = input_file
326
+ end
327
+
328
+ options[:closure] = resolve_option_path(closure, fixtures_dir) if closure
329
+ options[:config_fields] = resolve_option_path(config_fields, fixtures_dir) if config_fields
330
+ options[:continue] = resolve_option_path(continue, fixtures_dir) if continue
331
+
332
+ # extended schema files still live at the item's fixtures_dir (not the subdir)
333
+ unless use_args
334
+ eis = if extended_input_schema
335
+ resolve_option_path(extended_input_schema, fixtures_dir)
336
+ elsif extended
337
+ f = File.join(fixtures_dir, 'input_fields.json')
338
+ File.exist?(f) ? f : nil
339
+ end
340
+ options[:extended_input_schema] = eis if eis
341
+
342
+ eos = if extended_output_schema
343
+ resolve_option_path(extended_output_schema, fixtures_dir)
344
+ elsif extended
345
+ f = File.join(fixtures_dir, 'output_fields.json')
346
+ File.exist?(f) ? f : nil
347
+ end
348
+ options[:extended_output_schema] = eos if eos
349
+ end
350
+
351
+ if debug
352
+ warn "[exec.rb] script: #{script_path}"
353
+ warn "[exec.rb] subdir: #{subdir}"
354
+ warn "[exec.rb] path: #{exec_path}"
355
+ warn "[exec.rb] connector: #{connector_full_path}"
356
+ warn "[exec.rb] connection: #{connection.inspect}"
357
+ warn "[exec.rb] #{use_args ? 'args' : 'input'}: #{input_file}"
358
+ warn "[exec.rb] output: #{output_file}"
359
+ end
360
+
361
+ # Step 3: invoke ExecCommand against the generated input
362
+ begin
363
+ cmd = Workato::CLI::ExecCommand.new(path: exec_path, options: options)
364
+ cmd.call
365
+ rescue StandardError => e
366
+ FileUtils.rm_f(input_file)
367
+ FileUtils.rm_f(output_file)
368
+ File.write(error_file, "#{e.class}: #{e.message}\n\n#{e.backtrace.join("\n")}\n")
369
+ Wisco::TerminalOutput.emit_error("Error executing #{section}.#{key} with #{File.basename(script_path)}: #{e.message}")
370
+ Wisco::TerminalOutput.emit_error(" Details written to: #{error_file}")
371
+ return
372
+ end
373
+
374
+ FileUtils.rm_f(error_file)
375
+
376
+ return unless File.exist?(output_file)
377
+
378
+ pretty = JSON.pretty_generate(JSON.parse(File.read(output_file)))
379
+ File.write(output_file, pretty + "\n")
380
+ puts " Written: #{output_file}"
381
+ end
240
382
  end
241
383
  end
242
384
  end
@@ -8,11 +8,34 @@ require_relative '../path_utils'
8
8
  module Wisco
9
9
  module Commands
10
10
  module Fixtures
11
- SENTINEL = '# Remove this comment before updating. Files that include this line will be overwritten.'
11
+ SENTINEL = '# Remove this comment before updating. Files that include this line will be overwritten.'
12
+ RB_SENTINEL = '# WISCO_SKIP'
13
+
14
+ RB_TEMPLATE = <<~RUBY
15
+ # WISCO_SKIP
16
+ # Remove the WISCO_SKIP line above once this script is ready to run.
17
+ #
18
+ # The last expression in this file becomes the input passed to the connector item.
19
+ # Helper methods available inside the script:
20
+ # call_method(:name, *args) — invoke a methods: entry on the connector
21
+ # call_pick_list(:name, *args) — invoke a pick_lists: entry on the connector
22
+ # connection — Hash of decrypted settings.yaml(.enc)
23
+ #
24
+ # Example:
25
+ # require 'securerandom'
26
+ # {
27
+ # order_number: "ORD-\#{SecureRandom.hex(4).upcase}",
28
+ # created_at: Time.now.iso8601
29
+ # }
30
+
31
+ {
32
+ # TODO: fill in fields
33
+ }
34
+ RUBY
12
35
 
13
36
  module_function
14
37
 
15
- def run(path_arg, target_dir, overwrite: false, debug: false)
38
+ def run(path_arg, target_dir, overwrite: false, ruby: false, debug: false)
16
39
  target_dir = File.expand_path(target_dir)
17
40
  config_path = Wisco.config_path(target_dir)
18
41
 
@@ -44,8 +67,10 @@ module Wisco
44
67
 
45
68
  if section == 'pick_lists'
46
69
  process_pick_list(key, connector, fixtures_dir, overwrite: overwrite)
70
+ generate_execute_input_rb(fixtures_dir, overwrite: overwrite) if ruby
47
71
  elsif section == 'methods'
48
72
  process_method(key, connector, fixtures_dir, overwrite: overwrite)
73
+ generate_execute_input_rb(fixtures_dir, overwrite: overwrite) if ruby
49
74
  else
50
75
  # ── config_fields pre-check ──────────────────────────────────────
51
76
  item = connector[section.to_sym]&.[](key.to_sym)
@@ -76,6 +101,7 @@ module Wisco
76
101
  )
77
102
 
78
103
  generate_execute_input(input_fields_file, fixtures_dir, overwrite: overwrite, debug: debug)
104
+ generate_execute_input_rb(fixtures_dir, overwrite: overwrite) if ruby
79
105
 
80
106
  # ── output_fields ────────────────────────────────────────────────
81
107
  output_fields_file = File.join(fixtures_dir, 'output_fields.json')
@@ -133,6 +159,35 @@ module Wisco
133
159
  puts " Written: #{output_file}"
134
160
  end
135
161
 
162
+ # Writes an `execute_input.rb` template into `fixtures_dir`. The template
163
+ # is prefixed with `# WISCO_SKIP` so it is identifiable as unedited and
164
+ # will be skipped by `wisco exec` until the user removes that line.
165
+ #
166
+ # Overwrite rules mirror generate_execute_input:
167
+ # - File absent -> write
168
+ # - File present, # WISCO_SKIP on L1 -> overwrite (still a template)
169
+ # - File present, no sentinel -> skip (user-edited); force with --overwrite
170
+ def generate_execute_input_rb(fixtures_dir, overwrite: false)
171
+ output_file = File.join(fixtures_dir, 'execute_input.rb')
172
+
173
+ if File.exist?(output_file)
174
+ first_line = begin
175
+ File.open(output_file, &:readline).chomp
176
+ rescue StandardError
177
+ ''
178
+ end
179
+ has_sentinel = first_line.strip == RB_SENTINEL
180
+
181
+ unless has_sentinel || overwrite
182
+ puts " Skipped (user-edited): #{output_file}"
183
+ return
184
+ end
185
+ end
186
+
187
+ File.write(output_file, RB_TEMPLATE)
188
+ puts " Written: #{output_file}"
189
+ end
190
+
136
191
  # Recursively converts a Workato schema array into a template hash.
137
192
  # Scalars become "<type_value_required|optional>" placeholder strings.
138
193
  # Objects expand into a nested hash via their properties.
@@ -0,0 +1,160 @@
1
+ require 'workato/connector/sdk'
2
+ require_relative 'terminal_output'
3
+
4
+ module Wisco
5
+ # Evaluates an `execute_*.rb` script and returns the value of its last
6
+ # expression. The script is eval'd inside a binding that exposes helpers
7
+ # (`call_method`, `call_pick_list`, `connection`) so the script can build
8
+ # dynamic input that varies per run.
9
+ module ExecScript
10
+ SENTINEL_REGEX = /\A\s*#\s*WISCO_SKIP\b/.freeze
11
+
12
+ module_function
13
+
14
+ # Returns true if the file's first non-blank line is a `# WISCO_SKIP`
15
+ # comment, in which case the script should be skipped by `wisco exec`.
16
+ def sentinel?(path)
17
+ File.foreach(path) do |line|
18
+ stripped = line.strip
19
+ next if stripped.empty?
20
+ return SENTINEL_REGEX.match?(line)
21
+ end
22
+ false
23
+ rescue StandardError
24
+ false
25
+ end
26
+
27
+ # Evaluates the script at `script_path` and returns the value of its last
28
+ # expression. Raises StandardError subclasses on:
29
+ # - syntax / runtime errors in the script
30
+ # - invalid return value (must be a Hash or Array)
31
+ #
32
+ # `connector_full_path` — absolute path to connector.rb (used to build
33
+ # the SDK Connector for helper invocations).
34
+ # `target_dir` — connector project root (used to find
35
+ # settings.yaml(.enc) + master.key).
36
+ # `connection_name` — connection key inside settings.yaml; defaults to 'default'.
37
+ def evaluate(script_path:, connector_full_path:, target_dir:, connection_name: 'default')
38
+ host = ScriptHost.new(connector_full_path, target_dir, connection_name)
39
+ source = File.read(script_path)
40
+ result = host.instance_eval(source, script_path, 1)
41
+
42
+ unless result.is_a?(Hash) || result.is_a?(Array)
43
+ raise InvalidReturn,
44
+ "Script must return a Hash (for actions/triggers) or Array/Hash (for methods/pick_lists). " \
45
+ "Got: #{result.class}"
46
+ end
47
+
48
+ result
49
+ end
50
+
51
+ class InvalidReturn < StandardError; end
52
+
53
+ # Host object that the script is eval'd against. Exposes the helper API.
54
+ # Defined as a class (not a Module) so `instance_eval` gives the script
55
+ # access to standard top-level Ruby plus our helpers, without polluting
56
+ # Object globally.
57
+ class ScriptHost
58
+ def initialize(connector_full_path, target_dir, connection_name)
59
+ @connector_full_path = connector_full_path
60
+ @target_dir = target_dir
61
+ @connection_name = connection_name || 'default'
62
+ @sdk_connector = nil
63
+ @connection_loaded = false
64
+ @connection_cached = nil
65
+ @warned_settings = false
66
+ end
67
+
68
+ # Hash of decrypted settings for the configured connection. Returns {}
69
+ # and emits a one-time warning if settings can't be loaded.
70
+ def connection
71
+ return @connection_cached if @connection_loaded
72
+
73
+ @connection_loaded = true
74
+ @connection_cached = load_connection
75
+ end
76
+
77
+ # Invoke a connector `methods:` entry. Args are forwarded to the lambda
78
+ # as-is. Returns the lambda's return value.
79
+ #
80
+ # Example:
81
+ # call_method(:format_timestamp, Time.now)
82
+ def call_method(name, *args)
83
+ proxy = sdk_connector.methods
84
+ unless proxy.respond_to?(name.to_sym)
85
+ raise InvalidReturn, "No methods entry named '#{name}' found in connector."
86
+ end
87
+ proxy.public_send(name.to_sym, *args)
88
+ end
89
+
90
+ # Invoke a connector `pick_lists:` entry. `args` is a Hash of named
91
+ # arguments for dependent pick lists. The SDK supplies `connection` to
92
+ # the lambda automatically.
93
+ #
94
+ # Examples:
95
+ # call_pick_list(:active_customers)
96
+ # call_pick_list(:customer_orders, customer_id: 123)
97
+ def call_pick_list(name, **args)
98
+ proxy = sdk_connector.pick_lists
99
+ unless proxy.respond_to?(name.to_sym)
100
+ raise InvalidReturn, "No pick_lists entry named '#{name}' found in connector."
101
+ end
102
+ # SDK signature: pick_list_name(settings = nil, args = {})
103
+ # Passing nil for settings tells the SDK to reuse the connector's settings.
104
+ proxy.public_send(name.to_sym, nil, args)
105
+ end
106
+
107
+ private
108
+
109
+ def sdk_connector
110
+ @sdk_connector ||= Workato::Connector::Sdk::Connector.from_file(@connector_full_path, connection)
111
+ end
112
+
113
+ # Tries (in order): settings.yaml.enc + master.key, then settings.yaml.
114
+ # Uses Workato::Connector::Sdk::Settings so encryption is handled by the
115
+ # SDK's own loader (same path it uses for `wisco exec`). Returns {} and
116
+ # warns once on any failure.
117
+ def load_connection
118
+ enc_file = File.join(@target_dir, 'settings.yaml.enc')
119
+ key_file = File.join(@target_dir, 'master.key')
120
+ yaml_file = File.join(@target_dir, 'settings.yaml')
121
+
122
+ if File.exist?(enc_file) && File.exist?(key_file)
123
+ read_settings(enc_file) do |name|
124
+ Workato::Connector::Sdk::Settings.from_encrypted_file(enc_file, key_file, name)
125
+ end
126
+ elsif File.exist?(yaml_file)
127
+ read_settings(yaml_file) do |name|
128
+ Workato::Connector::Sdk::Settings.from_file(yaml_file, name)
129
+ end
130
+ else
131
+ warn_settings("No settings.yaml or settings.yaml.enc found in #{@target_dir}")
132
+ {}
133
+ end
134
+ end
135
+
136
+ # Reads settings using a Settings.from_* loader. Tries with the configured
137
+ # connection name first; if that key isn't present at the top level
138
+ # (KeyError), falls back to loading the whole file as a flat hash.
139
+ def read_settings(path)
140
+ hash = begin
141
+ yield(@connection_name)
142
+ rescue KeyError
143
+ yield(nil)
144
+ end
145
+ return {} unless hash.respond_to?(:to_h)
146
+
147
+ hash.to_h
148
+ rescue StandardError => e
149
+ warn_settings("Failed to load settings from #{path}: #{e.message}")
150
+ {}
151
+ end
152
+
153
+ def warn_settings(msg)
154
+ return if @warned_settings
155
+ @warned_settings = true
156
+ Wisco::TerminalOutput.emit_warning("[WARN] connection helper: #{msg}. Returning {}.")
157
+ end
158
+ end
159
+ end
160
+ end
data/lib/wisco/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Wisco
2
- VERSION = '0.3.6'
2
+ VERSION = '0.3.7'
3
3
  end
data/lib/wisco.rb CHANGED
@@ -127,12 +127,15 @@ module Wisco
127
127
  get_users auto-detect section
128
128
  DESC
129
129
  option :overwrite, type: :boolean, default: false, desc: 'Overwrite execute_input.json even if user-edited'
130
+ option :ruby, type: :boolean, default: false,
131
+ desc: 'Also scaffold an execute_input.rb template for dynamic input scripts'
130
132
  option :debug, type: :boolean, default: false, desc: 'Print ExecCommand call details'
131
133
  def fixtures(path_arg, target_dir = nil)
132
134
  Wisco::Commands::Fixtures.run(
133
135
  path_arg,
134
136
  target_dir || Dir.pwd,
135
137
  overwrite: options[:overwrite],
138
+ ruby: options[:ruby],
136
139
  debug: options[:debug]
137
140
  )
138
141
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wisco
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: 0.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - mbillington
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-06 00:00:00.000000000 Z
11
+ date: 2026-06-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -77,6 +77,7 @@ files:
77
77
  - lib/wisco/commands/schema.rb
78
78
  - lib/wisco/config.rb
79
79
  - lib/wisco/connector.rb
80
+ - lib/wisco/exec_script.rb
80
81
  - lib/wisco/path_utils.rb
81
82
  - lib/wisco/profile.rb
82
83
  - lib/wisco/terminal_output.rb