MuranoCLI 3.1.0 → 3.2.0.beta.1

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.
@@ -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
+