mmrelay 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl

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.

Potentially problematic release.


This version of mmrelay might be problematic. Click here for more details.

mmrelay/cli.py CHANGED
@@ -7,6 +7,9 @@ import importlib.resources
7
7
  import os
8
8
  import shutil
9
9
  import sys
10
+ from collections.abc import Mapping
11
+
12
+ import yaml
10
13
 
11
14
  # Import version from package
12
15
  from mmrelay import __version__
@@ -131,6 +134,11 @@ def parse_arguments():
131
134
  help="Validate configuration file",
132
135
  description="Check configuration file syntax and completeness",
133
136
  )
137
+ config_subparsers.add_parser(
138
+ "diagnose",
139
+ help="Diagnose configuration system issues",
140
+ description="Test config generation capabilities and troubleshoot platform-specific issues",
141
+ )
134
142
 
135
143
  # AUTH group
136
144
  auth_parser = subparsers.add_parser(
@@ -141,11 +149,24 @@ def parse_arguments():
141
149
  auth_subparsers = auth_parser.add_subparsers(
142
150
  dest="auth_command", help="Auth commands"
143
151
  )
144
- auth_subparsers.add_parser(
152
+ login_parser = auth_subparsers.add_parser(
145
153
  "login",
146
154
  help="Authenticate with Matrix",
147
155
  description="Set up Matrix authentication for E2EE support",
148
156
  )
157
+ login_parser.add_argument(
158
+ "--homeserver",
159
+ help="Matrix homeserver URL (e.g., https://matrix.org). If provided, --username and --password are also required.",
160
+ )
161
+ login_parser.add_argument(
162
+ "--username",
163
+ help="Matrix username (with or without @ and :server). If provided, --homeserver and --password are also required.",
164
+ )
165
+ login_parser.add_argument(
166
+ "--password",
167
+ metavar="PWD",
168
+ help="Matrix password (can be empty). If provided, --homeserver and --username are also required. For security, prefer interactive mode.",
169
+ )
149
170
 
150
171
  auth_subparsers.add_parser(
151
172
  "status",
@@ -189,9 +210,10 @@ def parse_arguments():
189
210
 
190
211
  # Use parse_known_args to handle unknown arguments gracefully (e.g., pytest args)
191
212
  args, unknown = parser.parse_known_args()
192
- # If there are unknown arguments and we're not in a test environment, warn about them
193
- if unknown and not any("pytest" in arg or "test" in arg for arg in sys.argv):
194
- print(f"Warning: Unknown arguments ignored: {unknown}")
213
+ # If there are unknown arguments and we're not in a test invocation, warn about them
214
+ # Heuristic: suppress warning when pytest appears in argv (unit tests may pass extra args)
215
+ if unknown and not any("pytest" in arg or "py.test" in arg for arg in sys.argv):
216
+ print(f"Warning: Unknown arguments ignored: {unknown}", file=sys.stderr)
195
217
 
196
218
  return args
197
219
 
@@ -215,14 +237,16 @@ def print_version():
215
237
 
216
238
  def _validate_e2ee_dependencies():
217
239
  """
218
- Check whether end-to-end encryption (E2EE) runtime dependencies are available.
219
-
220
- Performs a platform check and attempts to import required packages (python-olm, nio.crypto.OlmDevice,
221
- and nio.store.SqliteStore). Prints a short user-facing status message and guidance.
222
-
240
+ Check whether end-to-end encryption (E2EE) is usable on the current platform.
241
+
223
242
  Returns:
224
- bool: True if the platform supports E2EE and all required dependencies can be imported;
225
- False if running on an unsupported platform (Windows) or if any dependency is missing.
243
+ bool: True if the platform is supported and required E2EE libraries can be imported;
244
+ False otherwise.
245
+
246
+ Notes:
247
+ - This function performs only local checks (platform and importability) and does not perform
248
+ network I/O.
249
+ - It emits user-facing messages to indicate missing platform support or missing dependencies.
226
250
  """
227
251
  if sys.platform == WINDOWS_PLATFORM:
228
252
  print("❌ Error: E2EE is not supported on Windows")
@@ -239,42 +263,34 @@ def _validate_e2ee_dependencies():
239
263
  print("✅ E2EE dependencies are installed")
240
264
  return True
241
265
  except ImportError:
242
- print("❌ Error: E2EE enabled but dependencies not installed")
266
+ print("❌ Error: E2EE dependencies not installed")
267
+ print(" End-to-end encryption features require additional dependencies")
243
268
  print(" Install E2EE support: pipx install 'mmrelay[e2e]'")
244
269
  return False
245
270
 
246
271
 
247
272
  def _validate_credentials_json(config_path):
248
273
  """
249
- Validate that a credentials.json file exists next to the given config and contains the required Matrix authentication fields.
274
+ Validate that a credentials.json file exists (adjacent to config_path or in the base directory) and contains the required Matrix session fields.
250
275
 
251
- Searches for credentials.json in the same directory as config_path, then falls back to the application's base directory. If found, the file is parsed as JSON and must include non-empty values for: "homeserver", "access_token", "user_id", and "device_id".
276
+ Checks for a credentials.json via _find_credentials_json_path(config_path). If found, the file is parsed as JSON and must include non-empty string values for the keys "homeserver", "access_token", "user_id", and "device_id". On validation failure the function prints a brief error and guidance to run the auth login flow.
252
277
 
253
278
  Parameters:
254
279
  config_path (str): Path to the configuration file used to determine the primary search directory for credentials.json.
255
280
 
256
281
  Returns:
257
- bool: True if a valid credentials.json was found and contains all required fields; False otherwise. When invalid or missing fields are detected the function prints a short error and guidance to run the auth login flow.
282
+ bool: True if a credentials.json was found and contains all required non-empty fields; False otherwise.
258
283
  """
259
284
  try:
260
285
  import json
261
286
 
262
- # Look for credentials.json in the same directory as the config file
263
- config_dir = os.path.dirname(config_path)
264
- credentials_path = os.path.join(config_dir, "credentials.json")
265
-
266
- if not os.path.exists(credentials_path):
267
- # Also try the standard location
268
- from mmrelay.config import get_base_dir
269
-
270
- standard_credentials_path = os.path.join(get_base_dir(), "credentials.json")
271
- if os.path.exists(standard_credentials_path):
272
- credentials_path = standard_credentials_path
273
- else:
274
- return False
287
+ # Look for credentials.json using helper function
288
+ credentials_path = _find_credentials_json_path(config_path)
289
+ if not credentials_path:
290
+ return False
275
291
 
276
292
  # Load and validate credentials
277
- with open(credentials_path, "r") as f:
293
+ with open(credentials_path, "r", encoding="utf-8") as f:
278
294
  credentials = json.load(f)
279
295
 
280
296
  # Check for required fields
@@ -282,7 +298,7 @@ def _validate_credentials_json(config_path):
282
298
  missing_fields = [
283
299
  field
284
300
  for field in required_fields
285
- if field not in credentials or not credentials[field]
301
+ if not _is_valid_non_empty_string((credentials or {}).get(field))
286
302
  ]
287
303
 
288
304
  if missing_fields:
@@ -298,6 +314,52 @@ def _validate_credentials_json(config_path):
298
314
  return False
299
315
 
300
316
 
317
+ def _is_valid_non_empty_string(value) -> bool:
318
+ """
319
+ Return True if value is a string containing non-whitespace characters.
320
+
321
+ Checks that the input is an instance of `str` and that stripping whitespace
322
+ does not produce an empty string.
323
+
324
+ Returns:
325
+ bool: True when value is a non-empty, non-whitespace-only string; otherwise False.
326
+ """
327
+ return isinstance(value, str) and value.strip() != ""
328
+
329
+
330
+ def _has_valid_password_auth(matrix_section):
331
+ """
332
+ Return True if the given Matrix config section contains valid password-based authentication settings.
333
+
334
+ The function expects matrix_section to be a dict-like mapping from configuration keys to values.
335
+ It validates that:
336
+ - `homeserver` and `bot_user_id` are present and are non-empty strings (after trimming),
337
+ - `password` is present and is a string (it may be an empty string, which is accepted).
338
+
339
+ If matrix_section is not a dict, the function returns False.
340
+
341
+ Parameters:
342
+ matrix_section: dict-like Matrix configuration section (may be the parsed "matrix" config).
343
+
344
+ Returns:
345
+ bool: True when password-based authentication is correctly configured as described above; otherwise False.
346
+ """
347
+ if not isinstance(matrix_section, Mapping):
348
+ return False
349
+
350
+ pwd = matrix_section.get("password")
351
+ homeserver = matrix_section.get(CONFIG_KEY_HOMESERVER)
352
+ bot_user_id = matrix_section.get(CONFIG_KEY_BOT_USER_ID)
353
+
354
+ # Allow empty password strings (some environments legitimately use empty passwords).
355
+ # Homeserver and bot_user_id must still be valid non-empty strings.
356
+ return (
357
+ isinstance(pwd, str)
358
+ and _is_valid_non_empty_string(homeserver)
359
+ and _is_valid_non_empty_string(bot_user_id)
360
+ )
361
+
362
+
301
363
  def _validate_matrix_authentication(config_path, matrix_section):
302
364
  """
303
365
  Determine whether Matrix authentication is configured and usable.
@@ -323,7 +385,10 @@ def _validate_matrix_authentication(config_path, matrix_section):
323
385
  and whether E2EE support is available.
324
386
  """
325
387
  has_valid_credentials = _validate_credentials_json(config_path)
326
- has_access_token = matrix_section and "access_token" in matrix_section
388
+ token = (matrix_section or {}).get(CONFIG_KEY_ACCESS_TOKEN)
389
+ has_access_token = _is_valid_non_empty_string(token)
390
+
391
+ has_password = _has_valid_password_auth(matrix_section)
327
392
 
328
393
  if has_valid_credentials:
329
394
  print("✅ Using credentials.json for Matrix authentication")
@@ -331,8 +396,16 @@ def _validate_matrix_authentication(config_path, matrix_section):
331
396
  print(" E2EE support available (if enabled)")
332
397
  return True
333
398
 
399
+ elif has_password:
400
+ print(
401
+ "✅ Using password in config for initial authentication (credentials.json will be created on first run)"
402
+ )
403
+ print(f" {msg_for_e2ee_support()}")
404
+ return True
334
405
  elif has_access_token:
335
- print("✅ Using access_token for Matrix authentication")
406
+ print(
407
+ "✅ Using access_token for Matrix authentication (deprecated — consider 'mmrelay auth login' to create credentials.json)"
408
+ )
336
409
  print(f" {msg_for_e2ee_support()}")
337
410
  return True
338
411
 
@@ -388,7 +461,7 @@ def _validate_e2ee_config(config, matrix_section, config_path):
388
461
  )
389
462
  if store_path:
390
463
  expanded_path = os.path.expanduser(store_path)
391
- if not os.path.exists(os.path.dirname(expanded_path)):
464
+ if not os.path.exists(expanded_path):
392
465
  print(f"ℹ️ Note: E2EE store directory will be created: {expanded_path}")
393
466
 
394
467
  print("✅ E2EE configuration is valid")
@@ -470,19 +543,9 @@ def _analyze_e2ee_setup(config, config_path):
470
543
  "Enable E2EE in config.yaml under matrix section: e2ee: enabled: true"
471
544
  )
472
545
 
473
- # Check credentials (same logic as _validate_credentials_json)
474
- config_dir = os.path.dirname(config_path)
475
- credentials_path = os.path.join(config_dir, "credentials.json")
476
-
477
- if not os.path.exists(credentials_path):
478
- # Also try the standard location
479
- from mmrelay.config import get_base_dir
480
-
481
- standard_credentials_path = os.path.join(get_base_dir(), "credentials.json")
482
- if os.path.exists(standard_credentials_path):
483
- credentials_path = standard_credentials_path
484
-
485
- analysis["credentials_available"] = os.path.exists(credentials_path)
546
+ # Check credentials file existence
547
+ credentials_path = _find_credentials_json_path(config_path)
548
+ analysis["credentials_available"] = bool(credentials_path)
486
549
 
487
550
  if not analysis["credentials_available"]:
488
551
  analysis["recommendations"].append(
@@ -506,54 +569,89 @@ def _analyze_e2ee_setup(config, config_path):
506
569
  return analysis
507
570
 
508
571
 
572
+ def _find_credentials_json_path(config_path: str | None) -> str | None:
573
+ """
574
+ Return the filesystem path to a credentials.json file if one can be found, otherwise None.
575
+
576
+ Search order:
577
+ 1. A credentials.json file located in the same directory as the provided config_path.
578
+ 2. A credentials.json file in the application's base directory (get_base_dir()).
579
+
580
+ Parameters:
581
+ config_path (str | None): Path to the configuration file used to derive the adjacent credentials.json location.
582
+
583
+ Returns:
584
+ str | None: Absolute path to the discovered credentials.json, or None if no file is found.
585
+ """
586
+ if not config_path:
587
+ from mmrelay.config import get_base_dir
588
+
589
+ standard = os.path.join(get_base_dir(), "credentials.json")
590
+ return standard if os.path.exists(standard) else None
591
+
592
+ config_dir = os.path.dirname(config_path)
593
+ candidate = os.path.join(config_dir, "credentials.json")
594
+ if os.path.exists(candidate):
595
+ return candidate
596
+ from mmrelay.config import get_base_dir
597
+
598
+ standard = os.path.join(get_base_dir(), "credentials.json")
599
+ return standard if os.path.exists(standard) else None
600
+
601
+
509
602
  def _print_unified_e2ee_analysis(e2ee_status):
510
603
  """
511
- Print a concise, user-facing analysis of E2EE readiness from a centralized status object.
604
+ Print a concise, user-facing analysis of E2EE readiness.
512
605
 
513
- This formats and prints the platform support, dependency availability, configuration enabled state,
514
- authentication (credentials.json) presence, and the overall status. If the overall status is not
515
- "ready", prints actionable fix instructions obtained from mmrelay.e2ee_utils.get_e2ee_fix_instructions.
606
+ Given a status dictionary produced by the E2EE analysis routines, prints platform support,
607
+ dependency availability, whether E2EE is enabled in configuration, whether credentials.json
608
+ is available, and the overall status. If the overall status is not "ready", prints actionable
609
+ fix instructions.
516
610
 
517
611
  Parameters:
518
- e2ee_status (dict): Status dictionary as returned by get_e2ee_status(config, config_path).
519
- Expected keys:
520
- - platform_supported (bool)
521
- - dependencies_installed (bool)
522
- - enabled (bool)
523
- - credentials_available (bool)
524
- - overall_status (str)
612
+ e2ee_status (dict): Status dictionary with (at least) the following keys:
613
+ - platform_supported (bool): whether the current OS/platform supports E2EE.
614
+ - dependencies_installed or dependencies_available (bool): whether required E2EE
615
+ Python packages and runtime dependencies are present.
616
+ - enabled or config_enabled (bool): whether E2EE is enabled in the configuration.
617
+ - credentials_available (bool): whether a usable credentials.json is present.
618
+ - overall_status (str): high-level status ("ready", "disabled", "incomplete", etc.).
525
619
  """
526
620
  print("\n🔐 E2EE Configuration Analysis:")
527
621
 
528
622
  # Platform support
529
- if e2ee_status["platform_supported"]:
623
+ if e2ee_status.get("platform_supported", True):
530
624
  print("✅ Platform: E2EE supported")
531
625
  else:
532
626
  print("❌ Platform: E2EE not supported on Windows")
533
627
 
534
628
  # Dependencies
535
- if e2ee_status["dependencies_installed"]:
629
+ if e2ee_status.get(
630
+ "dependencies_installed", e2ee_status.get("dependencies_available", False)
631
+ ):
536
632
  print("✅ Dependencies: E2EE dependencies installed")
537
633
  else:
538
634
  print("❌ Dependencies: E2EE dependencies not fully installed")
539
635
 
540
636
  # Configuration
541
- if e2ee_status["enabled"]:
637
+ if e2ee_status.get("enabled", e2ee_status.get("config_enabled", False)):
542
638
  print("✅ Configuration: E2EE enabled")
543
639
  else:
544
640
  print("❌ Configuration: E2EE disabled")
545
641
 
546
642
  # Authentication
547
- if e2ee_status["credentials_available"]:
643
+ if e2ee_status.get("credentials_available", False):
548
644
  print("✅ Authentication: credentials.json found")
549
645
  else:
550
646
  print("❌ Authentication: credentials.json not found")
551
647
 
552
648
  # Overall status
553
- print(f"\n📊 Overall Status: {e2ee_status['overall_status'].upper()}")
649
+ print(
650
+ f"\n📊 Overall Status: {e2ee_status.get('overall_status', 'unknown').upper()}"
651
+ )
554
652
 
555
653
  # Show fix instructions if needed
556
- if e2ee_status["overall_status"] != "ready":
654
+ if e2ee_status.get("overall_status") != "ready":
557
655
  from mmrelay.e2ee_utils import get_e2ee_fix_instructions
558
656
 
559
657
  instructions = get_e2ee_fix_instructions(e2ee_status)
@@ -682,23 +780,18 @@ def check_config(args=None):
682
780
  """
683
781
  Validate the application's YAML configuration file and its required sections.
684
782
 
685
- Performs these checks:
686
- - Locates the first existing config file from get_config_paths(args) (parses CLI args if args is None).
687
- - Verifies YAML syntax and reports syntax errors or style warnings.
688
- - Ensures the config is non-empty.
689
- - Validates Matrix authentication: accepts credentials supplied via credentials.json or requires a matrix section with homeserver, access_token, and bot_user_id when credentials.json is absent.
690
- - Validates end-to-end-encryption (E2EE) configuration and dependencies.
691
- - Ensures matrix_rooms exists, is a non-empty list, and each room is a dict containing an id.
692
- - Validates the meshtastic section: requires connection_type and the connection-specific fields (serial_port for serial, host for tcp/network, ble_address for ble). Warns about deprecated connection types.
693
- - Validates optional meshtastic fields and types (broadcast_enabled, detection_sensor, message_delay >= 2.0, meshnet_name) and reports missing optional settings as guidance.
694
- - Warns if a deprecated db section is present.
695
- - Prints a short environment summary on success.
783
+ Performs these checks: locates the first existing config file from get_config_paths(args),
784
+ validates YAML syntax and non-empty content, verifies Matrix authentication (prefers
785
+ credentials.json but accepts access_token or password in the matrix section), performs
786
+ E2EE configuration and dependency checks, validates matrix_rooms and meshtastic sections
787
+ (including connection-specific requirements), checks a set of optional meshtastic settings
788
+ (and value constraints), and warns about deprecated sections.
696
789
 
697
790
  Side effects:
698
- - Prints errors, warnings, and status messages to stdout.
791
+ - Prints human-readable errors, warnings, and status messages to stdout.
699
792
 
700
793
  Parameters:
701
- args (argparse.Namespace | None): Parsed CLI arguments. If None, CLI arguments will be parsed internally.
794
+ args (argparse.Namespace | None): Parsed CLI arguments; if None, CLI arguments are parsed internally.
702
795
 
703
796
  Returns:
704
797
  bool: True if a configuration file was found and passed all checks; False otherwise.
@@ -717,7 +810,7 @@ def check_config(args=None):
717
810
  config_path = path
718
811
  print(f"Found configuration file at: {config_path}")
719
812
  try:
720
- with open(config_path, "r") as f:
813
+ with open(config_path, "r", encoding="utf-8") as f:
721
814
  config_content = f.read()
722
815
 
723
816
  # Validate YAML syntax first
@@ -748,6 +841,9 @@ def check_config(args=None):
748
841
  # Create empty matrix section if missing - no fields required
749
842
  config[CONFIG_SECTION_MATRIX] = {}
750
843
  matrix_section = config[CONFIG_SECTION_MATRIX]
844
+ if not isinstance(matrix_section, dict):
845
+ print("Error: 'matrix' section must be a mapping (YAML object)")
846
+ return False
751
847
  required_matrix_fields = (
752
848
  []
753
849
  ) # No fields required from config when using credentials.json
@@ -756,22 +852,37 @@ def check_config(args=None):
756
852
  if CONFIG_SECTION_MATRIX not in config:
757
853
  print("Error: Missing 'matrix' section in config")
758
854
  print(
759
- " Either add matrix section with access_token and bot_user_id,"
855
+ " Either add matrix section with access_token or password and bot_user_id,"
760
856
  )
761
857
  print(f" {msg_or_run_auth_login()}")
762
858
  return False
763
859
 
764
860
  matrix_section = config[CONFIG_SECTION_MATRIX]
861
+ if not isinstance(matrix_section, dict):
862
+ print("Error: 'matrix' section must be a mapping (YAML object)")
863
+ return False
864
+
765
865
  required_matrix_fields = [
766
866
  CONFIG_KEY_HOMESERVER,
767
- CONFIG_KEY_ACCESS_TOKEN,
768
867
  CONFIG_KEY_BOT_USER_ID,
769
868
  ]
869
+ token = matrix_section.get(CONFIG_KEY_ACCESS_TOKEN)
870
+ pwd = matrix_section.get("password")
871
+ has_token = _is_valid_non_empty_string(token)
872
+ # Allow explicitly empty password strings; require the value to be a string
873
+ # (reject unquoted numeric types)
874
+ has_password = isinstance(pwd, str)
875
+ if not (has_token or has_password):
876
+ print(
877
+ "Error: Missing authentication in 'matrix' section: provide 'access_token' or 'password'"
878
+ )
879
+ print(f" {msg_or_run_auth_login()}")
880
+ return False
770
881
 
771
882
  missing_matrix_fields = [
772
883
  field
773
884
  for field in required_matrix_fields
774
- if field not in matrix_section
885
+ if not _is_valid_non_empty_string(matrix_section.get(field))
775
886
  ]
776
887
 
777
888
  if missing_matrix_fields:
@@ -929,7 +1040,8 @@ def check_config(args=None):
929
1040
  # Special validation for message_delay
930
1041
  if option == "message_delay" and value < 2.0:
931
1042
  print(
932
- f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}"
1043
+ f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}",
1044
+ file=sys.stderr,
933
1045
  )
934
1046
  return False
935
1047
  else:
@@ -943,16 +1055,23 @@ def check_config(args=None):
943
1055
  # Check for deprecated db section
944
1056
  if "db" in config:
945
1057
  print(
946
- "\nWarning: 'db' section is deprecated. Please use 'database' instead."
1058
+ "\nWarning: 'db' section is deprecated. Please use 'database' instead.",
1059
+ file=sys.stderr,
947
1060
  )
948
1061
  print(
949
- "This option still works but may be removed in future versions.\n"
1062
+ "This option still works but may be removed in future versions.\n",
1063
+ file=sys.stderr,
950
1064
  )
951
1065
 
952
1066
  print("\n✅ Configuration file is valid!")
953
1067
  return True
1068
+ except (OSError, ValueError, UnicodeDecodeError) as e:
1069
+ print(
1070
+ f"Error checking configuration: {e.__class__.__name__}: {e}",
1071
+ file=sys.stderr,
1072
+ )
954
1073
  except Exception as e:
955
- print(f"Error checking configuration: {e}")
1074
+ print(f"Error checking configuration: {e}", file=sys.stderr)
956
1075
  return False
957
1076
 
958
1077
  print("Error: No configuration file found in any of the following locations:")
@@ -977,8 +1096,27 @@ def main():
977
1096
  int: Exit code (0 on success, non-zero on failure).
978
1097
  """
979
1098
  try:
1099
+ # Set up Windows console for better compatibility
1100
+ try:
1101
+ from mmrelay.windows_utils import setup_windows_console
1102
+
1103
+ setup_windows_console()
1104
+ except (ImportError, OSError, AttributeError):
1105
+ # windows_utils not available or Windows console setup failed
1106
+ # This is intentional - we want to continue if Windows utils fail
1107
+ pass
1108
+
980
1109
  args = parse_arguments()
981
1110
 
1111
+ # Handle the --data-dir option
1112
+ if args and args.data_dir:
1113
+ import mmrelay.config
1114
+
1115
+ # Set the global custom_data_dir variable
1116
+ mmrelay.config.custom_data_dir = os.path.abspath(args.data_dir)
1117
+ # Create the directory if it doesn't exist
1118
+ os.makedirs(mmrelay.config.custom_data_dir, exist_ok=True)
1119
+
982
1120
  # Handle subcommands first (modern interface)
983
1121
  if hasattr(args, "command") and args.command:
984
1122
  return handle_subcommand(args)
@@ -1019,20 +1157,33 @@ def main():
1019
1157
  print(f"Error importing main module: {e}")
1020
1158
  return 1
1021
1159
 
1160
+ except (OSError, PermissionError, KeyboardInterrupt) as e:
1161
+ # Handle common system-level errors
1162
+ print(f"System error: {e.__class__.__name__}: {e}", file=sys.stderr)
1163
+ return 1
1022
1164
  except Exception as e:
1023
- print(f"Unexpected error: {e}")
1165
+ # Default error message
1166
+ error_msg = f"Unexpected error: {e.__class__.__name__}: {e}"
1167
+ # Provide Windows-specific error guidance if available
1168
+ try:
1169
+ from mmrelay.windows_utils import get_windows_error_message, is_windows
1170
+
1171
+ if is_windows():
1172
+ error_msg = f"Error: {get_windows_error_message(e)}"
1173
+ except ImportError:
1174
+ pass # Use default message
1175
+ print(error_msg, file=sys.stderr)
1024
1176
  return 1
1025
1177
 
1026
1178
 
1027
1179
  def handle_subcommand(args):
1028
1180
  """
1029
- Dispatch the modern grouped CLI subcommand to its handler and return an exit code.
1030
-
1031
- Parameters:
1032
- args (argparse.Namespace): Parsed CLI arguments (as produced by parse_arguments()). Must have a `command` attribute with one of: "config", "auth", or "service".
1181
+ Dispatch the modern grouped CLI subcommand to the appropriate handler and return an exit code.
1033
1182
 
1034
- Returns:
1035
- int: Process exit code 0 on success, non-zero on error or unknown command.
1183
+ The function expects an argparse.Namespace from parse_arguments() with a `command`
1184
+ attribute set to one of: "config", "auth", or "service". Delegates to the
1185
+ corresponding handler and returns its exit code. If `command` is unknown,
1186
+ prints an error and returns 1.
1036
1187
  """
1037
1188
  if args.command == "config":
1038
1189
  return handle_config_command(args)
@@ -1040,6 +1191,7 @@ def handle_subcommand(args):
1040
1191
  return handle_auth_command(args)
1041
1192
  elif args.command == "service":
1042
1193
  return handle_service_command(args)
1194
+
1043
1195
  else:
1044
1196
  print(f"Unknown command: {args.command}")
1045
1197
  return 1
@@ -1047,21 +1199,25 @@ def handle_subcommand(args):
1047
1199
 
1048
1200
  def handle_config_command(args):
1049
1201
  """
1050
- Dispatch the 'config' subgroup commands: "generate" and "check".
1202
+ Dispatch the "config" command group to the selected subcommand handler.
1051
1203
 
1052
- If `args.config_command` is "generate", writes a sample config to the default location.
1053
- If "check", validates the configuration referenced by `args` (see check_config).
1204
+ Supported subcommands:
1205
+ - "generate": create or update the sample configuration file at the preferred location.
1206
+ - "check": validate the resolved configuration file (delegates to check_config).
1207
+ - "diagnose": run a sequence of non-destructive diagnostics and print a report (delegates to handle_config_diagnose).
1054
1208
 
1055
1209
  Parameters:
1056
- args (argparse.Namespace): Parsed CLI namespace with a `config_command` attribute.
1210
+ args (argparse.Namespace): CLI namespace containing `config_command` (one of "generate", "check", "diagnose") and any subcommand-specific options.
1057
1211
 
1058
1212
  Returns:
1059
- int: Process exit code (0 on success, 1 on failure or unknown subcommand).
1213
+ int: Exit code (0 on success, 1 on failure or for unknown subcommands).
1060
1214
  """
1061
1215
  if args.config_command == "generate":
1062
1216
  return 0 if generate_sample_config() else 1
1063
1217
  elif args.config_command == "check":
1064
1218
  return 0 if check_config(args) else 1
1219
+ elif args.config_command == "diagnose":
1220
+ return handle_config_diagnose(args)
1065
1221
  else:
1066
1222
  print(f"Unknown config command: {args.config_command}")
1067
1223
  return 1
@@ -1096,24 +1252,97 @@ def handle_auth_command(args):
1096
1252
 
1097
1253
  def handle_auth_login(args):
1098
1254
  """
1099
- Run the interactive Matrix bot login flow and return a CLI-style exit code.
1255
+ Run the Matrix bot login flow and return a CLI-style exit code.
1256
+
1257
+ Performs Matrix bot authentication either interactively (prompts the user) or non-interactively
1258
+ when all three parameters (--homeserver, --username, --password) are provided on the command line.
1259
+ For non-interactive mode, --homeserver and --username must be non-empty strings; --password may be
1260
+ an empty string (some flows will prompt). Supplying some but not all of the three parameters
1261
+ is treated as an error and the function exits with a non-zero status.
1100
1262
 
1101
- Runs the login_matrix_bot coroutine to perform authentication for the Matrix/E2EE bot and prints a short header. Returns 0 on successful authentication; returns 1 if the login fails, is cancelled by the user (KeyboardInterrupt), or an unexpected error occurs.
1263
+ Returns:
1264
+ int: 0 on successful authentication, 1 on failure, cancellation (KeyboardInterrupt), or unexpected errors.
1102
1265
 
1103
1266
  Parameters:
1104
- args: Parsed command-line arguments (not used by this handler).
1267
+ args: Parsed CLI namespace; may contain attributes `homeserver`, `username`, and `password`.
1105
1268
  """
1106
1269
  import asyncio
1107
1270
 
1108
1271
  from mmrelay.matrix_utils import login_matrix_bot
1109
1272
 
1110
- # Show header
1111
- print("Matrix Bot Authentication for E2EE")
1112
- print("===================================")
1273
+ # Extract arguments
1274
+ homeserver = getattr(args, "homeserver", None)
1275
+ username = getattr(args, "username", None)
1276
+ password = getattr(args, "password", None)
1277
+
1278
+ # Count provided parameters (empty strings count as provided)
1279
+ provided_params = [p for p in [homeserver, username, password] if p is not None]
1280
+
1281
+ # Determine mode based on parameters provided
1282
+ if len(provided_params) == 3:
1283
+ # All parameters provided - validate required non-empty fields
1284
+ if not _is_valid_non_empty_string(homeserver) or not _is_valid_non_empty_string(
1285
+ username
1286
+ ):
1287
+ print(
1288
+ "❌ Error: --homeserver and --username must be non-empty for non-interactive login."
1289
+ )
1290
+ return 1
1291
+ # Password may be empty (flows may prompt)
1292
+ elif len(provided_params) > 0:
1293
+ # Some but not all parameters provided - show error
1294
+ missing_params = []
1295
+ if homeserver is None:
1296
+ missing_params.append("--homeserver")
1297
+ if username is None:
1298
+ missing_params.append("--username")
1299
+ if password is None:
1300
+ missing_params.append("--password")
1301
+
1302
+ error_message = f"""❌ Error: All authentication parameters are required when using command-line options.
1303
+ Missing: {', '.join(missing_params)}
1304
+
1305
+ 💡 Options:
1306
+ • For secure interactive authentication: mmrelay auth login
1307
+ • For automated authentication: provide all three parameters
1308
+
1309
+ ⚠️ Security Note: Command-line passwords may be visible in process lists and shell history.
1310
+ Interactive mode is recommended for manual use."""
1311
+ print(error_message)
1312
+ return 1
1313
+ else:
1314
+ # No parameters provided - run in interactive mode
1315
+ # Check if E2EE is actually configured before mentioning it
1316
+ # Use silent checking to avoid warnings during initial setup
1317
+ try:
1318
+ from mmrelay.config import check_e2ee_enabled_silently
1319
+
1320
+ e2ee_enabled = check_e2ee_enabled_silently(args)
1321
+
1322
+ if e2ee_enabled:
1323
+ print("Matrix Bot Authentication for E2EE")
1324
+ print("===================================")
1325
+ else:
1326
+ print("\nMatrix Bot Authentication")
1327
+ print("=========================")
1328
+ except (OSError, PermissionError, ImportError, ValueError) as e:
1329
+ # Fallback if silent checking fails due to config file or import issues
1330
+ from mmrelay.log_utils import get_logger
1331
+
1332
+ logger = get_logger("CLI")
1333
+ logger.debug(f"Failed to silently check E2EE status: {e}")
1334
+ print("\nMatrix Bot Authentication")
1335
+ print("=========================")
1113
1336
 
1114
1337
  try:
1115
- # For now, use the existing login function
1116
- result = asyncio.run(login_matrix_bot())
1338
+ result = asyncio.run(
1339
+ login_matrix_bot(
1340
+ homeserver=homeserver,
1341
+ username=username,
1342
+ password=password,
1343
+ logout_others=False,
1344
+ )
1345
+ )
1117
1346
  return 0 if result else 1
1118
1347
  except KeyboardInterrupt:
1119
1348
  print("\nAuthentication cancelled by user.")
@@ -1125,38 +1354,66 @@ def handle_auth_login(args):
1125
1354
 
1126
1355
  def handle_auth_status(args):
1127
1356
  """
1128
- Show Matrix authentication status by locating and reading a credentials.json file.
1357
+ Print the Matrix authentication status by locating and reading a credentials.json file.
1129
1358
 
1130
- Searches candidate config directories derived from the provided parsed-arguments namespace for a credentials.json file. If found and readable, prints the file path and the homeserver, user_id, and device_id values. If the file is unreadable or not found, prints guidance to run the authentication flow.
1359
+ Searches for credentials.json next to each discovered config file (in preference order),
1360
+ then falls back to the application's base directory. If a readable credentials.json is
1361
+ found, prints its path and the homeserver, user_id, and device_id values.
1131
1362
 
1132
1363
  Parameters:
1133
- args (argparse.Namespace): Parsed CLI arguments used to determine the list of config paths to search.
1364
+ args: argparse.Namespace
1365
+ Parsed CLI arguments (used to locate config file paths).
1134
1366
 
1135
1367
  Returns:
1136
- int: Exit code — 0 if a readable credentials.json was found, 1 otherwise.
1368
+ int: Exit code — 0 if a valid credentials.json was found and read, 1 otherwise.
1369
+
1370
+ Side effects:
1371
+ Writes human-readable status messages to stdout.
1137
1372
  """
1138
1373
  import json
1139
- import os
1140
1374
 
1141
- from mmrelay.config import get_config_paths
1375
+ from mmrelay.config import get_base_dir, get_config_paths
1142
1376
 
1143
1377
  print("Matrix Authentication Status")
1144
1378
  print("============================")
1145
1379
 
1146
- # Check for credentials.json
1147
1380
  config_paths = get_config_paths(args)
1148
- for config_path in config_paths:
1149
- config_dir = os.path.dirname(config_path)
1150
- credentials_path = os.path.join(config_dir, "credentials.json")
1381
+
1382
+ # Developer note: Build a de-duplicated sequence of candidate locations,
1383
+ # preserving preference order: each config-adjacent credentials.json first,
1384
+ # then the standard base-dir fallback.
1385
+ seen = set()
1386
+ candidate_paths = []
1387
+ for p in (
1388
+ os.path.join(os.path.dirname(cp), "credentials.json") for cp in config_paths
1389
+ ):
1390
+ if p not in seen:
1391
+ candidate_paths.append(p)
1392
+ seen.add(p)
1393
+ base_candidate = os.path.join(get_base_dir(), "credentials.json")
1394
+ if base_candidate not in seen:
1395
+ candidate_paths.append(base_candidate)
1396
+
1397
+ for credentials_path in candidate_paths:
1151
1398
  if os.path.exists(credentials_path):
1152
1399
  try:
1153
- with open(credentials_path, "r") as f:
1400
+ with open(credentials_path, "r", encoding="utf-8") as f:
1154
1401
  credentials = json.load(f)
1155
1402
 
1403
+ required = ("homeserver", "access_token", "user_id", "device_id")
1404
+ if not all(
1405
+ isinstance(credentials.get(k), str) and credentials.get(k).strip()
1406
+ for k in required
1407
+ ):
1408
+ print(
1409
+ f"❌ Error: credentials.json at {credentials_path} is missing required fields"
1410
+ )
1411
+ print(f"Run '{get_command('auth_login')}' to authenticate")
1412
+ return 1
1156
1413
  print(f"✅ Found credentials.json at: {credentials_path}")
1157
- print(f" Homeserver: {credentials.get('homeserver', 'Unknown')}")
1158
- print(f" User ID: {credentials.get('user_id', 'Unknown')}")
1159
- print(f" Device ID: {credentials.get('device_id', 'Unknown')}")
1414
+ print(f" Homeserver: {credentials.get('homeserver')}")
1415
+ print(f" User ID: {credentials.get('user_id')}")
1416
+ print(f" Device ID: {credentials.get('device_id')}")
1160
1417
  return 0
1161
1418
  except Exception as e:
1162
1419
  print(f"❌ Error reading credentials.json: {e}")
@@ -1169,15 +1426,21 @@ def handle_auth_status(args):
1169
1426
 
1170
1427
  def handle_auth_logout(args):
1171
1428
  """
1172
- Log out the bot from Matrix and remove local session artifacts.
1429
+ Log out the Matrix bot and remove local session artifacts.
1173
1430
 
1174
- Prompts for a verification password (unless provided via args.password), warns if the password was supplied on the command line, asks for confirmation unless args.yes is True, and then performs the logout by calling the logout_matrix_bot routine. On success the function returns 0; on failure or cancellation it returns 1. KeyboardInterrupt is treated as a cancellation and returns 1.
1431
+ Prompts for a verification password (unless a non-empty password is provided via args.password),
1432
+ optionally asks for interactive confirmation (skipped if args.yes is True), and attempts to clear
1433
+ local session data (credentials, E2EE store) and invalidate the bot's access token.
1175
1434
 
1176
1435
  Parameters:
1177
- args (argparse.Namespace): CLI arguments. Relevant attributes:
1178
- password (str | None): If provided and non-empty, used as the verification password.
1179
- If provided as an empty string or omitted, the function will prompt securely.
1180
- yes (bool): If True, skip the interactive confirmation prompt.
1436
+ args (argparse.Namespace): CLI arguments with the following relevant attributes:
1437
+ password (str | None): If a non-empty string is provided, it will be used as the
1438
+ verification password. If None or an empty string, the function prompts securely.
1439
+ yes (bool): If True, skip the confirmation prompt.
1440
+
1441
+ Returns:
1442
+ int: 0 on successful logout, 1 on failure or if the operation is cancelled (including
1443
+ KeyboardInterrupt).
1181
1444
  """
1182
1445
  import asyncio
1183
1446
 
@@ -1197,7 +1460,11 @@ def handle_auth_logout(args):
1197
1460
  # Handle password input
1198
1461
  password = getattr(args, "password", None)
1199
1462
 
1200
- if password is None or password == "":
1463
+ if (
1464
+ password is None
1465
+ or password
1466
+ == "" # nosec B105 (user-entered secret; prompting securely via getpass)
1467
+ ):
1201
1468
  # No --password flag or --password with no value, prompt securely
1202
1469
  import getpass
1203
1470
 
@@ -1231,10 +1498,16 @@ def handle_auth_logout(args):
1231
1498
 
1232
1499
  def handle_service_command(args):
1233
1500
  """
1234
- Handle service-related CLI subcommands.
1235
-
1236
- Currently supports the "install" subcommand which attempts to import and run mmrelay.setup_utils.install_service.
1237
- Returns 0 on success, 1 on failure or for unknown subcommands. Prints an error message if setup utilities cannot be imported.
1501
+ Dispatch service-related subcommands.
1502
+
1503
+ Currently supports the "install" subcommand which imports and runs mmrelay.setup_utils.install_service().
1504
+ Returns 0 on successful installation, 1 on failure or for unknown subcommands.
1505
+
1506
+ Parameters:
1507
+ args: argparse.Namespace with a `service_command` attribute indicating the requested action.
1508
+
1509
+ Returns:
1510
+ int: Exit code (0 on success, 1 on error).
1238
1511
  """
1239
1512
  if args.service_command == "install":
1240
1513
  try:
@@ -1249,6 +1522,259 @@ def handle_service_command(args):
1249
1522
  return 1
1250
1523
 
1251
1524
 
1525
+ def _diagnose_config_paths(args):
1526
+ """
1527
+ Print a diagnostic summary of resolved configuration file search paths and their directory accessibility.
1528
+
1529
+ Uses get_config_paths(args) to compute the ordered list of candidate config file locations, then prints each path with a concise directory status icon:
1530
+ - ✅ directory exists and is writable
1531
+ - ⚠️ directory exists but is not writable
1532
+ - ❌ directory does not exist
1533
+
1534
+ Parameters:
1535
+ args (argparse.Namespace): Parsed CLI arguments used to determine the config search order.
1536
+ """
1537
+ print("1. Testing configuration paths...")
1538
+ from mmrelay.config import get_config_paths
1539
+
1540
+ paths = get_config_paths(args)
1541
+ print(f" Config search paths: {len(paths)} locations")
1542
+ for i, path in enumerate(paths, 1):
1543
+ dir_path = os.path.dirname(path)
1544
+ dir_exists = os.path.exists(dir_path)
1545
+ dir_writable = os.access(dir_path, os.W_OK) if dir_exists else False
1546
+ status = "✅" if dir_exists and dir_writable else "⚠️" if dir_exists else "❌"
1547
+ print(f" {i}. {path} {status}")
1548
+ print()
1549
+
1550
+
1551
+ def _diagnose_sample_config_accessibility():
1552
+ """
1553
+ Check availability of the bundled sample configuration and print a short diagnostic.
1554
+
1555
+ Performs two non-destructive checks and prints human-readable results:
1556
+ 1) Verifies whether the sample config file exists at the path returned by mmrelay.tools.get_sample_config_path().
1557
+ 2) Attempts to read the embedded resource "sample_config.yaml" from the mmrelay.tools package via importlib.resources and reports success and the content length.
1558
+
1559
+ Returns:
1560
+ bool: True if a filesystem sample config exists at the resolved path; False otherwise.
1561
+ """
1562
+ print("2. Testing sample config accessibility...")
1563
+ from mmrelay.tools import get_sample_config_path
1564
+
1565
+ sample_path = get_sample_config_path()
1566
+ sample_exists = os.path.exists(sample_path)
1567
+ print(f" Sample config path: {sample_path}")
1568
+ print(f" Sample config exists: {'✅' if sample_exists else '❌'}")
1569
+
1570
+ # Test importlib.resources fallback
1571
+ try:
1572
+ import importlib.resources
1573
+
1574
+ content = (
1575
+ importlib.resources.files("mmrelay.tools")
1576
+ .joinpath("sample_config.yaml")
1577
+ .read_text()
1578
+ )
1579
+ print(f" importlib.resources fallback: ✅ ({len(content)} chars)")
1580
+ except (FileNotFoundError, ImportError, OSError) as e:
1581
+ print(f" importlib.resources fallback: ❌ ({e})")
1582
+ print()
1583
+
1584
+ return sample_exists
1585
+
1586
+
1587
+ def _diagnose_platform_specific(args):
1588
+ """
1589
+ Run platform-specific diagnostic checks.
1590
+
1591
+ On Windows, imports and runs Windows-specific requirement checks and a configuration-generation
1592
+ test from mmrelay.windows_utils, printing per-component results and any warnings. On non-Windows
1593
+ platforms this reports that platform-specific tests are not required.
1594
+
1595
+ Parameters:
1596
+ args (argparse.Namespace): Parsed CLI arguments; passed through to the Windows
1597
+ config-generation test when running on Windows.
1598
+
1599
+ Returns:
1600
+ bool: True if the platform is Windows (Windows checks were executed), False otherwise.
1601
+ """
1602
+ print("3. Platform-specific diagnostics...")
1603
+ import sys
1604
+
1605
+ from mmrelay.constants.app import WINDOWS_PLATFORM
1606
+
1607
+ on_windows = sys.platform == WINDOWS_PLATFORM
1608
+ print(f" Platform: {sys.platform}")
1609
+ print(f" Windows: {'Yes' if on_windows else 'No'}")
1610
+
1611
+ if on_windows:
1612
+ try:
1613
+ from mmrelay.windows_utils import (
1614
+ check_windows_requirements,
1615
+ test_config_generation_windows,
1616
+ )
1617
+
1618
+ # Check Windows requirements
1619
+ warnings = check_windows_requirements()
1620
+ if warnings:
1621
+ print(" Windows warnings: ⚠️")
1622
+ for line in warnings.split("\n"):
1623
+ if line.strip():
1624
+ print(f" {line}")
1625
+ else:
1626
+ print(" Windows compatibility: ✅")
1627
+
1628
+ # Run Windows-specific tests
1629
+ print("\n Windows config generation test:")
1630
+ results = test_config_generation_windows(args)
1631
+
1632
+ for component, result in results.items():
1633
+ if component == "overall_status":
1634
+ continue
1635
+ if isinstance(result, dict):
1636
+ status_icon = (
1637
+ "✅"
1638
+ if result["status"] == "ok"
1639
+ else "❌" if result["status"] == "error" else "⚠️"
1640
+ )
1641
+ print(f" {component}: {status_icon}")
1642
+
1643
+ overall = results.get("overall_status", "unknown")
1644
+ print(
1645
+ f" Overall Windows status: {'✅' if overall == 'ok' else '⚠️' if overall == 'partial' else '❌'}"
1646
+ )
1647
+
1648
+ except ImportError:
1649
+ print(" Windows utilities: ❌ (not available)")
1650
+ else:
1651
+ print(" Platform-specific tests: ✅ (Unix-like system)")
1652
+
1653
+ print()
1654
+ return on_windows
1655
+
1656
+
1657
+ def _get_minimal_config_template():
1658
+ """
1659
+ Return a minimal MMRelay YAML configuration template used as a fallback when the packaged sample_config.yaml cannot be located.
1660
+
1661
+ The template contains a small, functional configuration (matrix connection hints, a serial meshtastic connection, one room entry, and basic logging) that users can edit to create a working config file.
1662
+
1663
+ Returns:
1664
+ str: A YAML-formatted minimal configuration template.
1665
+ """
1666
+ return """# MMRelay Configuration File
1667
+ # This is a minimal template created when the full sample config was unavailable
1668
+ # For complete configuration options, visit:
1669
+ # https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki
1670
+
1671
+ matrix:
1672
+ homeserver: https://matrix.example.org
1673
+ # Use 'mmrelay auth login' to set up authentication
1674
+ # access_token: your_access_token_here
1675
+ # bot_user_id: '@your_bot:matrix.example.org'
1676
+
1677
+ meshtastic:
1678
+ connection_type: serial
1679
+ serial_port: /dev/ttyUSB0 # Windows: COM3, macOS: /dev/cu.usbserial-*
1680
+ # host: meshtastic.local # For network connection
1681
+ # ble_address: "your_device_address" # For BLE connection
1682
+
1683
+ matrix_rooms:
1684
+ - id: '#your-room:matrix.example.org'
1685
+ meshtastic_channel: 0
1686
+
1687
+ logging:
1688
+ level: info
1689
+
1690
+ # Uncomment and configure as needed:
1691
+ # database:
1692
+ # msg_map:
1693
+ # msgs_to_keep: 100
1694
+
1695
+ # plugins:
1696
+ # ping:
1697
+ # active: true
1698
+ # weather:
1699
+ # active: true
1700
+ # units: metric
1701
+ """
1702
+
1703
+
1704
+ def _diagnose_minimal_config_template():
1705
+ """
1706
+ Validate the built-in minimal YAML configuration template and print a concise pass/fail status.
1707
+
1708
+ Attempts to parse the string returned by _get_minimal_config_template() using yaml.safe_load. Prints a single-line result showing a ✅ with the template character length when the template is valid YAML, or a ❌ with the YAML parsing error when invalid. This is a non-destructive diagnostic helper that prints output and does not return a value.
1709
+ """
1710
+ print("4. Testing minimal config template fallback...")
1711
+ try:
1712
+ template = _get_minimal_config_template()
1713
+ yaml.safe_load(template)
1714
+ print(f" Minimal template: ✅ ({len(template)} chars, valid YAML)")
1715
+ except yaml.YAMLError as e:
1716
+ print(f" Minimal template: ❌ ({e})")
1717
+
1718
+ print()
1719
+
1720
+
1721
+ def handle_config_diagnose(args):
1722
+ """
1723
+ Run non-destructive diagnostics for the MMRelay configuration subsystem and print a human-readable report.
1724
+
1725
+ Performs four checks: resolves candidate config paths and reports directory accessibility; verifies packaged sample config availability (filesystem and importlib.resources fallback); runs platform-specific diagnostics (Windows-focused where applicable); and validates the built-in minimal YAML template. Prints findings and suggested next steps to stdout/stderr.
1726
+
1727
+ Parameters:
1728
+ args (argparse.Namespace): Parsed CLI arguments used to resolve config search paths and to control platform-specific checks.
1729
+
1730
+ Returns:
1731
+ int: Exit code (0 on success, 1 on failure).
1732
+ """
1733
+ print("MMRelay Configuration System Diagnostics")
1734
+ print("=" * 40)
1735
+ print()
1736
+
1737
+ try:
1738
+ # Test 1: Basic config path resolution
1739
+ _diagnose_config_paths(args)
1740
+
1741
+ # Test 2: Sample config accessibility
1742
+ sample_exists = _diagnose_sample_config_accessibility()
1743
+
1744
+ # Test 3: Platform-specific diagnostics
1745
+ on_windows = _diagnose_platform_specific(args)
1746
+
1747
+ # Test 4: Minimal config template
1748
+ _diagnose_minimal_config_template()
1749
+
1750
+ print("=" * 40)
1751
+ print("Diagnostics complete!")
1752
+
1753
+ # Provide guidance based on results
1754
+ if on_windows and not sample_exists:
1755
+ print("\n💡 Windows Troubleshooting Tips:")
1756
+ print(" • Try: pip install --upgrade --force-reinstall mmrelay")
1757
+ print(" • Use: python -m mmrelay config generate")
1758
+ print(" • Check antivirus software for quarantined files")
1759
+
1760
+ return 0
1761
+
1762
+ except Exception as e:
1763
+ print(f"❌ Diagnostics failed: {e}", file=sys.stderr)
1764
+
1765
+ # Provide platform-specific guidance
1766
+ try:
1767
+ from mmrelay.windows_utils import get_windows_error_message, is_windows
1768
+
1769
+ if is_windows():
1770
+ error_msg = get_windows_error_message(e)
1771
+ print(f"\nWindows-specific guidance: {error_msg}", file=sys.stderr)
1772
+ except ImportError:
1773
+ pass
1774
+
1775
+ return 1
1776
+
1777
+
1252
1778
  if __name__ == "__main__":
1253
1779
  import sys
1254
1780
 
@@ -1311,7 +1837,8 @@ def generate_sample_config():
1311
1837
  If an existing config file is found in any candidate path (from get_config_paths()), this function aborts and prints its location. Otherwise it creates a sample config at the first candidate path. Sources tried, in order, are:
1312
1838
  - the path returned by get_sample_config_path(),
1313
1839
  - the packaged resource mmrelay.tools:sample_config.yaml via importlib.resources,
1314
- - a set of fallback filesystem locations relative to the package and current working directory.
1840
+ - a set of fallback filesystem locations relative to the package and current working directory,
1841
+ - a minimal configuration template as a last resort.
1315
1842
 
1316
1843
  On success the sample is written to disk and (on Unix-like systems) secure file permissions are applied (owner read/write, 0o600). Returns True when a sample config is successfully generated and False on any error or if a config already exists.
1317
1844
  """
@@ -1356,7 +1883,17 @@ def generate_sample_config():
1356
1883
  )
1357
1884
  return True
1358
1885
  except (IOError, OSError) as e:
1359
- print(f"Error copying sample config file: {e}")
1886
+ # Provide Windows-specific error guidance if available
1887
+ try:
1888
+ from mmrelay.windows_utils import get_windows_error_message, is_windows
1889
+
1890
+ if is_windows():
1891
+ error_msg = get_windows_error_message(e)
1892
+ print(f"Error copying sample config file: {error_msg}")
1893
+ else:
1894
+ print(f"Error copying sample config file: {e}")
1895
+ except ImportError:
1896
+ print(f"Error copying sample config file: {e}")
1360
1897
  return False
1361
1898
 
1362
1899
  # If the helper function failed, try using importlib.resources directly
@@ -1369,7 +1906,7 @@ def generate_sample_config():
1369
1906
  )
1370
1907
 
1371
1908
  # Write the sample config to the target path
1372
- with open(target_path, "w") as f:
1909
+ with open(target_path, "w", encoding="utf-8") as f:
1373
1910
  f.write(sample_config_content)
1374
1911
 
1375
1912
  # Set secure permissions on Unix systems (600 - owner read/write)
@@ -1381,7 +1918,17 @@ def generate_sample_config():
1381
1918
  )
1382
1919
  return True
1383
1920
  except (FileNotFoundError, ImportError, OSError) as e:
1384
- print(f"Error accessing sample_config.yaml: {e}")
1921
+ print(f"Error accessing sample_config.yaml via importlib.resources: {e}")
1922
+
1923
+ # Provide Windows-specific guidance if needed
1924
+ try:
1925
+ from mmrelay.windows_utils import is_windows
1926
+
1927
+ if is_windows():
1928
+ print("This may be due to Windows installer packaging differences.")
1929
+ print("Trying alternative methods...")
1930
+ except ImportError:
1931
+ pass
1385
1932
 
1386
1933
  # Fallback to traditional file paths if importlib.resources fails
1387
1934
  # First, check in the package directory
@@ -1409,8 +1956,61 @@ def generate_sample_config():
1409
1956
  )
1410
1957
  return True
1411
1958
  except (IOError, OSError) as e:
1412
- print(f"Error copying sample config file from {path}: {e}")
1959
+ # Provide Windows-specific error guidance if available
1960
+ try:
1961
+ from mmrelay.windows_utils import (
1962
+ get_windows_error_message,
1963
+ is_windows,
1964
+ )
1965
+
1966
+ if is_windows():
1967
+ error_msg = get_windows_error_message(e)
1968
+ print(
1969
+ f"Error copying sample config file from {path}: {error_msg}"
1970
+ )
1971
+ else:
1972
+ print(f"Error copying sample config file from {path}: {e}")
1973
+ except ImportError:
1974
+ print(f"Error copying sample config file from {path}: {e}")
1413
1975
  return False
1414
1976
 
1415
- print("Error: Could not find sample_config.yaml")
1977
+ print("Error: Could not find sample_config.yaml in any location")
1978
+
1979
+ # Last resort: create a minimal config template
1980
+ print("\nAttempting to create minimal config template...")
1981
+ try:
1982
+ minimal_config = _get_minimal_config_template()
1983
+ with open(target_path, "w", encoding="utf-8") as f:
1984
+ f.write(minimal_config)
1985
+
1986
+ # Set secure permissions on Unix systems
1987
+ set_secure_file_permissions(target_path)
1988
+
1989
+ print(f"Created minimal config template at: {target_path}")
1990
+ print(
1991
+ "\n⚠️ This is a minimal template. Please refer to documentation for full configuration options."
1992
+ )
1993
+ print("Visit: https://github.com/jeremiah-k/meshtastic-matrix-relay/wiki")
1994
+ return True
1995
+
1996
+ except (IOError, OSError) as e:
1997
+ print(f"Failed to create minimal config template: {e}")
1998
+
1999
+ # Provide Windows-specific troubleshooting guidance
2000
+ try:
2001
+ from mmrelay.windows_utils import is_windows
2002
+
2003
+ if is_windows():
2004
+ print("\nWindows Troubleshooting:")
2005
+ print("1. Check if MMRelay was installed correctly")
2006
+ print("2. Try reinstalling with: pipx install --force mmrelay")
2007
+ print(
2008
+ "3. Use alternative entry point: python -m mmrelay config generate"
2009
+ )
2010
+ print("4. Check antivirus software - it may have quarantined files")
2011
+ print("5. Run diagnostics: python -m mmrelay config diagnose")
2012
+ print("6. Manually create config file using documentation")
2013
+ except ImportError:
2014
+ pass
2015
+
1416
2016
  return False