bashhub 3.0.2__tar.gz → 3.0.3__tar.gz

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.
Files changed (49) hide show
  1. {bashhub-3.0.2/bashhub.egg-info → bashhub-3.0.3}/PKG-INFO +8 -2
  2. {bashhub-3.0.2 → bashhub-3.0.3}/README.md +1 -1
  3. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/bashhub.py +41 -42
  4. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/bashhub_globals.py +15 -11
  5. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/bashhub_setup.py +155 -53
  6. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/bh.py +23 -11
  7. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/i_search.py +31 -25
  8. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/interactive_search.py +22 -19
  9. bashhub-3.0.3/bashhub/model/__init__.py +11 -0
  10. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/model/command.py +18 -19
  11. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/model/command_form.py +6 -3
  12. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/model/min_command.py +4 -4
  13. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/model/serializable.py +16 -9
  14. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/model/status_view.py +11 -3
  15. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/model/system.py +13 -13
  16. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/rest_client.py +113 -56
  17. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/shell_utils.py +7 -5
  18. bashhub-3.0.3/bashhub/validation.py +54 -0
  19. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/version.py +1 -1
  20. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/view/status.py +6 -2
  21. {bashhub-3.0.2 → bashhub-3.0.3/bashhub.egg-info}/PKG-INFO +8 -2
  22. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub.egg-info/SOURCES.txt +5 -1
  23. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub.egg-info/requires.txt +7 -0
  24. {bashhub-3.0.2 → bashhub-3.0.3}/pyproject.toml +28 -0
  25. bashhub-3.0.3/tests/test_bashhub_setup.py +418 -0
  26. bashhub-3.0.3/tests/test_bh.py +32 -0
  27. bashhub-3.0.3/tests/test_rest_client.py +207 -0
  28. bashhub-3.0.3/tests/test_validation.py +76 -0
  29. bashhub-3.0.2/bashhub/model/__init__.py +0 -6
  30. bashhub-3.0.2/tests/test_bashhub_setup.py +0 -62
  31. {bashhub-3.0.2 → bashhub-3.0.3}/LICENSE.md +0 -0
  32. {bashhub-3.0.2 → bashhub-3.0.3}/MANIFEST.in +0 -0
  33. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/__init__.py +0 -0
  34. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/shell/bashhub.fish +0 -0
  35. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/shell/bashhub.sh +0 -0
  36. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/shell/bashhub.zsh +0 -0
  37. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/shell/deps/bash-preexec.sh +0 -0
  38. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/shell/deps/lib-bashhub.sh +0 -0
  39. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub/view/__init__.py +0 -0
  40. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub.egg-info/dependency_links.txt +0 -0
  41. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub.egg-info/entry_points.txt +0 -0
  42. {bashhub-3.0.2 → bashhub-3.0.3}/bashhub.egg-info/top_level.txt +0 -0
  43. {bashhub-3.0.2 → bashhub-3.0.3}/setup.cfg +0 -0
  44. {bashhub-3.0.2 → bashhub-3.0.3}/tests/test_bashhub.py +0 -0
  45. {bashhub-3.0.2 → bashhub-3.0.3}/tests/test_bashhub_globals.py +0 -0
  46. {bashhub-3.0.2 → bashhub-3.0.3}/tests/test_command.py +0 -0
  47. {bashhub-3.0.2 → bashhub-3.0.3}/tests/test_command_form.py +0 -0
  48. {bashhub-3.0.2 → bashhub-3.0.3}/tests/test_interactive_search.py +0 -0
  49. {bashhub-3.0.2 → bashhub-3.0.3}/tests/test_shell_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bashhub
3
- Version: 3.0.2
3
+ Version: 3.0.3
4
4
  Summary: Bashhub.com python client
5
5
  Author-email: Ryan Caloras <ryan@bashhub.com>
6
6
  License-Expression: Apache-2.0
@@ -24,9 +24,15 @@ Requires-Dist: inflection>=0.5.1
24
24
  Requires-Dist: humanize>=4.13.0
25
25
  Provides-Extra: test
