mmrelay 1.1.4__py3-none-any.whl → 1.2.0__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
@@ -5,14 +5,26 @@ Command-line interface handling for the Meshtastic Matrix Relay.
5
5
  import argparse
6
6
  import importlib.resources
7
7
  import os
8
+ import shutil
8
9
  import sys
9
10
 
10
- import yaml
11
- from yaml.loader import SafeLoader
12
-
13
11
  # Import version from package
14
12
  from mmrelay import __version__
15
- from mmrelay.config import get_config_paths
13
+ from mmrelay.cli_utils import (
14
+ get_command,
15
+ get_deprecation_warning,
16
+ msg_for_e2ee_support,
17
+ msg_or_run_auth_login,
18
+ msg_run_auth_login,
19
+ msg_setup_auth,
20
+ msg_setup_authentication,
21
+ msg_suggest_generate_config,
22
+ )
23
+ from mmrelay.config import (
24
+ get_config_paths,
25
+ set_secure_file_permissions,
26
+ validate_yaml_syntax,
27
+ )
16
28
  from mmrelay.constants.app import WINDOWS_PLATFORM
17
29
  from mmrelay.constants.config import (
18
30
  CONFIG_KEY_ACCESS_TOKEN,
@@ -33,15 +45,26 @@ from mmrelay.constants.network import (
33
45
  )
34
46
  from mmrelay.tools import get_sample_config_path
35
47
 
48
+ # =============================================================================
49
+ # CLI Argument Parsing and Command Handling
50
+ # =============================================================================
51
+
36
52
 
37
53
  def parse_arguments():
38
54
  """
39
- Parse and validate command-line arguments for the Meshtastic Matrix Relay CLI.
55
+ Parse command-line arguments for the Meshtastic Matrix Relay CLI.
56
+
57
+ Builds a modern grouped CLI with subcommands for config (generate, check), auth (login, status),
58
+ and service (install), while preserving hidden legacy flags (--generate-config, --install-service,
59
+ --check-config, --auth) for backward compatibility. Supports global options: --config,
60
+ --data-dir, --log-level, --logfile, and --version.
40
61
 
41
- Supports options for specifying configuration file, data directory, logging preferences, version display, sample configuration generation, service installation, and configuration validation. On Windows, also accepts a deprecated positional argument for the config file path with a warning. Ignores unknown arguments outside of test environments and warns if any are present.
62
+ Unknown arguments are ignored when running outside of test environments (parsed via
63
+ parse_known_args); a warning is printed if unknown args are present and the process does not
64
+ appear to be a test run.
42
65
 
43
66
  Returns:
44
- argparse.Namespace: Parsed command-line arguments.
67
+ argparse.Namespace: The parsed arguments namespace.
45
68
  """
46
69
  parser = argparse.ArgumentParser(
47
70
  description="Meshtastic Matrix Relay - Bridge between Meshtastic and Matrix"
@@ -64,28 +87,105 @@ def parse_arguments():
64
87
  default=None,
65
88
  )
66
89
  parser.add_argument("--version", action="store_true", help="Show version and exit")
90
+ # Deprecated flags (hidden from help but still functional)
67
91
  parser.add_argument(
68
92
  "--generate-config",
69
93
  action="store_true",
70
- help="Generate a sample config.yaml file",
94
+ help=argparse.SUPPRESS,
71
95
  )
72
96
  parser.add_argument(
73
97
  "--install-service",
74
98
  action="store_true",
75
- help="Install or update the systemd user service",
99
+ help=argparse.SUPPRESS,
76
100
  )
77
101
  parser.add_argument(
78
102
  "--check-config",
79
103
  action="store_true",
80
- help="Check if the configuration file is valid",
104
+ help=argparse.SUPPRESS,
105
+ )
106
+ parser.add_argument(
107
+ "--auth",
108
+ action="store_true",
109
+ help=argparse.SUPPRESS,
81
110
  )
82
111
 
83
- # Windows-specific handling for backward compatibility
84
- # On Windows, add a positional argument for the config file path
85
- if sys.platform == WINDOWS_PLATFORM:
86
- parser.add_argument(
87
- "config_path", nargs="?", help=argparse.SUPPRESS, default=None
88
- )
112
+ # Add grouped subcommands for modern CLI interface
113
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
114
+
115
+ # CONFIG group
116
+ config_parser = subparsers.add_parser(
117
+ "config",
118
+ help="Configuration management",
119
+ description="Manage configuration files and validation",
120
+ )
121
+ config_subparsers = config_parser.add_subparsers(
122
+ dest="config_command", help="Config commands", required=True
123
+ )
124
+ config_subparsers.add_parser(
125
+ "generate",
126
+ help="Create sample config.yaml file",
127
+ description="Generate a sample configuration file with default settings",
128
+ )
129
+ config_subparsers.add_parser(
130
+ "check",
131
+ help="Validate configuration file",
132
+ description="Check configuration file syntax and completeness",
133
+ )
134
+
135
+ # AUTH group
136
+ auth_parser = subparsers.add_parser(
137
+ "auth",
138
+ help="Authentication management",
139
+ description="Manage Matrix authentication and credentials",
140
+ )
141
+ auth_subparsers = auth_parser.add_subparsers(
142
+ dest="auth_command", help="Auth commands"
143
+ )
144
+ auth_subparsers.add_parser(
145
+ "login",
146
+ help="Authenticate with Matrix",
147
+ description="Set up Matrix authentication for E2EE support",
148
+ )
149
+
150
+ auth_subparsers.add_parser(
151
+ "status",
152
+ help="Check authentication status",
153
+ description="Display current Matrix authentication status",
154
+ )
155
+
156
+ logout_parser = auth_subparsers.add_parser(
157
+ "logout",
158
+ help="Log out and clear all sessions",
159
+ description="Clear all Matrix authentication data and E2EE store",
160
+ )
161
+ logout_parser.add_argument(
162
+ "--password",
163
+ nargs="?",
164
+ const="",
165
+ help="Password for verification. If no value provided, will prompt securely.",
166
+ type=str,
167
+ )
168
+ logout_parser.add_argument(
169
+ "-y",
170
+ "--yes",
171
+ action="store_true",
172
+ help="Do not prompt for confirmation (useful for non-interactive environments)",
173
+ )
174
+
175
+ # SERVICE group
176
+ service_parser = subparsers.add_parser(
177
+ "service",
178
+ help="Service management",
179
+ description="Manage systemd user service for MMRelay",
180
+ )
181
+ service_subparsers = service_parser.add_subparsers(
182
+ dest="service_command", help="Service commands", required=True
183
+ )
184
+ service_subparsers.add_parser(
185
+ "install",
186
+ help="Install systemd user service",
187
+ description="Install or update the systemd user service for MMRelay",
188
+ )
89
189
 
90
190
  # Use parse_known_args to handle unknown arguments gracefully (e.g., pytest args)
91
191
  args, unknown = parser.parse_known_args()
@@ -93,21 +193,6 @@ def parse_arguments():
93
193
  if unknown and not any("pytest" in arg or "test" in arg for arg in sys.argv):
94
194
  print(f"Warning: Unknown arguments ignored: {unknown}")
95
195
 
96
- # If on Windows and a positional config path is provided but --config is not, use the positional one
97
- if (
98
- sys.platform == WINDOWS_PLATFORM
99
- and hasattr(args, "config_path")
100
- and args.config_path
101
- and not args.config
102
- ):
103
- args.config = args.config_path
104
- # Print a deprecation warning
105
- print("Warning: Using positional argument for config file is deprecated.")
106
- print(f"Please use --config {args.config_path} instead.")
107
- # Remove the positional argument from sys.argv to avoid issues with other argument parsers
108
- if args.config_path in sys.argv:
109
- sys.argv.remove(args.config_path)
110
-
111
196
  return args
112
197
 
113
198
 
@@ -125,20 +210,498 @@ def print_version():
125
210
  """
126
211
  Print the version in a simple format.
127
212
  """
128
- print(f"MMRelay v{__version__}")
213
+ print(f"MMRelay version {__version__}")
129
214
 
130
215
 
131
- def check_config(args=None):
216
+ def _validate_e2ee_dependencies():
217
+ """
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
+
223
+ 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.
132
226
  """
133
- Validates the application's configuration file for required structure and fields.
227
+ if sys.platform == WINDOWS_PLATFORM:
228
+ print("❌ Error: E2EE is not supported on Windows")
229
+ print(" Reason: python-olm library requires native C libraries")
230
+ print(" Solution: Use Linux or macOS for E2EE support")
231
+ return False
232
+
233
+ # Check if E2EE dependencies are available
234
+ try:
235
+ import olm # noqa: F401
236
+ from nio.crypto import OlmDevice # noqa: F401
237
+ from nio.store import SqliteStore # noqa: F401
238
+
239
+ print("✅ E2EE dependencies are installed")
240
+ return True
241
+ except ImportError:
242
+ print("❌ Error: E2EE enabled but dependencies not installed")
243
+ print(" Install E2EE support: pipx install 'mmrelay[e2e]'")
244
+ return False
245
+
246
+
247
+ def _validate_credentials_json(config_path):
248
+ """
249
+ Validate that a credentials.json file exists next to the given config and contains the required Matrix authentication fields.
134
250
 
135
- If a configuration file is found, checks for the presence and correctness of required sections and keys, including Matrix and Meshtastic settings, and validates the format of matrix rooms. Prints errors or warnings for missing or deprecated fields. Returns True if the configuration is valid, otherwise False.
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".
136
252
 
137
253
  Parameters:
138
- args: Parsed command-line arguments. If None, arguments are parsed internally.
254
+ config_path (str): Path to the configuration file used to determine the primary search directory for credentials.json.
139
255
 
140
256
  Returns:
141
- bool: True if the configuration file is valid, False otherwise.
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.
258
+ """
259
+ try:
260
+ import json
261
+
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
275
+
276
+ # Load and validate credentials
277
+ with open(credentials_path, "r") as f:
278
+ credentials = json.load(f)
279
+
280
+ # Check for required fields
281
+ required_fields = ["homeserver", "access_token", "user_id", "device_id"]
282
+ missing_fields = [
283
+ field
284
+ for field in required_fields
285
+ if field not in credentials or not credentials[field]
286
+ ]
287
+
288
+ if missing_fields:
289
+ print(
290
+ f"❌ Error: credentials.json missing required fields: {', '.join(missing_fields)}"
291
+ )
292
+ print(f" {msg_run_auth_login()}")
293
+ return False
294
+
295
+ return True
296
+ except Exception as e:
297
+ print(f"❌ Error: Could not validate credentials.json: {e}")
298
+ return False
299
+
300
+
301
+ def _validate_matrix_authentication(config_path, matrix_section):
302
+ """
303
+ Determine whether Matrix authentication is configured and usable.
304
+
305
+ Checks for a valid credentials.json (located relative to the provided config path) and, if not present,
306
+ falls back to an access_token in the provided matrix_section. Returns True when authentication
307
+ information is found and usable; returns False when no authentication is configured.
308
+
309
+ Parameters:
310
+ config_path (str | os.PathLike): Path to the application's YAML config file; used to locate a
311
+ credentials.json candidate in the same directory or standard locations.
312
+ matrix_section (Mapping | None): The parsed "matrix" configuration section (mapping-like). If
313
+ provided, an "access_token" key will be considered as a valid fallback when credentials.json
314
+ is absent.
315
+
316
+ Returns:
317
+ bool: True if a valid authentication method (credentials.json or access_token) is available,
318
+ False otherwise.
319
+
320
+ Notes:
321
+ - The function prefers credentials.json over an access_token if both are present.
322
+ - The function emits user-facing status messages describing which authentication source is used
323
+ and whether E2EE support is available.
324
+ """
325
+ has_valid_credentials = _validate_credentials_json(config_path)
326
+ has_access_token = matrix_section and "access_token" in matrix_section
327
+
328
+ if has_valid_credentials:
329
+ print("✅ Using credentials.json for Matrix authentication")
330
+ if sys.platform != WINDOWS_PLATFORM:
331
+ print(" E2EE support available (if enabled)")
332
+ return True
333
+
334
+ elif has_access_token:
335
+ print("✅ Using access_token for Matrix authentication")
336
+ print(f" {msg_for_e2ee_support()}")
337
+ return True
338
+
339
+ else:
340
+ print("❌ Error: No Matrix authentication configured")
341
+ print(f" {msg_setup_auth()}")
342
+ return False
343
+
344
+
345
+ def _validate_e2ee_config(config, matrix_section, config_path):
346
+ """
347
+ Validate end-to-end encryption (E2EE) configuration and Matrix authentication for the given config.
348
+
349
+ Performs these checks:
350
+ - Confirms Matrix authentication is available (via credentials.json or matrix access token); returns False if authentication is missing or invalid.
351
+ - If no matrix section is present, treats E2EE as not configured and returns True.
352
+ - If E2EE/encryption is enabled in the matrix config, verifies platform/dependency support and inspects the configured store path. If the store directory does not yet exist, a note is printed indicating it will be created.
353
+
354
+ Parameters:
355
+ config_path (str): Path to the active configuration file (used to locate credentials.json and related auth artifacts).
356
+ matrix_section (dict | None): The "matrix" subsection of the parsed config (may be None or empty).
357
+ config (dict): Full parsed configuration (unused for most checks but kept for consistency with caller signature).
358
+
359
+ Returns:
360
+ bool: True if configuration and required authentication/dependencies are valid (or E2EE is not configured); False if validation fails.
361
+
362
+ Side effects:
363
+ Prints informational or error messages about authentication, dependency checks, and E2EE store path status.
364
+ """
365
+ # First validate authentication
366
+ if not _validate_matrix_authentication(config_path, matrix_section):
367
+ return False
368
+
369
+ # Check for E2EE configuration
370
+ if not matrix_section:
371
+ return True # No matrix section means no E2EE config to validate
372
+
373
+ e2ee_config = matrix_section.get("e2ee", {})
374
+ encryption_config = matrix_section.get("encryption", {}) # Legacy support
375
+
376
+ e2ee_enabled = e2ee_config.get("enabled", False) or encryption_config.get(
377
+ "enabled", False
378
+ )
379
+
380
+ if e2ee_enabled:
381
+ # Platform and dependency check
382
+ if not _validate_e2ee_dependencies():
383
+ return False
384
+
385
+ # Store path validation
386
+ store_path = e2ee_config.get("store_path") or encryption_config.get(
387
+ "store_path"
388
+ )
389
+ if store_path:
390
+ expanded_path = os.path.expanduser(store_path)
391
+ if not os.path.exists(os.path.dirname(expanded_path)):
392
+ print(f"ℹ️ Note: E2EE store directory will be created: {expanded_path}")
393
+
394
+ print("✅ E2EE configuration is valid")
395
+
396
+ return True
397
+
398
+
399
+ def _analyze_e2ee_setup(config, config_path):
400
+ """
401
+ Analyze local E2EE readiness without contacting Matrix.
402
+
403
+ Performs an offline inspection of the environment and configuration to determine
404
+ whether end-to-end encryption (E2EE) can be used. Checks platform support
405
+ (Windows is considered unsupported), presence of required Python dependencies
406
+ (olm and selected nio components), whether E2EE is enabled in the provided
407
+ config, and whether a credentials.json is available adjacent to the supplied
408
+ config_path or in the standard base directory.
409
+
410
+ Parameters:
411
+ config (dict): Parsed configuration (typically from config.yaml). Only the
412
+ "matrix" section is consulted to detect E2EE/encryption enablement.
413
+ config_path (str): Path to the configuration file used to locate a
414
+ credentials.json sibling; also used to resolve an alternate standard
415
+ credentials location.
416
+
417
+ Returns:
418
+ dict: Analysis summary with these keys:
419
+ - config_enabled (bool): True if E2EE/encryption is enabled in config.
420
+ - dependencies_available (bool): True if required E2EE packages are
421
+ importable.
422
+ - credentials_available (bool): True if a usable credentials.json was
423
+ found.
424
+ - platform_supported (bool): False on unsupported platforms (Windows).
425
+ - overall_status (str): One of "ready", "disabled", "not_supported",
426
+ "incomplete", or "unknown" describing the combined readiness.
427
+ - recommendations (list): Human-actionable strings suggesting fixes or
428
+ next steps (e.g., enable E2EE in config, install dependencies, run
429
+ auth login).
430
+ """
431
+ analysis = {
432
+ "config_enabled": False,
433
+ "dependencies_available": False,
434
+ "credentials_available": False,
435
+ "platform_supported": True,
436
+ "overall_status": "unknown",
437
+ "recommendations": [],
438
+ }
439
+
440
+ # Check platform support
441
+ if sys.platform == WINDOWS_PLATFORM:
442
+ analysis["platform_supported"] = False
443
+ analysis["recommendations"].append(
444
+ "E2EE is not supported on Windows. Use Linux/macOS for E2EE support."
445
+ )
446
+
447
+ # Check dependencies
448
+ try:
449
+ import olm # noqa: F401
450
+ from nio.crypto import OlmDevice # noqa: F401
451
+ from nio.store import SqliteStore # noqa: F401
452
+
453
+ analysis["dependencies_available"] = True
454
+ except ImportError:
455
+ analysis["dependencies_available"] = False
456
+ analysis["recommendations"].append(
457
+ "Install E2EE dependencies: pipx install 'mmrelay[e2e]'"
458
+ )
459
+
460
+ # Check config setting
461
+ matrix_section = config.get("matrix", {})
462
+ e2ee_config = matrix_section.get("e2ee", {})
463
+ encryption_config = matrix_section.get("encryption", {}) # Legacy support
464
+ analysis["config_enabled"] = e2ee_config.get(
465
+ "enabled", False
466
+ ) or encryption_config.get("enabled", False)
467
+
468
+ if not analysis["config_enabled"]:
469
+ analysis["recommendations"].append(
470
+ "Enable E2EE in config.yaml under matrix section: e2ee: enabled: true"
471
+ )
472
+
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)
486
+
487
+ if not analysis["credentials_available"]:
488
+ analysis["recommendations"].append(
489
+ "Set up Matrix authentication: mmrelay auth login"
490
+ )
491
+
492
+ # Determine overall status based on setup only
493
+ if not analysis["platform_supported"]:
494
+ analysis["overall_status"] = "not_supported"
495
+ elif (
496
+ analysis["config_enabled"]
497
+ and analysis["dependencies_available"]
498
+ and analysis["credentials_available"]
499
+ ):
500
+ analysis["overall_status"] = "ready"
501
+ elif not analysis["config_enabled"]:
502
+ analysis["overall_status"] = "disabled"
503
+ else:
504
+ analysis["overall_status"] = "incomplete"
505
+
506
+ return analysis
507
+
508
+
509
+ def _print_unified_e2ee_analysis(e2ee_status):
510
+ """
511
+ Print a concise, user-facing analysis of E2EE readiness from a centralized status object.
512
+
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.
516
+
517
+ 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)
525
+ """
526
+ print("\n🔐 E2EE Configuration Analysis:")
527
+
528
+ # Platform support
529
+ if e2ee_status["platform_supported"]:
530
+ print("✅ Platform: E2EE supported")
531
+ else:
532
+ print("❌ Platform: E2EE not supported on Windows")
533
+
534
+ # Dependencies
535
+ if e2ee_status["dependencies_installed"]:
536
+ print("✅ Dependencies: E2EE dependencies installed")
537
+ else:
538
+ print("❌ Dependencies: E2EE dependencies not fully installed")
539
+
540
+ # Configuration
541
+ if e2ee_status["enabled"]:
542
+ print("✅ Configuration: E2EE enabled")
543
+ else:
544
+ print("❌ Configuration: E2EE disabled")
545
+
546
+ # Authentication
547
+ if e2ee_status["credentials_available"]:
548
+ print("✅ Authentication: credentials.json found")
549
+ else:
550
+ print("❌ Authentication: credentials.json not found")
551
+
552
+ # Overall status
553
+ print(f"\n📊 Overall Status: {e2ee_status['overall_status'].upper()}")
554
+
555
+ # Show fix instructions if needed
556
+ if e2ee_status["overall_status"] != "ready":
557
+ from mmrelay.e2ee_utils import get_e2ee_fix_instructions
558
+
559
+ instructions = get_e2ee_fix_instructions(e2ee_status)
560
+ print("\n🔧 To fix E2EE issues:")
561
+ for instruction in instructions:
562
+ print(f" {instruction}")
563
+
564
+
565
+ def _print_e2ee_analysis(analysis):
566
+ """
567
+ Print a user-facing analysis of end-to-end encryption (E2EE) readiness to standard output.
568
+
569
+ Parameters:
570
+ analysis (dict): Analysis results with the following keys:
571
+ - dependencies_available (bool): True if required E2EE dependencies (e.g., python-olm) are present.
572
+ - credentials_available (bool): True if a usable credentials.json was found.
573
+ - platform_supported (bool): True if the current platform supports E2EE (Windows is considered unsupported).
574
+ - config_enabled (bool): True if E2EE is enabled in the configuration.
575
+ - overall_status (str): One of "ready", "disabled", "not_supported", or "incomplete" indicating the aggregated readiness.
576
+ - recommendations (list[str]): User-facing remediation steps or suggestions (may be empty).
577
+
578
+ Returns:
579
+ None
580
+
581
+ Notes:
582
+ - This function only prints a human-readable report and does not modify state.
583
+ """
584
+ print("\n🔐 E2EE Configuration Analysis:")
585
+
586
+ # Current settings
587
+ print("\n📋 Current Settings:")
588
+
589
+ # Dependencies
590
+ if analysis["dependencies_available"]:
591
+ print(" ✅ Dependencies: Installed (python-olm available)")
592
+ else:
593
+ print(" ❌ Dependencies: Missing (python-olm not installed)")
594
+
595
+ # Credentials
596
+ if analysis["credentials_available"]:
597
+ print(" ✅ Authentication: Ready (credentials.json found)")
598
+ else:
599
+ print(" ❌ Authentication: Missing (no credentials.json)")
600
+
601
+ # Platform
602
+ if not analysis["platform_supported"]:
603
+ print(" ❌ Platform: Windows (E2EE not supported)")
604
+ else:
605
+ print(" ✅ Platform: Supported")
606
+
607
+ # Config setting
608
+ if analysis["config_enabled"]:
609
+ print(" ✅ Configuration: ENABLED (e2ee.enabled: true)")
610
+ else:
611
+ print(" ❌ Configuration: DISABLED (e2ee.enabled: false)")
612
+
613
+ # Predicted behavior
614
+ print("\n🚨 PREDICTED BEHAVIOR:")
615
+ if analysis["overall_status"] == "ready":
616
+ print(" ✅ E2EE is fully configured and ready")
617
+ print(" ✅ Encrypted rooms will receive encrypted messages")
618
+ print(" ✅ Unencrypted rooms will receive normal messages")
619
+ elif analysis["overall_status"] == "disabled":
620
+ print(" ⚠️ E2EE is disabled in configuration")
621
+ print(" ❌ Messages to encrypted rooms will be BLOCKED")
622
+ print(" ✅ Messages to unencrypted rooms will work normally")
623
+ elif analysis["overall_status"] == "not_supported":
624
+ print(" ❌ E2EE not supported on Windows")
625
+ print(" ❌ Messages to encrypted rooms will be BLOCKED")
626
+ else:
627
+ print(" ⚠️ E2EE setup incomplete - some issues need to be resolved")
628
+ print(" ❌ Messages to encrypted rooms may be BLOCKED")
629
+
630
+ print(
631
+ "\n💡 Note: Room encryption status will be checked when mmrelay connects to Matrix"
632
+ )
633
+
634
+ # Recommendations
635
+ if analysis["recommendations"]:
636
+ print("\n🔧 TO FIX:")
637
+ for i, rec in enumerate(analysis["recommendations"], 1):
638
+ print(f" {i}. {rec}")
639
+
640
+ if analysis["overall_status"] == "ready":
641
+ print(
642
+ "\n✅ E2EE setup is complete! Run 'mmrelay' to start with E2EE support."
643
+ )
644
+ else:
645
+ print(
646
+ "\n⚠️ After fixing issues above, run 'mmrelay config check' again to verify."
647
+ )
648
+
649
+
650
+ def _print_environment_summary():
651
+ """
652
+ Print a concise environment summary including platform, Python version, and Matrix E2EE capability.
653
+
654
+ Provides:
655
+ - Platform and Python version.
656
+ - Whether E2EE is supported on the current platform (Windows is reported as not supported).
657
+ - Whether the `olm` dependency is installed when E2EE is supported, and a brief installation hint if missing.
658
+
659
+ This function writes human-facing lines to standard output and returns None.
660
+ """
661
+ print("\n🖥️ Environment Summary:")
662
+ print(f" Platform: {sys.platform}")
663
+ print(f" Python: {sys.version.split()[0]}")
664
+
665
+ # E2EE capability check
666
+ if sys.platform == WINDOWS_PLATFORM:
667
+ print(" E2EE Support: ❌ Not available (Windows limitation)")
668
+ print(" Matrix Support: ✅ Available")
669
+ else:
670
+ try:
671
+ import olm # noqa: F401
672
+ from nio.crypto import OlmDevice # noqa: F401
673
+ from nio.store import SqliteStore # noqa: F401
674
+
675
+ print(" E2EE Support: ✅ Available and installed")
676
+ except ImportError:
677
+ print(" E2EE Support: ⚠️ Available but not installed")
678
+ print(" Install: pipx install 'mmrelay[e2e]'")
679
+
680
+
681
+ def check_config(args=None):
682
+ """
683
+ Validate the application's YAML configuration file and its required sections.
684
+
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.
696
+
697
+ Side effects:
698
+ - Prints errors, warnings, and status messages to stdout.
699
+
700
+ Parameters:
701
+ args (argparse.Namespace | None): Parsed CLI arguments. If None, CLI arguments will be parsed internally.
702
+
703
+ Returns:
704
+ bool: True if a configuration file was found and passed all checks; False otherwise.
142
705
  """
