MuranoCLI 3.1.0 → 3.2.0.beta.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -79,29 +79,44 @@ module MrMurano
79
79
 
80
80
  CFG_SOLUTION_ID_KEYS = %w[application.id product.id].freeze
81
81
 
82
+ CFG_NET_PROTOCOLS = %w[https http].freeze
83
+
84
+ # If token not cached, set a very short timeout, to avoid database pollution.
85
+ CFG_AUTH_DEFAULT_TTL = 10
86
+
82
87
  def migrate_old_env
83
88
  return if ENV[CFG_OLD_ENV_NAME].nil?
84
- warning %(ENV "#{CFG_OLD_ENV_NAME}" is no longer supported. Rename it to "#{CFG_ENV_NAME}")
89
+ warning %(
90
+ ENV "#{CFG_OLD_ENV_NAME}" is no longer supported. Rename it to "#{CFG_ENV_NAME}"
91
+ ).strip
85
92
  unless ENV[CFG_ENV_NAME].nil?
86
- warning %(Both "#{CFG_ENV_NAME}" and "#{CFG_OLD_ENV_NAME}" defined, please remove "#{CFG_OLD_ENV_NAME}".)
93
+ warning %(
94
+ Both "#{CFG_ENV_NAME}" and "#{CFG_OLD_ENV_NAME}" defined, please remove "#{CFG_OLD_ENV_NAME}".
95
+ ).strip
87
96
  end
88
97
  ENV[CFG_ENV_NAME] = ENV[CFG_OLD_ENV_NAME]
89
98
  end
90
99
 
91
100
  def migrate_old_config(where)
101
+ migrate_warn_old_dir(where)
102
+ migrate_warn_old_cfg(where)
103
+ end
104
+
105
+ def migrate_warn_old_dir(where)
92
106
  # Check for dir.
93
- if (where + CFG_OLD_DIR_NAME).exist?
94
- warning %(Moving old directory "#{CFG_OLD_DIR_NAME}" to "#{CFG_DIR_NAME}" in "#{where}")
95
- (where + CFG_OLD_DIR_NAME).rename(where + CFG_DIR_NAME)
96
- end
107
+ return unless (where + CFG_OLD_DIR_NAME).exist?
108
+ warning %(Moving old directory "#{CFG_OLD_DIR_NAME}" to "#{CFG_DIR_NAME}" in "#{where}")
109
+ (where + CFG_OLD_DIR_NAME).rename(where + CFG_DIR_NAME)
110
+ end
97
111
 
112
+ def migrate_warn_old_cfg(where)
98
113
  # Check for cfg.
99
- # rubocop:disable Style/GuardClause
100
- if (where + CFG_OLD_FILE_NAME).exist?
101
- warning %(Moving old config "#{CFG_OLD_FILE_NAME}" to "#{CFG_FILE_NAME}" in "#{where}")
102
- (where + CFG_DIR_NAME).mkpath
103
- (where + CFG_OLD_FILE_NAME).rename(where + CFG_FILE_NAME)
104
- end
114
+ return unless (where + CFG_OLD_FILE_NAME).exist?
115
+ warning %(
116
+ Moving old config "#{CFG_OLD_FILE_NAME}" to "#{CFG_FILE_NAME}" in "#{where}"
117
+ ).strip
118
+ (where + CFG_DIR_NAME).mkpath
119
+ (where + CFG_OLD_FILE_NAME).rename(where + CFG_FILE_NAME)
105
120
  end
106
121
 
107
122
  def initialize(cmd_runner=nil)
@@ -138,6 +153,10 @@ module MrMurano
138
153
  # The user can exclude certain scopes.
139
154
  @exclude_scopes = []
140
155
 
156
+ # Poor man's input validator, for string 'choice's.
157
+ @value_options = {}
158
+ @value_errors = 0
159
+
141
160
  set_defaults
142
161
  end
143
162
 
@@ -154,6 +173,15 @@ module MrMurano
154
173
 