26
26
  Requires-Dist: pytest>=3.3.1; extra == "test"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=3.3.1; extra == "dev"
29
+ Requires-Dist: mypy>=1.0; extra == "dev"
30
+ Requires-Dist: ruff>=0.4; extra == "dev"
31
+ Requires-Dist: types-requests; extra == "dev"
32
+ Requires-Dist: types-python-dateutil; extra == "dev"
27
33
  Dynamic: license-file
28
34
 
29
- <img src="https://bashhub.com/assets/images/bashhub-logo.png" alt="Bashhub Logo">
35
+ <img src="https://bashhub.com/static/web/images/bashhub-logo.png" alt="Bashhub Logo">
30
36
 
31
37
  Bashhub saves every terminal command entered across all sessions and systems and provides powerful querying across all commands.
32
38
 
@@ -1,4 +1,4 @@
1
- <img src="https://bashhub.com/assets/images/bashhub-logo.png" alt="Bashhub Logo">
1
+ <img src="https://bashhub.com/static/web/images/bashhub-logo.png" alt="Bashhub Logo">
2
2
 
3
3
  Bashhub saves every terminal command entered across all sessions and systems and provides powerful querying across all commands.
4
4
 
@@ -1,29 +1,27 @@
1
1
  #!/usr/bin/python
2
- from time import *
2
+ from __future__ import annotations
3
+
4
+ import io
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from time import mktime
11
+
3
12
  import click
4
- import traceback
5
13
  import dateutil.parser
6
- import sys
7
- import os
8
- import io
14
+ import requests
9
15
 
16
+ from . import bashhub_globals, bashhub_setup, rest_client, shell_utils
17
+ from .bashhub_globals import BH_FILTER, BH_HOME, write_to_config_file
10
18
  from .model import CommandForm
11
- from . import rest_client
12
- from . import bashhub_setup
13
- from . import bashhub_globals
14
- from .bashhub_globals import BH_FILTER, BH_HOME, BH_SAVE_COMMANDS
15
- from .bashhub_globals import write_to_config_file
16
19
  from .version import version_str
17
- import shutil
18
- import requests
19
- import subprocess
20
- from . import shell_utils
21
- import re
22
- from .view.status import *
20
+ from .view.status import build_status_view
23
21
 
24
22
 
25
-
26
- def print_version(ctx, param, value):
23
+ def print_version(ctx: click.Context, param: click.Parameter,
24
+ value: bool) -> None:
27
25
  if not value or ctx.resilient_parsing:
28
26
  return
29
27
  click.echo(version_str)
@@ -42,13 +40,13 @@ CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help'])
42
40
  help='Display version',
43
41
  expose_value=False,
44
42
  is_eager=True)
45
- def bashhub():
43
+ def bashhub() -> None:
46
44
  """Bashhub command line client"""
47
45
  pass
48
46
 
49
47
 
50
48
  @bashhub.command()
51
- def version():
49
+ def version() -> None:
52
50
  """Display version"""
53
51
  click.echo(version_str)
54
52
 
@@ -60,7 +58,7 @@ def version():
60
58
  default=False,
61
59
  is_flag=True,
62
60
  help="Turn off saving commands for all sessions.")
63
- def off(is_global):
61
+ def off(is_global: bool) -> None:
64
62
  """Turn off saving commands to Bashhub. Applies for this current session."""
65
63
  if is_global:
66
64
  write_to_config_file('save_commands', 'False')
@@ -74,7 +72,7 @@ def off(is_global):
74
72
  "--local",
75
73
  help="Turn on saving commands for only this session.",
76
74
  is_flag=True)
77
- def on(local):
75
+ def on(local: bool) -> None:
78
76
  """Turn on saving commands to Bashhub. Applies globally."""
79
77
  f = io.open(BH_HOME + '/script.bh', 'w+', encoding='utf-8')
80
78
 
@@ -91,22 +89,23 @@ def on(local):
91
89
  @click.argument('pid', type=int)
92
90
  @click.argument('process_start_time', type=int)
93
91
  @click.argument('exit_status', type=int)