143
706
 
144
707
  # If args is None, parse them now
@@ -155,24 +718,56 @@ def check_config(args=None):
155
718
  print(f"Found configuration file at: {config_path}")
156
719
  try:
157
720
  with open(config_path, "r") as f:
158
- config = yaml.load(f, Loader=SafeLoader)
721
+ config_content = f.read()
722
+
723
+ # Validate YAML syntax first
724
+ is_valid, message, config = validate_yaml_syntax(
725
+ config_content, config_path
726
+ )
727
+ if not is_valid:
728
+ print(f"YAML Syntax Error:\n{message}")
729
+ return False
730
+ elif message: # Warnings
731
+ print(f"YAML Style Warnings:\n{message}\n")
159
732
 
160
733
  # Check if config is empty
161
734
  if not config:
162
- print("Error: Configuration file is empty or invalid")
735
+ print(
736
+ "Error: Configuration file is empty or contains only comments"
737
+ )
163
738
  return False
164
739
 
165
- # Check matrix section
166
- if CONFIG_SECTION_MATRIX not in config:
167
- print("Error: Missing 'matrix' section in config")
168
- return False
740
+ # Check if we have valid credentials.json first
741
+ has_valid_credentials = _validate_credentials_json(config_path)
742
+
743
+ # Check matrix section requirements based on credentials.json availability
744
+ if has_valid_credentials:
745
+ # With credentials.json, no matrix section fields are required
746
+ # (homeserver, access_token, user_id, device_id all come from credentials.json)
747
+ if CONFIG_SECTION_MATRIX not in config:
748
+ # Create empty matrix section if missing - no fields required
749
+ config[CONFIG_SECTION_MATRIX] = {}
750
+ matrix_section = config[CONFIG_SECTION_MATRIX]
751
+ required_matrix_fields = (
752
+ []
753
+ ) # No fields required from config when using credentials.json
754
+ else:
755
+ # Without credentials.json, require full matrix section
756
+ if CONFIG_SECTION_MATRIX not in config:
757
+ print("Error: Missing 'matrix' section in config")
758
+ print(
759
+ " Either add matrix section with access_token and bot_user_id,"
760
+ )
761
+ print(f" {msg_or_run_auth_login()}")
762
+ return False
763
+
764
+ matrix_section = config[CONFIG_SECTION_MATRIX]
765
+ required_matrix_fields = [
766
+ CONFIG_KEY_HOMESERVER,
767
+ CONFIG_KEY_ACCESS_TOKEN,
768
+ CONFIG_KEY_BOT_USER_ID,
769
+ ]
169
770
 