155
174
  set('net.host', CFG_HOST_BIZAPI, :defaults)
156
175
  set('net.protocol', 'https', :defaults)
176
+ @value_options['net.protocol'] = CFG_NET_PROTOCOLS
177
+
178
+ set('auth.ttl', nil, :defaults)
179
+ # See global options --[no-]token and --[no-]basic.
180
+ set('auth.scheme-token', nil, :defaults)
181
+ set('auth.scheme-basic', nil, :defaults)
182
+ # See global options --[no-]login and --[no-]cache.
183
+ set('auth.persist-token', nil, :defaults)
184
+ set('auth.persist-basic', nil, :defaults)
157
185
 
158
186
  set('location.base', @project_dir, :defaults) unless @project_dir.nil?
159
187
  set('location.files', 'files', :defaults)
@@ -163,7 +191,9 @@ module MrMurano
163
191
  set('location.resources', 'specs/resources.yaml', :defaults)
164
192
  set('location.cors', 'cors.yaml', :defaults)
165
193
 
166
- set('sync.bydefault', SyncRoot.instance.bydefault.join(' '), :defaults) if defined? SyncRoot
194
+ if defined? SyncRoot
195
+ set('sync.bydefault', SyncRoot.instance.bydefault.join(' '), :defaults)
196
+ end
167
197
 
168
198
  set('files.default_page', 'index.html', :defaults)
169
199
  set('files.searchFor', '**/*', :defaults)
@@ -382,6 +412,8 @@ module MrMurano
382
412
  init_curl_file
383
413
  # If user doesn't want paging, disable it.
384
414
  program :help_paging, !$cfg['tool.no-page'] unless $cfg['tool.no-page'].nil?
415
+ # Check for errors.
416
+ must_be_valid_values!
385
417
  end
386
418
 
387
419
  ## Load specified file into the config stack
@@ -480,6 +512,7 @@ module MrMurano
480
512
  # If key isn't dotted, then assume the tool section.
481
513
  ikey = section
482
514
  section = 'tool'
515
+ key = "#{section}.#{ikey}"
483
516
  end
484
517
 
485
518
  paths = @paths.select { |p| scope == p.kind }
@@ -493,8 +526,11 @@ module MrMurano
493
526
  cfg = paths.first
494
527
  data = cfg.data
495
528
  tomod = data[section]
496
- tomod[ikey] = value unless value.nil?
497
- tomod.delete(ikey) if value.nil?
529
+ if !value.nil?
530
+ change_value(key, tomod, ikey, value)
531
+ else
532
+ tomod.delete(ikey)
533
+ end
498
534
  data[section] = tomod
499
535
  # Remove empty sections to make test results more predictable.
500
536
  # Interesting: IniFile.each only returns sections with key-vals,
@@ -509,6 +545,24 @@ module MrMurano
509
545
  cfg.write
510
546
  end
511
547
 