94
- def save(command, path, pid, process_start_time, exit_status):
92
+ def save(command: str, path: str, pid: int, process_start_time: int,
93
+ exit_status: int) -> None:
95
94
  """Save a command to Bashhub"""
96
95
  pid_start_time = unix_time_to_epoc_millis(process_start_time)
97
- command = command.strip()
96
+ command_text = command.strip()
98
97
 
99
98
  # Check if we have commands saving turned on
100
99
  if not bashhub_globals.BH_SAVE_COMMANDS:
101
100
  return
102
101
 
103
102
  # Check if we should ignore this command.
104
- if "#ignore" in command:
103
+ if "#ignore" in command_text:
105
104
  return
106
105
 
107
106
  # Check if we should filter this command.
108
107
  bh_filter = bashhub_globals.BH_FILTER
109
- if bh_filter and re.findall(bh_filter, command):
108
+ if bh_filter and re.findall(bh_filter, command_text):
110
109
  return
111
110
 
112
111
  # Check that we have an auth token.
@@ -114,18 +113,18 @@ def save(command, path, pid, process_start_time, exit_status):
114
113
  print("No auth token found. Run 'bashhub setup' to login.")
115
114
  return
116
115
 
117
- command = CommandForm(command, path, exit_status, pid, pid_start_time)
118
- rest_client.save_command(command)
116
+ command_form = CommandForm(command_text, path, exit_status, pid, pid_start_time)
117
+ rest_client.save_command(command_form)
119
118
 
120
119
 
121
120
  @bashhub.command()
122
- def setup():
121
+ def setup() -> None:
123
122
  """Run Bashhub user and system setup"""
124
123
  bashhub_setup.main()
125
124
 
126
125
 
127
126
  @bashhub.command()
128
- def status():
127
+ def status() -> None:
129
128
  """Stats for this session and user"""
130
129
  # Get our user and session information from our context
131
130
  (ppid, start_time) = shell_utils.get_session_information()
@@ -136,9 +135,10 @@ def status():
136
135
 
137
136
  @bashhub.command()
138
137
  @click.pass_context
139
- def help(ctx):
138
+ def help(ctx: click.Context) -> None:
140
139
  """Show this message and exit"""
141
- click.echo(ctx.parent.get_help())
140
+ if ctx.parent is not None:
141
+ click.echo(ctx.parent.get_help())
142
142
 
143
143
  # Dynamic help text containing the BH_FILTER variable.
144
144
  filtered_text = "BH_FILTER={0}".format(
@@ -157,7 +157,7 @@ is configured via a regex exported as BH_FILTER.
157
157
  '--regex',
158
158
  default=BH_FILTER,
159
159
  help='Regex to filter against')
160
- def filter(command, regex):
160
+ def filter(command: str, regex: str) -> None:
161
161
 
162
162
  # Check if the regex we receive is valid
163
163
  if not bashhub_globals.is_valid_regex(regex):
@@ -178,7 +178,7 @@ def filter(command, regex):
178
178
 
179
179
  @bashhub.command()
180
180
  @click.argument('version', type=str, default='')
181
- def update(version):
181
+ def update(version: str) -> None:
182
182
  """Update your Bashhub installation"""
183
183
 
184
184
  if version != '':
@@ -201,40 +201,39 @@ def update(version):
201
201
 
202
202
 
203
203
  @bashhub.group()
204
- def util():
204
+ def util() -> None:
205
205
  """Misc utils used by Bashhub"""
206
206
  pass
207
207
 
208
208
 
209
209
  @util.command()
210
- def update_system_info():
210
+ def update_system_info() -> None:
211
211
  """Updates system info for Bashhub"""
212
212
  result = bashhub_setup.update_system_info()
213
213
  # Exit code based on if our update call was successful
214
- sys.exit(0) if result != None else sys.exit(1)
214
+ sys.exit(0) if result is not None else sys.exit(1)
215
215
 
216
216
 
217
217
  @util.command()
218
218
  @click.argument('date_string', type=str)
219
- def parsedate(date_string):
219
+ def parsedate(date_string: str) -> None:
220
220
  """date string to seconds since the unix epoch"""
221
221
  try:
222
222
  date = dateutil.parser.parse(date_string)
223
223
  unix_time = int(mktime(date.timetuple()))
224
224
  click.echo(unix_time)
225
- except Exception as e:
225
+ except Exception:
226
226
  # Should really log an error here
227
227
  click.echo(0)
228
228
 
229
229
 
230
- def unix_time_to_epoc_millis(unix_time):
230
+ def unix_time_to_epoc_millis(unix_time: int) -> int:
231
231
  return int(unix_time) * 1000
232
232
 
233
233
 
234
- def main():
234
+ def main() -> None:
235
235
  try:
236
236
  bashhub()
237
237
  except Exception as e:
238
- formatted = traceback.format_exc(e)
239
238
  click.echo("Oops, looks like an exception occured: " + str(e))
240
239
  sys.exit(1)
@@ -2,23 +2,25 @@
2
2
  This file should be used for declaring any global variables that need to be
3
3
  pulled in from environment variables or are just used across multiple files.
4
4
  """
5
+ from __future__ import annotations
5
6
 
7
+ import configparser
6
8
  import os
7
9
  import re
8
- import time
9
10
  import stat
11
+ import time as _time
12
+ from configparser import NoOptionError, NoSectionError
10
13
 
11
- import configparser
12
- from configparser import NoSectionError, NoOptionError
13
14
 
14
15
  # Current time in milleseconds to use across app.
15
- current_milli_time = lambda: int(round(time.time() * 1000))
16
+ def current_milli_time() -> int:
17
+ return int(round(_time.time() * 1000))
16
18
 
17
19
  BH_HOME = '~/.bashhub' if 'HOME' not in list(os.environ.keys()) \
18
20
  else os.environ['HOME'] + '/.bashhub'
19
21
 
20
22
 
21
- def write_to_config_file(section, value):
23
+ def write_to_config_file(section: str, value: str) -> bool:
22
24
  exists = os.path.exists(BH_HOME)
23
25
  file_path = BH_HOME + '/config'
24
26
  permissions = stat.S_IRUSR | stat.S_IWUSR
@@ -39,14 +41,14 @@ def write_to_config_file(section, value):
39
41
  return False
40
42
 
41
43
 
42
- def get_from_config(key, default=''):
44
+ def get_from_config(key: str, default: str = '') -> str:
43
45
  try:
44
46
  config = configparser.ConfigParser()
45
47
  config.read(BH_HOME + '/config')
46
48
  return config.get('bashhub', key)
47
- except NoSectionError as error:
49
+ except NoSectionError:
48
50
  return default
49
- except NoOptionError as error:
51
+ except NoOptionError:
50
52
  return default
51
53
 
52
54
  # Optional environment variable to configure for development
@@ -58,6 +60,8 @@ BH_SAVE_COMMANDS = os.getenv('BH_SAVE_COMMANDS', \
58
60
 
59
61
  BH_SYSTEM_NAME = get_from_config("system_name")
60
62
 
63
+ BH_MAC = get_from_config("mac")
64
+
61
65
  # Check if debug mode is enabled
62
66
  BH_DEBUG = os.getenv('BH_DEBUG', get_from_config("debug"))
63
67
 
@@ -65,18 +69,18 @@ BH_DEBUG = os.getenv('BH_DEBUG', get_from_config("debug"))
65
69
  # Get our token from the environment if one is present
66
70
  # otherwise retrieve it from our config. Needs to
67
71
  # be a function since we may change our token during setup
68
- def BH_AUTH():
72
+ def BH_AUTH() -> str:
69
73
  return os.getenv('BH_ACCESS_TOKEN', get_from_config("access_token"))
70
74
 
71
75
 
72
- def is_valid_regex(regex):
76
+ def is_valid_regex(regex: str) -> bool:
73
77
  try:
74
78
  re.compile(regex)
75
79
  return True
76
80
  except re.error:
77
81
  return False
78
82
 
79
- def get_bh_filter():
83
+ def get_bh_filter() -> str:
80
84
  filter = os.getenv('BH_FILTER', get_from_config('filter'))
81
85
  return filter if is_valid_regex(filter) else '__invalid__'
82
86
 
@@ -1,26 +1,21 @@
1
1
  #!/usr/bin/python
2
- from time import *
3
- import jsonpickle
4
- import json
5
- import sys
6
- import requests
2
+ from __future__ import annotations
3
+
7
4
  import getpass
5
+ import socket
6
+ import sys
8
7
  import traceback
9
8
  import uuid
10
- import stat
11
- import socket
9
+ from typing import Callable
10
+
12
11
  from . import rest_client
13
- from .version import __version__
14
- from .model import *
15
12
  from .bashhub_globals import *
16
- import requests
17
- from requests import ConnectionError
18
- from requests import HTTPError
19
- import collections
20
- import configparser
13
+ from .model import *
14
+ from .validation import validate_email, validate_password, validate_username
15
+ from .version import __version__
21
16
 
22
17
 
23
- def query_yes_no(question, default="yes"):
18
+ def query_yes_no(question: str, default: str = "yes") -> bool:
24
19
  """Ask a yes/no question via input() and return their answer.