170
- matrix_section = config[CONFIG_SECTION_MATRIX]
171
- required_matrix_fields = [
172
- CONFIG_KEY_HOMESERVER,
173
- CONFIG_KEY_ACCESS_TOKEN,
174
- CONFIG_KEY_BOT_USER_ID,
175
- ]
176
771
  missing_matrix_fields = [
177
772
  field
178
773
  for field in required_matrix_fields
@@ -180,11 +775,39 @@ def check_config(args=None):
180
775
  ]
181
776
 
182
777
  if missing_matrix_fields:
183
- print(
184
- f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
185
- )
778
+ if has_valid_credentials:
779
+ print(
780
+ f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
781
+ )
782
+ print(
783
+ " Note: credentials.json provides authentication; no matrix.* fields are required in config"
784
+ )
785
+ else:
786
+ print(
787
+ f"Error: Missing required fields in 'matrix' section: {', '.join(missing_matrix_fields)}"
788
+ )
789
+ print(f" {msg_setup_authentication()}")
186
790
  return False
187
791
 
792
+ # Perform comprehensive E2EE analysis using centralized utilities
793
+ try:
794
+ from mmrelay.e2ee_utils import (
795
+ get_e2ee_status,
796
+ )
797
+
798
+ e2ee_status = get_e2ee_status(config, config_path)
799
+ _print_unified_e2ee_analysis(e2ee_status)
800
+
801
+ # Check if there are critical E2EE issues
802
+ if not e2ee_status.get("platform_supported", True):
803
+ print("\n⚠️ Warning: E2EE is not supported on Windows")
804
+ print(" Messages to encrypted rooms will be blocked")
805
+ except Exception as e:
806
+ print(f"\n⚠️ Could not perform E2EE analysis: {e}")
807
+ print(" Falling back to basic E2EE validation...")
808
+ if not _validate_e2ee_config(config, matrix_section, config_path):
809
+ return False
810
+
188
811
  # Check matrix_rooms section