548
+ def change_value(key, section, ikey, value)
549
+ choices = @value_options[key]
550
+ if !choices.nil? && !choices.include?(value)
551
+ warning %(Invalid value #{fancy_ticks(value)} for key #{fancy_ticks(key)})
552
+ warning %( Choose from: #{choices.join(', ')})
553
+ @value_errors += 1
554
+ else
555
+ section[ikey] = value
556
+ end
557
+ end
558
+
559
+ def must_be_valid_values!
560
+ return unless @value_errors > 0
561
+ error('You must correct the errors in your config (listed above) to continue.')
562
+ @runner.command_exit = 1
563
+ exit 1
564
+ end
565
+
512
566
  # key is <section>.<key>
513
567
  def [](key)
514
568
  get(key)
@@ -537,74 +591,101 @@ module MrMurano
537
591
  CFG_SCOPES.each do |scope|
538
592
  locats += "\n" unless first
539
593
  first = false
594
+ locats += location_of_scope(scope)
595
+ end
596
+ locats
597
+ end
540
598
 
541
- cfg_paths = @paths.select { |p| p.kind == scope }
599
+ def location_of_scope(scope)
600
+ locat = ''
542
601
 
543
- scope_q = fancy_ticks(scope)
544
- msg = "Scope: #{scope_q}\n\n"
545
- locats += Rainbow(msg).bright.underline
602
+ scope_q = fancy_ticks(scope)
603
+ scope_l = "Scope: #{scope_l}\n\n"
604
+ locat += Rainbow(scope_l).bright.underline
546
605
 
547
- if !cfg_paths.empty?
548
- cfg = cfg_paths.first
606
+ cfg_paths = @paths.select { |p| p.kind == scope }
607
+ if !cfg_paths.empty?
608
+ cfg = cfg_paths.first
609
+ locat += location_of_cfg(scope, cfg)
610
+ else
611
+ locat += location_not_found(scope, scope_q)
612
+ end
613
+ locat
614
+ end
549
615
 
550
- if !cfg.path.nil? && cfg.path.exist?
551
- path = "Path: #{cfg.path}\n"
552
- elsif %i[internal defaults].include? cfg.kind
553
- # cfg.path is nil.
554
- path = "Path: #{scope_q} config is not saved.\n"
555
- else
556
- path = "Path: #{scope_q} config does not exist.\n"
557
- end
558
- #locats += Rainbow(path).bright
559
- locats += path
560
- locats += "\n"
561
-
562
- skip_content = false
563
- if scope == :env
564
- locats += "Use the environment variable, MURANO_CONFIGFILE, to specify this config file.\n"
565
- locats += "\n"
566
- if ENV['MURANO_PASSWORD'].to_s.empty?
567
- locats += "The MURANO_PASSWORD environ is not set.\n"
568
- else
569
- locats += "The MURANO_PASSWORD environ is set and will be used.\n"
570
- end
571
- skip_content = !cfg.path.exist?
572
- end
573
- next if skip_content
574
- locats += "\n" if scope == :env
575
-
576
- base = IniFile.new
577
- base.merge! cfg.data
578
- content = base.to_s
579
- if !content.empty?
580
- locats += "Config:\n\n"
581
- #locats += base.to_s
582
- base.to_s.split("\n").each do |line|
583
- locats += ' ' + line + "\n"
584
- end
585
- else
586
- msg = "Config: Empty INI file.\n"
587
- #locats += Rainbow(msg).aqua.bright
588
- locats += msg
589
- end
616
+ def location_of_cfg(scope, cfg)
617
+ locat = ''
618
+
619
+ locat += location_path(scope, cfg)
620
+ locat += "\n"
621
+
622
+ locat_env, skip_content = location_env_and_skip?(scope, cfg)
623
+ locat += locat_env
624
+ return '' if skip_content
625
+ locat += "\n" if scope == :env
626
+
627
+ base = IniFile.new
628
+ base.merge! cfg.data
629
+ content = base.to_s
630
+ if !content.empty?
631
+ locat += "Config:\n\n"
632
+ base.to_s.split("\n").each do |line|
633
+ locat += ' ' + line + "\n"
634
+ end
635
+ else
636
+ config_l = "Config: Empty INI file.\n"
637
+ locat += config_l
638
+ end
639
+
640
+ locat
641
+ end
642
+
643
+ def location_path(scope, cfg)
644
+ scope_q = fancy_ticks(scope)
645
+ if !cfg.path.nil? && cfg.path.exist?
646
+ "Path: #{cfg.path}\n"
647
+ elsif %i[internal defaults].include? cfg.kind
648
+ # cfg.path is nil.
649
+ "Path: #{scope_q} config is not saved.\n"
650
+ else
651
+ "Path: #{scope_q} config does not exist.\n"
652
+ end
653
+ end
654
+
655
+ def location_env_and_skip?(scope, cfg)
656
+ locat_env = ''
657
+ skip_content = false
658
+ if scope == :env
659
+ locat_env += "Use the environment variable, MURANO_CONFIGFILE, to specify this config file.\n"
660
+ locat_env += "\n"
661
+ if ENV['MURANO_PASSWORD'].to_s.empty?
662
+ locat_env += "The MURANO_PASSWORD environ is not set.\n"
590
663
  else
591
- msg = "No config found for #{scope_q}.\n"
592
- if scope != :specified
593
- locats += Rainbow(msg).red.bright
594
- else
595
- locats += "Path: #{scope_q} config does not exist.\n\n"
596
- locats += "Use --configfile to specify this config file.\n"
597
- end
664
+ locat_env += "The MURANO_PASSWORD environ is set and will be used.\n"
598
665
  end
666
+ skip_content = !cfg.path.exist?
599
667
  end
600
- locats
668
+ [locat_env, skip_content]
669
+ end
670
+
671
+ def location_not_found(scope, scope_q)
672
+ locat = ''
673
+ config_l = "No config found for #{scope_q}.\n"
674
+ if scope != :specified
675
+ locat += Rainbow(config_l).red.bright
676
+ else
677
+ locat += "Path: #{scope_q} config does not exist.\n\n"
678
+ locat += "Use --configfile to specify this config file.\n"
679
+ end
680
+ locat
601
681
  end
602
682
 
603
683
  # To capture curl calls when running rspec, write to a file.
604
684
  def init_curl_file
605
685
  if self['tool.curldebug'] && !self['tool.curlfile'].to_s.strip.empty?
606
686
  if @curlfile_f.nil?
607
- @curlfile_f = File.open(self['tool.curlfile'], 'a')
687
+ open_curl_file
688
+ return if @curlfile_f.nil?
608
689
  # MEH: Call @curlfile_f.close() at some point? Or let Ruby do on exit.
609
690
  @curlfile_f << Time.now.to_s + "\n"
610
691
  @curlfile_f << "murano #{ARGV.join(' ')}\n"
@@ -616,6 +697,14 @@ module MrMurano
616
697
  end
617
698
  end
618
699
 
700
+ def open_curl_file
701
+ @curlfile_f = File.open(self['tool.curlfile'], 'a')
702
+ rescue Errno::ENOENT => err
703
+ @curlfile_f = nil
704
+ warning("Unable to create curlfile at: #{self['tool.curlfile']}")
705
+ error(err.message)
706
+ end
707
+
619
708
  # To set a different protocol://host, i.e., for local developing.
620
709
  def set_net_host(url_or_host, scope=:internal)
621
710
  return if url_or_host.nil?
@@ -12,8 +12,8 @@ require 'mime/types'
12
12
  require 'digest'
13
13
  require 'http/form_data'
14
14
  require 'MrMurano/Config'
15
- require 'MrMurano/http'
16
15
  require 'MrMurano/verbosing'
16
+ require 'MrMurano/AccountBase'
17
17
  require 'MrMurano/SolutionId'
18
18
  require 'MrMurano/SyncAllowed'
19
19
  require 'MrMurano/SyncUpDown'
@@ -22,12 +22,13 @@ module MrMurano
22
22
  ## The details of talking to the Content service.
23
23
  module Content
24
24
  class Base
25
- include Http
25
+ include AccountBase
26
26
  include Verbose
27
27
  include SolutionId
28
28
  include SyncAllowed
29
29
 
30
30
  def initialize
31
+ super
31
32
  @solntype = 'product.id'
32
33
  @uriparts_apidex = 1
33
34
  init_api_id!
@@ -5,12 +5,14 @@
5
5
  # vim:tw=0:ts=2:sw=2:et:ai
6
6
  # Unauthorized copying of this file is strictly prohibited.
7
7
 
8
+ require 'MrMurano/AccountBase'
8
9
  require 'MrMurano/Exchange-Element'
10
+ require 'MrMurano/verbosing'
9
11
 
10
12
  module MrMurano
11
13
  # The Exchange class represents an end user's Murano IoT Exchange Elements.
12
14
  class Exchange < Business
13
- include Http
15
+ include AccountBase
14
16
  include Verbose
15
17
 
16
18
  def get(path='', query=nil, &block)
@@ -11,8 +11,8 @@ require 'net/http'
11
11
  require 'pathname'
12
12
  require 'uri'
13
13
  require 'MrMurano/hash'
14
- require 'MrMurano/http'
15
14
  require 'MrMurano/verbosing'
15
+ require 'MrMurano/AccountBase'
16
16
  require 'MrMurano/Config'
17
17
  require 'MrMurano/SolutionId'
18
18
  require 'MrMurano/SyncRoot'
@@ -23,12 +23,13 @@ module MrMurano
23
23
  # This is where interfacing to real hardware happens.
24
24
  module Gateway
25
25
  class GweBase
26
- include Http
26
+ include AccountBase
27
27
  include Verbose
28
28
  include SolutionId
29
29
  include SyncAllowed
30
30
 
31
31
  def initialize
32
+ super
32
33
  @solntype = 'product.id'
33
34
  @uriparts_apidex = 1
34
35
  init_api_id!
@@ -0,0 +1,287 @@
1
+ # Copyright © 2016-2017 Exosite LLC. All Rights Reserved
2
+ # License: PROPRIETARY. See LICENSE.txt.
3
+ # frozen_string_literal: true
4
+
5
+ # vim:tw=0:ts=2:sw=2:et:ai
6
+ # Unauthorized copying of this file is strictly prohibited.
7
+
8
+ require 'base64'
9
+ require 'singleton'
10
+ require 'MrMurano/http'
11
+ require 'MrMurano/verbosing'
12
+ require 'MrMurano/Config'
13
+ require 'MrMurano/Passwords'
14
+
15
+ module MrMurano
16
+ class HttpAuthed
17
+ # The tool only works for a single user. To avoid fetching the
18
+ # token multiple times (and to avoid having to pass an Account
19
+ # object around), we use a singleton [i.e., a nasty global!].
20
+ include Singleton
21
+
22
+ include Http
23
+ include Verbose
24
+
25
+ attr_accessor :login_info
26
+ attr_reader :token_biz
27
+
28
+ def initialize
29
+ @login_info = nil
30
+ @pass_file = nil
31
+ @token_file = nil
32
+ token_reset
33
+ end
34
+
35
+ def add_headers(request)
36
+ # Note that calling super (Http.add_headers) is redundant,
37
+ # as AccountBase will have done it before calling us here.
38
+ super
39
+ return request if @logging_on
40
+ if $cfg['auth.scheme-basic']
41
+ ensure_password!
42
+ # encode64 adds newlines every 60 encoded characters.
43
+ encoded = Base64.encode64("#{user}:#{password}").delete("\n")
44
+ request['Authorization'] = "basic #{encoded}"
45
+ elsif $cfg['auth.scheme-token']
46
+ ensure_token!
47
+ request['Authorization'] = 'token ' + token unless token.to_s.empty?
48
+ else
49
+ # DEVS: If this happens, you may need to use 'authless'.
50
+ raise 'No authentication specified'
51
+ end
52
+ request
53
+ end
54
+
55
+ # ---------------------------------------------------------------------
56
+
57
+ def pass_file
58
+ return @pass_file unless @pass_file.nil?
59
+ pwd_path = $cfg.file_at(MrMurano::Passwords::FILENAME, :user)
60
+ @pass_file = MrMurano::Passwords.new(pwd_path)
61
+ @pass_file.load
62
+ @pass_file
63
+ end
64
+
65
+ def password_save(net_host, user_name, user_pass)
66
+ @password_user = user_pass
67
+ return unless $cfg['auth.persist-basic']
68
+ pass_file.set(net_host, user_name, user_pass)
69
+ pass_file.save
70
+ end
71
+
72
+ def password_get_or_ask(net_host, user_name)
73
+ user_pass = password(net_host, user_name)
74
+ if user_pass.nil?
75
+ user_pass = yield
76
+ password_save(net_host, user_name, user_pass)
77
+ end
78
+ user_pass
79
+ end
80
+
81
+ def password_remove(net_host, user_name)
82
+ pass_file.remove(net_host, user_name)
83
+ pass_file.save
84
+ end
85
+
86
+ def token_file
87
+ return @token_file unless @token_file.nil?
88
+ tok_path = $cfg.file_at(MrMurano::Tokens::FILENAME, :user)
89
+ @token_file = MrMurano::Tokens.new(tok_path)
90
+ @token_file.load
91
+ @token_file
92
+ end
93
+
94
+ # ---------------------------------------------------------------------
95
+
96
+ def get_host_user_pair(net_host, user_name)
97
+ net_host = host if net_host.to_s.empty?
98
+ user_name = user if user_name.to_s.empty?
99
+ [net_host, user_name]
100
+ end
101
+
102
+ def password(net_host=nil, user_name=nil)
103
+ return @password_user unless @password_user.to_s.empty?
104
+ net_host, user_name = get_host_user_pair(net_host, user_name)
105
+ @password_user = pass_file.get(net_host, user_name)
106
+ end
107
+
108
+ def ensure_password!
109
+ pwd = password(host, user)
110
+ return pwd unless pwd.to_s.empty?
111
+ error 'Missing password!'
112
+ exit 1
113
+ end
114
+
115
+ def token
116
+ return '' if defined?(@logging_on) && @logging_on
117
+ token_fetch_biz if @token_biz.to_s.empty?
118
+ @token_biz
119
+ end
120
+
121
+ def token_reset
122
+ @token_biz = ''
123
+ end
124
+
125
+ def ensure_token!
126
+ return token unless token.to_s.empty?
127
+ error 'Not logged in!'
128
+ exit 1
129
+ end
130
+
131
+ # ---------------------------------------------------------------------
132
+
133
+ def token_lookup
134
+ return @token_biz unless @token_biz.to_s.empty?
135
+ @token_biz = token_from_file
136
+ end
137
+
138
+ def token_from_file(net_host=nil, user_name=nil)
139
+ net_host, user_name = get_host_user_pair(net_host, user_name)
140
+ acc_token = token_file.lookup(net_host, user_name)
141
+ if acc_token.to_s.empty?
142
+ nil
143
+ elsif acc_token !~ /^[a-fA-F0-9]+$/
144
+ warning "Malformed '#{net_host}:#{user_name}' token: #{acc_token}"
145
+ nil
146
+ else
147
+ acc_token
148
+ end
149
+ end
150
+
151
+ def token_save(net_host=nil, user_name=nil)
152
+ net_host, user_name = get_host_user_pair(net_host, user_name)
153
+ return unless $cfg['auth.persist-token']
154
+ token_file.set(net_host, user_name, @token_biz)
155
+ token_file.save
156
+ end
157
+
158
+ def token_remove(net_host=nil, user_name=nil)
159
+ net_host, user_name = get_host_user_pair(net_host, user_name)
160
+ token_file.remove(net_host, user_name)
161
+ token_file.save
162
+ end
163
+
164
+ def token_forget(net_host=nil, user_name=nil, keep_password: false)
165
+ net_host, user_name = get_host_user_pair(net_host, user_name)
166
+ password_remove(net_host, user_name) unless keep_password
167
+ token_remove(net_host, user_name)
168
+ end
169
+
170
+ def token_resolve
171
+ token_old = @token_biz
172
+ token_old = ENV['MURANO_TOKEN'] if token_old.to_s.empty?
173
+ token_old = token_from_file if token_old.to_s.empty?
174
+ token_old
175
+ end
176
+
177
+ # ---------------------------------------------------------------------
178
+
179
+ def token_fetch_biz
180
+ @logging_on = true
181
+
182
+ token_old = token_resolve
183
+ token_reset
184
+
185
+ # If token found, verify it works.
186
+ unless token_old.to_s.empty?
187
+ get('token/' + token_old) do |request, http|
188
+ http.request(request) do |response|
189
+ if response.is_a?(Net::HTTPSuccess)
190
+ # response.body is, e.g., "{\"email\":\"xxx@yyy.zzz\",\"ttl\":172800}"
191
+ @token_biz = token_old
192
+ elsif response.is_a?(Net::HTTPNotFound)
193
+ # Token expired!
194
+ token_filed = token_from_file
195
+ token_remove if token_filed == token_old
196
+ end
197
+ end
198
+ end
199
+ unless @token_biz.to_s.empty?
200
+ # Token should already be saved.
201
+ @logging_on = false
202
+ return
203
+ end
204
+ # else, token was rejected.
205
+ end
206
+
207
+ creds = @login_info
208
+ if @login_info.nil?
209
+ if $cfg['auth.scheme-token']
210
+ # Using token auth, but no creds, so user probably needs to logon.
211
+ @logging_on = false
212
+ return
213
+ end
214
+ # This would be a developer error.
215
+ raise 'No @login_info'
216
+ end
217
+
218
+ MrMurano::Verbose.whirly_start('Logging in...')
219
+ post('token/', creds) do |request, http|
220
+ http.request(request) do |response|
221
+ reply = JSON.parse(response.body, json_opts)
222
+ if response.is_a?(Net::HTTPSuccess)
223
+ @token_biz = reply[:token]
224
+ elsif response.is_a?(Net::HTTPConflict) && reply[:message] == 'twofactor'
225
+ MrMurano::Verbose.whirly_interject do
226
+ # Prompt user for emailed code.
227
+ token_fetch_2fa(creds)
228
+ end
229
+ else
230
+ showHttpError(request, response)
231
+ error 'Check to see if username and password are correct.'
232
+ unless ENV['MURANO_PASSWORD'].to_s.empty?
233
+ pwd_path = $cfg.file_at('passwords', :user)
234
+ warning %(
235
+ NOTE: MURANO_PASSWORD specifies password; it was not read from #{pwd_path}
236
+ ).strip
237
+ end
238
+ end
239
+ end
240
+ end
241
+
242
+ if @token_biz.to_s.empty?
243
+ keep_password = true
244
+ token_forget(keep_password: keep_password)
245
+ else
246
+ token_save
247
+ end
248
+ MrMurano::Verbose.whirly_stop
249
+ @logging_on = false
250
+ end
251
+
252
+ def token_fetch_2fa(creds)
253
+ error 'Two-factor Authentication'
254
+ warning 'A verification code has been sent to your email.'
255
+ code = ask('Please enter the code here to continue: ').strip
256
+ unless code =~ /^[a-fA-F0-9]+$/
257
+ error 'Expected token to contain only numbers and hexadecimal letters.'
258
+ exit 1
259
+ end
260
+ MrMurano::Verbose.whirly_start('Verifying code...')
261
+
262
+ path = 'key/' + code
263
+
264
+ response = get(path)
265
+ # Response is, e.g., {
266
+ # purpose: "twofactor",
267
+ # status: "exists",
268
+ # email: "xxx@yyy.zzz",
269
+ # bizid: null,
270
+ # businessName: null, }
271
+ return if response.nil?
272
+
273
+ response = post(path, password: creds[:password])
274
+ # Response is, e.g., { "token": "..." }
275
+ return if response.nil?
276
+
277
+ @token_biz = response[:token]
278
+
279
+ MrMurano::Verbose.whirly_stop
280
+
281
+ warning %(
282
+ When finished, run `murano token delete` or `murano logout` to clear your token.
283
+ ).strip
284
+ end
285
+ end
286
+ end
287
+