25
20
 
26
21
  "question" is a string that is presented to the user.
@@ -31,7 +26,7 @@ def query_yes_no(question, default="yes"):
31
26
  The "answer" return value is one of "yes" or "no".
32
27
  """
33
28
  valid = {"yes": True, "y": True, "ye": True, "no": False, "n": False}
34
- if default == None:
29
+ if default is None:
35
30
  prompt = " [y/n] "
36
31
  elif default == "yes":
37
32
  prompt = " [Y/n] "
@@ -52,10 +47,39 @@ def query_yes_no(question, default="yes"):
52
47
  "(or 'y' or 'n').\n")
53
48
 
54
49
 
55
- def get_new_user_information():
56
- email = input("What's your email? ")
57
- username = input("What username would you like? ")
58
- password = getpass.getpass("What password? ")
50
+ def prompt_until_valid(
51
+ prompt: str,
52
+ validator: Callable[[str], str | None],
53
+ secret: bool = False,
54
+ ) -> str:
55
+ """Prompt the user until validator() returns None. Re-prompts with the
56
+ error message on failure."""
57
+ while True:
58
+ value = getpass.getpass(prompt) if secret else input(prompt)
59
+ error = validator(value)
60
+ if error is None:
61
+ return value
62
+ print(error)
63
+
64
+
65
+ def prompt_password_with_confirmation() -> str:
66
+ """Prompt for a password, validate it, then require a matching
67
+ confirmation. Re-prompts from the start if the confirmation doesn't
68
+ match."""
69
+ while True:
70
+ password = prompt_until_valid(
71
+ "What password? ", validate_password, secret=True)
72
+ confirm = getpass.getpass("Confirm password: ")
73
+ if password == confirm:
74
+ return password
75
+ print("Passwords did not match. Please try again.")
76
+
77
+
78
+ def get_new_user_information() -> RegisterUser:
79
+ email = prompt_until_valid("What's your email? ", validate_email)
80
+ username = prompt_until_valid(
81
+ "What username would you like? ", validate_username)
82
+ password = prompt_password_with_confirmation()
59
83
  print("\nEmail: " + email + " Username: " + username)
60
84
  all_good = query_yes_no("Are these correct?")
61
85
 
@@ -65,59 +89,121 @@ def get_new_user_information():
65
89
  return get_new_user_information()
66
90
 
67
91
 
68
- def get_user_information_and_login(username=None, password=None, attempts=0):
92
+ def get_user_information_and_login(
93
+ username: str | None = None,
94
+ password: str | None = None,
95
+ attempts: int = 0,
96
+ ) -> tuple[str | None, str | None, str | None]:
69
97
  if attempts == 4:
70
98
  print("Too many bad attempts.")
71
- return None
99
+ return (None, None, None)
72
100
 
73
101
  # Only collect user information if we don't already have it
74
102
  # i.e. if we didn't just register a new user.
75
- if username == None and password == None:
103
+ if username is None and password is None:
76
104
  print("Please enter your bashhub credentials")
77
105
  username = input("Username: ")
78
106
  password = getpass.getpass("Password: ")
79
107
 
80
108
  # login once we have all of our information
109
+ if username is None or password is None:
110
+ return (None, None, None)
81
111
  access_token = rest_client.login_user(LoginForm(username, password))
82
112
 
83
113
  # Package our result to include our credentials to later login our system.
84
114
  if access_token:
85
- result = (username, password, access_token)
86
- else:
87
- result = get_user_information_and_login(attempts=attempts + 1) or (
88
- None, None, None)
89
-
90
- return result
91
-
115
+ return (username, password, access_token)
116
+ return get_user_information_and_login(attempts=attempts + 1) or (
117
+ None, None, None)
118
+
119
+
120
+ def get_mac_address() -> str:
121
+ """Get the mac address for our system as a fingerprint.
122
+
123
+ The mac is our stable, opaque identifier for a machine — the
124
+ server keys systems on ``(user, mac)``. To avoid drift between
125
+ runs (multi-interface boxes, VPN/Docker churn, random-mac
126
+ fallbacks), we prefer a value that we've previously agreed on
127
+ with the server and persisted in ``~/.bashhub/config``.
128
+
129
+ Resolution order:
130
+ 1. ``mac`` from the config file, if present. This is the
131
+ source of truth once we've successfully registered or
132
+ reconciled with the server.
133
+ 2. ``uuid.getnode()`` — today's behavior, used only on the
134
+ very first run before anything has been written to config.
135
+ 3. Hostname, if ``getnode()`` synthesized a random multicast
136
+ value (indicating it couldn't find any hardware MAC).
137
+
138
+ Note: we do *not* persist the computed value here. Persisting
139
+ happens only after the server confirms the value (on successful
140
+ register / PATCH / name-fallback resolve), so a transient bad
141
+ value never gets pinned into config.
142
+ """
92
143
 
93
- def get_mac_address():
94
- """Get the mac address for our system as a fingerprint. If we can't
95
- get the mac, use the hash of our hostname as a subtitute"""
144
+ cached = get_from_config("mac")
145
+ if cached:
146
+ return cached
96
147
 
97
- mac = uuid.getnode()
148
+ mac_int = uuid.getnode()
98
149
  # check if getnode fails
99
- if (mac >> 40) & 1:
150
+ if (mac_int >> 40) & 1:
100
151
  hostname = socket.gethostname()
101
152
  print("warning: cannot find MAC. Using hostname (%s) to identify system" % hostname)
102
- mac = hostname
103
- else:
104
- mac = str(mac)
105
- return mac
153
+ return hostname
154
+ return str(mac_int)
106
155
 
107
156
 
108
- # Update our hostname incase it changed.
109
- def update_system_info():
157
+ # Update our hostname in case it changed, and opportunistically pin
158
+ # the mac into local config / reconcile server-side if it drifted.
159
+ def update_system_info() -> int | None:
110
160
  mac = get_mac_address()
111
161
  hostname = socket.gethostname()
112
162
  patch = SystemPatch(hostname=hostname, client_version=__version__)
113
- return rest_client.patch_system(patch, mac)
163
+ result = rest_client.patch_system(patch, mac)
164
+
165
+ if result is not None:
166
+ # Happy path. Pin mac into config if it wasn't already there
167
+ # so future runs are immune to uuid.getnode() drift.
168
+ if not get_from_config("mac"):
169
+ write_to_config_file("mac", mac)
170
+ return result
171
+
172
+ # PATCH failed. If we have a stored system_name, try to self-heal
173
+ # by looking the system up by name and rewriting its mac on the
174
+ # server to match the one we just computed. This covers the
175
+ # common `bashhub update` scenario where uuid.getnode() returned
176
+ # a different value than what was originally registered.
177
+ system_name = get_from_config("system_name")
178
+ if not system_name:
179
+ return None
180
+
181
+ system = rest_client.get_system_information(name=system_name)
182
+ if system is None:
183
+ return None
184
+
185
+ reconcile = SystemPatch(
186
+ mac=mac, hostname=hostname, client_version=__version__)
187
+ reconciled = rest_client.patch_system(reconcile, system.mac)
188
+ if reconciled is not None:
189
+ write_to_config_file("mac", mac)
190
+ return reconciled
114
191
 
115
192
 
116
- def handle_system_information(username, password, attempts=0):
193
+ def handle_system_information(
194
+ username: str,
195
+ password: str,
196
+ attempts: int = 0,
197
+ ) -> tuple[str | None, str | None]:
117
198
 
118
199
  mac = get_mac_address()
119
- system = rest_client.get_system_information(mac)
120
- system_name = None
200
+ # Pass both mac and the previously-stored system_name (if any) so
201
+ # the server's mac->name fallback can recognize this box in one
202
+ # round-trip when the mac has drifted since last registration.
203
+ stored_system_name = get_from_config("system_name") or None
204
+ system = rest_client.get_system_information(
205
+ mac=mac, name=stored_system_name)
206
+ system_name: str | None = None
121
207
  # Register a new System if this one isn't recognized
122
208
  if system is None:
123
209
  hostname = socket.gethostname()
@@ -130,9 +216,12 @@ def handle_system_information(username, password, attempts=0):
130
216
  name, mac, hostname, __version__))
131
217
  if system_name:
132
218
  print("Registered a new system " + name)
219
+ # Pin the mac we just registered into local config so
220
+ # future runs read it back instead of recomputing.
221
+ write_to_config_file("mac", mac)
133
222
  else:
134
223
  if attempts < 3:
135
- print("Looks like registering your system failed. Lets retry.")
224
+ print("Looks like registering your system failed. Let's retry.")
136
225
  return handle_system_information(username, password, attempts + 1)
137
226
  else:
138
227
  return (None, None)
@@ -149,11 +238,21 @@ def handle_system_information(username, password, attempts=0):
149
238
  system_name = system.name
150
239
  print("Welcome back! Looks like this box is already registered as " +
151
240
  system.name + ".")
241
+ # The server may have matched by name rather than mac (the
242
+ # "drifted" case). If so, PATCH the server's mac to the
243
+ # current client value so both sides converge. Either way,
244
+ # pin the mac we agreed on into config.
245
+ if system.mac != mac:
246
+ hostname = socket.gethostname()
247
+ reconcile = SystemPatch(
248
+ mac=mac, hostname=hostname, client_version=__version__)
249
+ rest_client.patch_system(reconcile, system.mac)
250
+ write_to_config_file("mac", mac)
152
251
 
153
252
  return (access_token, system_name)
154
253
 
155
254
 
156
- def main():
255
+ def main() -> None:
157
256
  try:
158
257
 
159
258
  ascii_art = r"""
@@ -171,9 +270,9 @@ def main():
171
270
  is_new_user = query_yes_no("Are you a new user?")
172
271
 
173
272
  # Initialize variaous Credentials for logging in.
174
- username = None
175
- password = None
176
- access_token = None
273
+ username: str | None = None
274
+ password: str | None = None
275
+ access_token: str | None = None
177
276
 
178
277
  # If this is a new user walk them through the registration flow
179
278
  if is_new_user:
@@ -193,7 +292,7 @@ def main():
193
292
 
194
293
  (username, password, access_token) = get_user_information_and_login(
195
294
  username, password)
196
- if access_token == None:
295
+ if access_token is None:
197
296
  print("\nSorry looks like logging in failed.")
198
297
  print("If you forgot your password please reset it. "
199
298
  "https://bashhub.com/password-reset")
@@ -208,11 +307,14 @@ def main():
208
307
  print("Writing your config file failed.")
209
308
  sys.exit(1)
210
309
 
310
+ if username is None or password is None:
311
+ print("Sorry looks like getting your credentials failed. Exiting...")
312
+ sys.exit(1)
211
313
  (access_token, system_name) = handle_system_information(username,
212
314
  password)
213
315
 
214
- if access_token == None:
215
- print("Sorry looks like getting your info failed.\
316
+ if access_token is None or system_name is None:
317
+ print("Sorry looks like getting your access_token or system_name failed.\
216
318
  Exiting...")
217
319
  sys.exit(0)
218
320