189
812
  if "matrix_rooms" not in config or not config["matrix_rooms"]:
190
813
  print("Error: Missing or empty 'matrix_rooms' section in config")
@@ -225,7 +848,9 @@ def check_config(args=None):
225
848
  CONNECTION_TYPE_NETWORK,
226
849
  ]:
227
850
  print(
228
- f"Error: Invalid 'connection_type': {connection_type}. Must be '{CONNECTION_TYPE_TCP}', '{CONNECTION_TYPE_SERIAL}', or '{CONNECTION_TYPE_BLE}'"
851
+ f"Error: Invalid 'connection_type': {connection_type}. Must be "
852
+ f"'{CONNECTION_TYPE_TCP}', '{CONNECTION_TYPE_SERIAL}', '{CONNECTION_TYPE_BLE}'"
853
+ f" or '{CONNECTION_TYPE_NETWORK}' (deprecated)"
229
854
  )
230
855
  return False
231
856
 
@@ -260,6 +885,61 @@ def check_config(args=None):
260
885
  print("Error: Missing 'ble_address' for 'ble' connection type")
261
886
  return False
262
887
 
888
+ # Check for other important optional configurations and provide guidance
889
+ optional_configs = {
890
+ "broadcast_enabled": {
891
+ "type": bool,
892
+ "description": "Enable Matrix to Meshtastic message forwarding (required for two-way communication)",
893
+ },
894
+ "detection_sensor": {
895
+ "type": bool,
896
+ "description": "Enable forwarding of Meshtastic detection sensor messages",
897
+ },
898
+ "message_delay": {
899
+ "type": (int, float),
900
+ "description": "Delay in seconds between messages sent to mesh (minimum: 2.0)",
901
+ },
902
+ "meshnet_name": {
903
+ "type": str,
904
+ "description": "Name displayed for your meshnet in Matrix messages",
905
+ },
906
+ }
907
+
908
+ warnings = []
909
+ for option, config_info in optional_configs.items():
910
+ if option in meshtastic_section:
911
+ value = meshtastic_section[option]
912
+ expected_type = config_info["type"]
913
+ if not isinstance(value, expected_type):
914
+ if isinstance(expected_type, tuple):
915
+ type_name = " or ".join(
916
+ t.__name__ for t in expected_type
917
+ )
918
+ else:
919
+ type_name = (
920
+ expected_type.__name__
921
+ if hasattr(expected_type, "__name__")
922
+ else str(expected_type)
923
+ )
924
+ print(
925
+ f"Error: '{option}' must be of type {type_name}, got: {value}"
926
+ )
927
+ return False
928
+
929
+ # Special validation for message_delay
930
+ if option == "message_delay" and value < 2.0:
931
+ print(
932
+ f"Error: 'message_delay' must be at least 2.0 seconds (firmware limitation), got: {value}"
933
+ )
934
+ return False
935
+ else:
936
+ warnings.append(f" - {option}: {config_info['description']}")
937
+
938
+ if warnings:
939
+ print("\nOptional configurations not found (using defaults):")
940
+ for warning in warnings:
941
+ print(warning)
942
+
263
943
  # Check for deprecated db section
264
944
  if "db" in config:
265
945
  print(
@@ -269,11 +949,8 @@ def check_config(args=None):
269
949
  "This option still works but may be removed in future versions.\n"
270
950
  )
271
951
 
272
- print("Configuration file is valid!")
952
+ print("\n✅ Configuration file is valid!")
273
953
  return True
274
- except yaml.YAMLError as e:
275
- print(f"Error parsing YAML in {config_path}: {e}")
276
- return False
277
954
  except Exception as e:
278
955
  print(f"Error checking configuration: {e}")
279
956
  return False
@@ -281,26 +958,38 @@ def check_config(args=None):
281
958
  print("Error: No configuration file found in any of the following locations:")
282
959
  for path in config_paths:
283
960
  print(f" - {path}")
284
- print("\nRun 'mmrelay --generate-config' to generate a sample configuration file.")
961
+ print(f"\n{msg_suggest_generate_config()}")
285
962
  return False
286
963
 
287
964
 
288
965
  def main():
289
966
  """
290
- Runs the Meshtastic Matrix Relay CLI, handling argument parsing, command execution, and error reporting.
967
+ Entry point for the MMRelay command-line interface; parses arguments, dispatches commands, and returns an appropriate process exit code.
968
+
969
+ This function:
970
+ - Parses CLI arguments (modern grouped subcommands and hidden legacy flags).
971
+ - If a modern subcommand is provided, dispatches to the grouped subcommand handlers.
972
+ - If legacy flags are present, emits deprecation warnings and executes the corresponding legacy behavior (config check/generate, service install, auth, version).
973
+ - If no command flags are present, attempts to run the main runtime.
974
+ - Catches and reports import or unexpected errors and maps success/failure to exit codes.
291
975
 
292
976
  Returns:
293
- int: Exit code indicating success (0) or failure (non-zero).
977
+ int: Exit code (0 on success, non-zero on failure).
294
978
  """
295
979
  try:
296
980
  args = parse_arguments()
297
981
 
298
- # Handle --check-config
982
+ # Handle subcommands first (modern interface)
983
+ if hasattr(args, "command") and args.command:
984
+ return handle_subcommand(args)
985
+
986
+ # Handle legacy flags (with deprecation warnings)
299
987
  if args.check_config:
988
+ print(get_deprecation_warning("--check-config"))
300
989
  return 0 if check_config(args) else 1
301
990
 
302
- # Handle --install-service
303
991
  if args.install_service:
992
+ print(get_deprecation_warning("--install-service"))
304
993
  try:
305
994
  from mmrelay.setup_utils import install_service
306
995
 
@@ -309,15 +998,18 @@ def main():
309
998
  print(f"Error importing setup utilities: {e}")
310
999
  return 1
311
1000
 
312
- # Handle --generate-config
313
1001
  if args.generate_config:
1002
+ print(get_deprecation_warning("--generate-config"))
314
1003
  return 0 if generate_sample_config() else 1
315
1004
 
316
- # Handle --version
317
1005
  if args.version:
318
1006
  print_version()
319
1007
  return 0
320
1008
 
1009
+ if args.auth:
1010
+ print(get_deprecation_warning("--auth"))
1011
+ return handle_auth_command(args)
1012
+
321
1013
  # If no command was specified, run the main functionality
322
1014
  try:
323
1015
  from mmrelay.main import run_main
@@ -332,6 +1024,231 @@ def main():
332
1024
  return 1
333
1025
 
334
1026
 
1027
+ def handle_subcommand(args):
1028
+ """
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".
1033
+
1034
+ Returns:
1035
+ int: Process exit code — 0 on success, non-zero on error or unknown command.
1036
+ """
1037
+ if args.command == "config":
1038
+ return handle_config_command(args)
1039
+ elif args.command == "auth":
1040
+ return handle_auth_command(args)
1041
+ elif args.command == "service":
1042
+ return handle_service_command(args)
1043
+ else:
1044
+ print(f"Unknown command: {args.command}")
1045
+ return 1
1046
+
1047
+
1048
+ def handle_config_command(args):
1049
+ """
1050
+ Dispatch the 'config' subgroup commands: "generate" and "check".
1051
+
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).
1054
+
1055
+ Parameters:
1056
+ args (argparse.Namespace): Parsed CLI namespace with a `config_command` attribute.
1057
+
1058
+ Returns:
1059
+ int: Process exit code (0 on success, 1 on failure or unknown subcommand).
1060
+ """
1061
+ if args.config_command == "generate":
1062
+ return 0 if generate_sample_config() else 1
1063
+ elif args.config_command == "check":
1064
+ return 0 if check_config(args) else 1
1065
+ else:
1066
+ print(f"Unknown config command: {args.config_command}")
1067
+ return 1
1068
+
1069
+
1070
+ def handle_auth_command(args):
1071
+ """
1072
+ Dispatch the "auth" CLI subcommand to the appropriate handler.
1073
+
1074
+ If args.auth_command is "status" calls handle_auth_status; if "logout" calls handle_auth_logout;
1075
+ any other value (or missing attribute) defaults to handle_auth_login.
1076
+
1077
+ Parameters:
1078
+ args (argparse.Namespace): Parsed CLI arguments. Expected to optionally provide `auth_command`
1079
+ with one of "login", "status", or "logout".
1080
+
1081
+ Returns:
1082
+ int: Exit code from the invoked handler (0 = success, non-zero = failure).
1083
+ """
1084
+ if hasattr(args, "auth_command"):
1085
+ if args.auth_command == "status":
1086
+ return handle_auth_status(args)
1087
+ elif args.auth_command == "logout":
1088
+ return handle_auth_logout(args)
1089
+ else:
1090
+ # Default to login for auth login command
1091
+ return handle_auth_login(args)
1092
+ else:
1093
+ # Default to login for legacy --auth
1094
+ return handle_auth_login(args)
1095
+
1096
+
1097
+ def handle_auth_login(args):
1098
+ """
1099
+ Run the interactive Matrix bot login flow and return a CLI-style exit code.
1100
+
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.
1102
+
1103
+ Parameters:
1104
+ args: Parsed command-line arguments (not used by this handler).
1105
+ """
1106
+ import asyncio
1107
+
1108
+ from mmrelay.matrix_utils import login_matrix_bot
1109
+
1110
+ # Show header
1111
+ print("Matrix Bot Authentication for E2EE")
1112
+ print("===================================")
1113
+
1114
+ try:
1115
+ # For now, use the existing login function
1116
+ result = asyncio.run(login_matrix_bot())
1117
+ return 0 if result else 1
1118
+ except KeyboardInterrupt:
1119
+ print("\nAuthentication cancelled by user.")
1120
+ return 1
1121
+ except Exception as e:
1122
+ print(f"\nError during authentication: {e}")
1123
+ return 1
1124
+
1125
+
1126
+ def handle_auth_status(args):
1127
+ """
1128
+ Show Matrix authentication status by locating and reading a credentials.json file.
1129
+
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.
1131
+
1132
+ Parameters:
1133
+ args (argparse.Namespace): Parsed CLI arguments used to determine the list of config paths to search.
1134
+
1135
+ Returns:
1136
+ int: Exit code — 0 if a readable credentials.json was found, 1 otherwise.
1137
+ """
1138
+ import json
1139
+ import os
1140
+
1141
+ from mmrelay.config import get_config_paths
1142
+
1143
+ print("Matrix Authentication Status")
1144
+ print("============================")
1145
+
1146
+ # Check for credentials.json
1147
+ 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")
1151
+ if os.path.exists(credentials_path):
1152
+ try:
1153
+ with open(credentials_path, "r") as f:
1154
+ credentials = json.load(f)
1155
+
1156
+ 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')}")
1160
+ return 0
1161
+ except Exception as e:
1162
+ print(f"❌ Error reading credentials.json: {e}")
1163
+ return 1
1164
+
1165
+ print("❌ No credentials.json found")
1166
+ print(f"Run '{get_command('auth_login')}' to authenticate")
1167
+ return 1
1168
+
1169
+
1170
+ def handle_auth_logout(args):
1171
+ """
1172
+ Log out the bot from Matrix and remove local session artifacts.
1173
+
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.
1175
+
1176
+ 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.
1181
+ """
1182
+ import asyncio
1183
+
1184
+ from mmrelay.cli_utils import logout_matrix_bot
1185
+
1186
+ # Show header
1187
+ print("Matrix Bot Logout")
1188
+ print("=================")
1189
+ print()
1190
+ print("This will log out from Matrix and clear all local session data:")
1191
+ print("• Remove credentials.json")
1192
+ print("• Clear E2EE encryption store")
1193
+ print("• Invalidate Matrix access token")
1194
+ print()
1195
+
1196
+ try:
1197
+ # Handle password input
1198
+ password = getattr(args, "password", None)
1199
+
1200
+ if password is None or password == "":
1201
+ # No --password flag or --password with no value, prompt securely
1202
+ import getpass
1203
+
1204
+ password = getpass.getpass("Enter Matrix password for verification: ")
1205
+ else:
1206
+ # --password VALUE provided, warn about security
1207
+ print(
1208
+ "⚠️ Warning: Supplying password as argument exposes it in shell history and process list."
1209
+ )
1210
+ print(
1211
+ " For better security, use --password without a value to prompt securely."
1212
+ )
1213
+
1214
+ # Confirm the action unless forced
1215
+ if not getattr(args, "yes", False):
1216
+ confirm = input("Are you sure you want to logout? (y/N): ").lower().strip()
1217
+ if not confirm.startswith("y"):
1218
+ print("Logout cancelled.")
1219
+ return 0
1220
+
1221
+ # Run the logout process
1222
+ result = asyncio.run(logout_matrix_bot(password=password))
1223
+ return 0 if result else 1
1224
+ except KeyboardInterrupt:
1225
+ print("\nLogout cancelled by user.")
1226
+ return 1
1227
+ except Exception as e:
1228
+ print(f"\nError during logout: {e}")
1229
+ return 1
1230
+
1231
+
1232
+ def handle_service_command(args):
1233
+ """
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.
1238
+ """
1239
+ if args.service_command == "install":
1240
+ try:
1241
+ from mmrelay.setup_utils import install_service
1242
+
1243
+ return 0 if install_service() else 1
1244
+ except ImportError as e:
1245
+ print(f"Error importing setup utilities: {e}")
1246
+ return 1
1247
+ else:
1248
+ print(f"Unknown service command: {args.service_command}")
1249
+ return 1
1250
+
1251
+
335
1252
  if __name__ == "__main__":
336
1253
  import sys
337
1254
 
@@ -339,14 +1256,18 @@ if __name__ == "__main__":
339
1256
 
340
1257
 
341
1258
  def handle_cli_commands(args):
342
- """Handle CLI commands like --generate-config, --install-service, and --check-config.
1259
+ """
1260
+ Handle legacy CLI flags (--version, --install-service, --generate-config, --check-config).
343
1261
 
344
- Args:
345
- args: The parsed command-line arguments
1262
+ This helper processes backward-compatible flags and may call sys.exit() for flags that perform an immediate action
1263
+ (e.g., install service, check config). Prefer the modern grouped subcommands (e.g., `mmrelay config`, `mmrelay auth`)
1264
+ when available.
1265
+
1266
+ Parameters:
1267
+ args (argparse.Namespace): Parsed command-line arguments produced by parse_arguments().
346
1268
 
347
1269
  Returns:
348
- bool: True if a command was handled and the program should exit,
349
- False if normal execution should continue.
1270
+ bool: True if a legacy command was handled (the process may have already exited), False to continue normal flow.
350
1271
  """
351
1272
  # Handle --version
352
1273
  if args.version:
@@ -385,16 +1306,16 @@ def handle_cli_commands(args):
385
1306
 
386
1307
  def generate_sample_config():
387
1308
  """
388
- Generate a sample configuration file (`config.yaml`) in the default location if one does not already exist.
1309
+ Generate a sample configuration file at the highest-priority config path if no config already exists.
389
1310
 
390
- Attempts to copy a sample config from various sources, handling directory creation and file system errors gracefully. Prints informative messages on success or failure.
1311
+ 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
+ - the path returned by get_sample_config_path(),
1313
+ - 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.
391
1315
 
392
- Returns:
393
- bool: True if the sample config was generated successfully, False otherwise.
1316
+ 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.
394
1317
  """
395
1318
 
396
- import shutil
397
-
398
1319
  # Get the first config path (highest priority)
399
1320
  config_paths = get_config_paths()
400
1321
 
@@ -422,10 +1343,13 @@ def generate_sample_config():
422
1343
 
423
1344
  if os.path.exists(sample_config_path):
424
1345
  # Copy the sample config file to the target path
425
- import shutil
426
1346
 
427
1347
  try:
428
1348
  shutil.copy2(sample_config_path, target_path)
1349
+
1350
+ # Set secure permissions on Unix systems (600 - owner read/write)
1351
+ set_secure_file_permissions(target_path)
1352
+
429
1353
  print(f"Generated sample config file at: {target_path}")
430
1354
  print(
431
1355
  "\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."
@@ -448,6 +1372,9 @@ def generate_sample_config():
448
1372
  with open(target_path, "w") as f:
449
1373
  f.write(sample_config_content)
450
1374
 
1375
+ # Set secure permissions on Unix systems (600 - owner read/write)
1376
+ set_secure_file_permissions(target_path)
1377
+
451
1378
  print(f"Generated sample config file at: {target_path}")
452
1379
  print(
453
1380
  "\nEdit this file with your Matrix and Meshtastic settings before running mmrelay."