smanager 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {smanager-0.2.0 → smanager-0.2.2}/PKG-INFO +1 -1
  2. smanager-0.2.2/README.md +60 -0
  3. {smanager-0.2.0 → smanager-0.2.2}/smanager/__init__.py +1 -1
  4. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/metadata.py +16 -1
  5. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/metadata_wizard.py +164 -0
  6. {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/local_manager.py +11 -2
  7. {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/PKG-INFO +1 -1
  8. {smanager-0.2.0 → smanager-0.2.2}/tests/test_metadata_wizard.py +67 -1
  9. {smanager-0.2.0 → smanager-0.2.2}/tests/test_service_manager_events.py +16 -1
  10. smanager-0.2.0/README.md +0 -15
  11. {smanager-0.2.0 → smanager-0.2.2}/pyproject.toml +0 -0
  12. {smanager-0.2.0 → smanager-0.2.2}/setup.cfg +0 -0
  13. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/__init__.py +0 -0
  14. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/__init__.py +0 -0
  15. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/all.py +0 -0
  16. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/config.py +0 -0
  17. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/dependency.py +0 -0
  18. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/group.py +0 -0
  19. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/service.py +0 -0
  20. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/commands/status.py +0 -0
  21. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/config.py +0 -0
  22. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/events.py +0 -0
  23. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/formatters/__init__.py +0 -0
  24. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/formatters/status_table.py +0 -0
  25. {smanager-0.2.0 → smanager-0.2.2}/smanager/cli/main.py +0 -0
  26. {smanager-0.2.0 → smanager-0.2.2}/smanager/crawler/__init__.py +0 -0
  27. {smanager-0.2.0 → smanager-0.2.2}/smanager/crawler/sd_finder.py +0 -0
  28. {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/__init__.py +0 -0
  29. {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/config.py +0 -0
  30. {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/sd_info_v2.py +0 -0
  31. {smanager-0.2.0 → smanager-0.2.2}/smanager/datatypes/service_status.py +0 -0
  32. {smanager-0.2.0 → smanager-0.2.2}/smanager/events.py +0 -0
  33. {smanager-0.2.0 → smanager-0.2.2}/smanager/global_manager/__init__.py +0 -0
  34. {smanager-0.2.0 → smanager-0.2.2}/smanager/global_manager/base_global_manager.py +0 -0
  35. {smanager-0.2.0 → smanager-0.2.2}/smanager/global_manager/global_manager.py +0 -0
  36. {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/__init__.py +0 -0
  37. {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/base_manager.py +0 -0
  38. {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/base_sdv2_manager.py +0 -0
  39. {smanager-0.2.0 → smanager-0.2.2}/smanager/service_managers/docker_manager.py +0 -0
  40. {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/__init__.py +0 -0
  41. {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/base_vpn.py +0 -0
  42. {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/providers/__init__.py +0 -0
  43. {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/providers/tailscale.py +0 -0
  44. {smanager-0.2.0 → smanager-0.2.2}/smanager/vpn/registry.py +0 -0
  45. {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/SOURCES.txt +0 -0
  46. {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/dependency_links.txt +0 -0
  47. {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/entry_points.txt +0 -0
  48. {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/requires.txt +0 -0
  49. {smanager-0.2.0 → smanager-0.2.2}/smanager.egg-info/top_level.txt +0 -0
  50. {smanager-0.2.0 → smanager-0.2.2}/tests/test_cli_events.py +0 -0
  51. {smanager-0.2.0 → smanager-0.2.2}/tests/test_events.py +0 -0
  52. {smanager-0.2.0 → smanager-0.2.2}/tests/test_status_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smanager
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Requires-Python: >=3.9
5
5
  Requires-Dist: PyYAML>=6.0
6
6
  Requires-Dist: click>=8.0
@@ -0,0 +1,60 @@
1
+ # SManager
2
+ > A simple server manager for self-hosted services, with VPN integration and support for both local and Docker-based services.
3
+
4
+ ## Features
5
+ - Unified service management for local/systemd and Docker runtimes.
6
+ - VPN integration (Tailscale provider support).
7
+ - Interactive metadata creation and editing.
8
+ - YAML-based global configuration.
9
+
10
+ ## CLI Usage
11
+ Use this command pattern:
12
+
13
+ ```bash
14
+ smanager <group> <command> [options]
15
+ ```
16
+
17
+ Discover available commands at any level:
18
+
19
+ ```bash
20
+ smanager --help
21
+ smanager <group> --help
22
+ smanager <group> <command> --help
23
+ ```
24
+
25
+ ## Metadata Commands
26
+ The `metadata` group provides interactive workflows for service metadata files.
27
+
28
+ ### Create Metadata
29
+ ```bash
30
+ smanager metadata init
31
+ ```
32
+
33
+ Optional output override:
34
+
35
+ ```bash
36
+ smanager metadata init --output /path/to/sd_info.json
37
+ ```
38
+
39
+ ### Edit Existing Metadata
40
+ ```bash
41
+ smanager metadata edit
42
+ ```
43
+
44
+ Edit a specific file directly:
45
+
46
+ ```bash
47
+ smanager metadata edit --file /path/to/sd_info.json
48
+ ```
49
+
50
+ Behavior summary:
51
+ - Interactive prompts with inline defaults.
52
+ - Runtime-aware sections (Docker vs Local).
53
+ - Multi-select support for `groups` and `depends_on`.
54
+ - Validation before writing changes.
55
+
56
+ ## Notes
57
+ - `metadata init` creates a new file.
58
+ - `metadata edit` modifies an existing file.
59
+ - Use command-specific `--help` for the latest options and examples.
60
+
@@ -1,4 +1,4 @@
1
- __version__ = "0.2.0"
1
+ __version__ = "0.2.2"
2
2
 
3
3
  from .global_manager import GlobalManager
4
4
  from .datatypes import GlobalConfig, Service, ServiceStatus, MultiStatus
@@ -1,7 +1,7 @@
1
1
  import click
2
2
  from pathlib import Path
3
3
 
4
- from ..metadata_wizard import MetadataWizard
4
+ from ..metadata_wizard import MetadataEditor, MetadataWizard
5
5
 
6
6
 
7
7
  @click.group("metadata")
@@ -21,3 +21,18 @@ def init_cmd(output: Path | None) -> None:
21
21
  """Create a service metadata file using an interactive guided wizard."""
22
22
  wizard = MetadataWizard(output_override=output)
23
23
  wizard.run()
24
+
25
+
26
+ @metadata_cmd.command("edit")
27
+ @click.option(
28
+ "--file",
29
+ "metadata_file",
30
+ type=click.Path(path_type=Path, dir_okay=False, exists=False),
31
+ default=None,
32
+ metavar="PATH",
33
+ help="Path to an existing metadata file. If omitted, choose interactively.",
34
+ )
35
+ def edit_cmd(metadata_file: Path | None) -> None:
36
+ """Edit an existing service metadata file interactively."""
37
+ editor = MetadataEditor(metadata_path=metadata_file)
38
+ editor.run()
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import re
6
7
  from pathlib import Path
7
8
  from typing import Any, Iterable
@@ -409,3 +410,166 @@ class MetadataWizard:
409
410
  if len(values) <= 3:
410
411
  return ", ".join(values)
411
412
  return f"{len(values)} selected"
413
+
414
+
415
+ class MetadataEditor(MetadataWizard):
416
+ def __init__(self, metadata_path: Path | None = None, console: Console | None = None):
417
+ super().__init__(output_override=None, console=console)
418
+ self.metadata_path = metadata_path
419
+
420
+ def run(self) -> Path:
421
+ target_path = self._resolve_metadata_path()
422
+ metadata_dict = self._load_metadata_dict(target_path)
423
+ ServiceMetadata.from_dict(metadata_dict)
424
+ original_json = json.dumps(metadata_dict, sort_keys=True)
425
+
426
+ self.console.print(
427
+ Panel.fit(
428
+ f"[bold cyan]Metadata Editor[/]\n"
429
+ f"[dim]Editing {target_path}. Choose sections and save when ready.[/]",
430
+ title="smanager metadata edit",
431
+ )
432
+ )
433
+
434
+ while True:
435
+ section = self._ask_select(
436
+ "Edit section",
437
+ [
438
+ "Identity",
439
+ "Runtime",
440
+ "Runtime details",
441
+ "Groups",
442
+ "Depends on",
443
+ "VPN",
444
+ "Active",
445
+ "Save and exit",
446
+ "Exit without saving",
447
+ ],
448
+ "Identity",
449
+ )
450
+
451
+ if section == "Identity":
452
+ self._edit_identity(metadata_dict)
453
+ elif section == "Runtime":
454
+ self._edit_runtime(metadata_dict)
455
+ elif section == "Runtime details":
456
+ self._edit_runtime_details(metadata_dict)
457
+ elif section == "Groups":
458
+ self._edit_groups(metadata_dict, target_path)
459
+ elif section == "Depends on":
460
+ self._edit_dependencies(metadata_dict, target_path)
461
+ elif section == "VPN":
462
+ self._edit_vpn(metadata_dict)
463
+ elif section == "Active":
464
+ metadata_dict["active"] = self._ask_confirm("Enable service", bool(metadata_dict.get("active", True)))
465
+ elif section == "Save and exit":
466
+ break
467
+ elif section == "Exit without saving":
468
+ self.console.print("[yellow]No changes written.[/]")
469
+ return target_path
470
+
471
+ metadata = ServiceMetadata.from_dict(metadata_dict)
472
+ self._render_review(metadata, target_path.parent, target_path.name)
473
+
474
+ if not self._ask_confirm("Save changes", True):
475
+ raise click.ClickException("Cancelled by user.")
476
+
477
+ if json.dumps(metadata_dict, sort_keys=True) == original_json:
478
+ self.console.print("[yellow]No changes detected.[/]")
479
+ return target_path
480
+
481
+ metadata.write_to_json(str(target_path))
482
+ self.console.print(f"[green]✓ Metadata saved to {target_path}[/]")
483
+ return target_path
484
+
485
+ def _resolve_metadata_path(self) -> Path:
486
+ if self.metadata_path is not None:
487
+ target_path = self.metadata_path.expanduser()
488
+ else:
489
+ candidates = self._discover_metadata_files(Path.cwd())
490
+ if not candidates:
491
+ raise click.ClickException("No metadata files found (expected sd_info.*).")
492
+ if len(candidates) == 1:
493
+ target_path = candidates[0]
494
+ else:
495
+ default_choice = str(candidates[0])
496
+ selected = self._ask_select("Metadata file", [str(path) for path in candidates], default_choice)
497
+ target_path = Path(selected)
498
+
499
+ if not target_path.is_file():
500
+ raise click.ClickException(f"Metadata file not found: {target_path}")
501
+ return target_path
502
+
503
+ def _discover_metadata_files(self, root: Path) -> list[Path]:
504
+ return sorted(path for path in root.rglob("sd_info.*") if path.is_file())
505
+
506
+ def _load_metadata_dict(self, path: Path) -> dict[str, Any]:
507
+ try:
508
+ return json.loads(path.read_text(encoding="utf-8"))
509
+ except json.JSONDecodeError as exc:
510
+ raise click.ClickException(f"Invalid JSON metadata file: {path}") from exc
511
+
512
+ def _edit_identity(self, metadata_dict: dict[str, Any]) -> None:
513
+ metadata_dict["name"] = self._ask_text("Service name", str(metadata_dict.get("name", "")))
514
+ metadata_dict["description"] = self._ask_text("Description", str(metadata_dict.get("description", "")))
515
+ metadata_dict["id"] = self._ask_text("Service id", str(metadata_dict.get("id", "")))
516
+
517
+ def _edit_runtime(self, metadata_dict: dict[str, Any]) -> None:
518
+ runtime = metadata_dict.setdefault("runtime", {})
519
+ runtime_type = self._ask_select("Runtime type", ["docker", "local"], str(runtime.get("type", "local")))
520
+ runtime["type"] = runtime_type
521
+
522
+ ports = runtime.setdefault("ports", {})
523
+ ports["webui"] = self._ask_int("Web UI port", int(ports.get("webui", 8080)))
524
+ runtime["sys_user"] = self._ask_text("System user", str(runtime.get("sys_user", metadata_dict.get("id", "service"))))
525
+ runtime["sys_group"] = self._ask_text("System group", str(runtime.get("sys_group", metadata_dict.get("id", "service"))))
526
+
527
+ if runtime_type == "docker":
528
+ metadata_dict.pop("local", None)
529
+ metadata_dict.setdefault("docker", {"compose_file": "compose.yaml", "containers": None})
530
+ else:
531
+ metadata_dict.pop("docker", None)
532
+ service_id = str(metadata_dict.get("id", "service"))
533
+ metadata_dict.setdefault(
534
+ "local",
535
+ {
536
+ "installation_path": f"/opt/{service_id}",
537
+ "appdata_path": f"/var/lib/{service_id}",
538
+ "systemd_services": [service_id],
539
+ },
540
+ )
541
+
542
+ def _edit_runtime_details(self, metadata_dict: dict[str, Any]) -> None:
543
+ runtime_type = metadata_dict.get("runtime", {}).get("type", "local")
544
+ if runtime_type == "docker":
545
+ docker = metadata_dict.setdefault("docker", {})
546
+ docker["compose_file"] = self._ask_text("Compose file", str(docker.get("compose_file", "compose.yaml")))
547
+ containers = docker.get("containers") or []
548
+ containers_csv = ", ".join(containers)
549
+ parsed = parse_csv(self._ask_text("Containers (comma-separated)", containers_csv))
550
+ docker["containers"] = parsed or None
551
+ return
552
+
553
+ local = metadata_dict.setdefault("local", {})
554
+ service_id = str(metadata_dict.get("id", "service"))
555
+ local["installation_path"] = self._ask_text("Installation path", str(local.get("installation_path", f"/opt/{service_id}")))
556
+ local["appdata_path"] = self._ask_text("Appdata path", str(local.get("appdata_path", f"/var/lib/{service_id}")))
557
+ services = local.get("systemd_services") or [service_id]
558
+ parsed_services = parse_csv(self._ask_text("Systemd services (comma-separated)", ", ".join(services)))
559
+ local["systemd_services"] = parsed_services or [service_id]
560
+
561
+ def _edit_groups(self, metadata_dict: dict[str, Any], target_path: Path) -> None:
562
+ current = metadata_dict.get("groups") or []
563
+ available = sorted(set(self._existing_groups(target_path.parent) + current))
564
+ metadata_dict["groups"] = self._ask_groups(available) or None
565
+
566
+ def _edit_dependencies(self, metadata_dict: dict[str, Any], target_path: Path) -> None:
567
+ service_id = str(metadata_dict.get("id", ""))
568
+ current = metadata_dict.get("depends_on") or []
569
+ available = sorted(set(self._existing_dependencies(target_path.parent, service_id) + current) - {service_id})
570
+ metadata_dict["depends_on"] = self._ask_dependencies(available) or None
571
+
572
+ def _edit_vpn(self, metadata_dict: dict[str, Any]) -> None:
573
+ vpn = metadata_dict.setdefault("vpn", {})
574
+ vpn["serve_https"] = self._ask_confirm("Serve HTTPS through VPN", bool(vpn.get("serve_https", False)))
575
+ vpn["https_restart_delay"] = self._ask_int("HTTPS restart delay", int(vpn.get("https_restart_delay", 5)))
@@ -2,6 +2,7 @@ from . import BaseSDV2Manager
2
2
  from ..datatypes import Service
3
3
  from ..vpn import VPNService
4
4
  import time
5
+ import subprocess
5
6
  from ..datatypes import ServiceStatus, SingleStatus
6
7
 
7
8
  class LocalManager(BaseSDV2Manager):
@@ -105,10 +106,18 @@ class LocalManager(BaseSDV2Manager):
105
106
  """Check the status of the local systemd service and return a ServiceStatus object."""
106
107
  service_statuses = []
107
108
  for service_name in self._get_systemd_services():
108
- result = self.subprocess_run(["systemctl", "is-active", service_name])
109
+ result = subprocess.run(
110
+ ["systemctl", "is-active", service_name],
111
+ capture_output=True,
112
+ text=True,
113
+ check=False,
114
+ )
115
+ status_text = result.stdout.strip() or result.stderr.strip()
116
+ if not status_text:
117
+ status_text = "active" if result.returncode == 0 else "inactive"
109
118
  status = SingleStatus(
110
119
  name=service_name,
111
- status=result.stdout.strip()
120
+ status=status_text
112
121
  )
113
122
  service_statuses.append(status)
114
123
  return ServiceStatus(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: smanager
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Requires-Python: >=3.9
5
5
  Requires-Dist: PyYAML>=6.0
6
6
  Requires-Dist: click>=8.0
@@ -8,7 +8,7 @@ from unittest.mock import patch
8
8
 
9
9
  import click
10
10
 
11
- from smanager.cli.metadata_wizard import ADD_NEW_GROUP_LABEL, MetadataWizard
11
+ from smanager.cli.metadata_wizard import ADD_NEW_GROUP_LABEL, MetadataEditor, MetadataWizard
12
12
 
13
13
 
14
14
  class MetadataWizardTests(unittest.TestCase):
@@ -208,5 +208,71 @@ class MetadataWizardTests(unittest.TestCase):
208
208
  self.assertEqual(groups, ["media", "new-group"])
209
209
 
210
210
 
211
+ class MetadataEditorTests(unittest.TestCase):
212
+ def _metadata_payload(self) -> dict:
213
+ return {
214
+ "name": "Sonarr",
215
+ "description": "Sonarr service",
216
+ "schema_version": 2,
217
+ "id": "sonarr",
218
+ "active": True,
219
+ "runtime": {
220
+ "type": "local",
221
+ "ports": {"webui": 8989},
222
+ "sys_user": "sonarr",
223
+ "sys_group": "sonarr",
224
+ },
225
+ "groups": ["media"],
226
+ "depends_on": ["qbittorrent"],
227
+ "local": {
228
+ "installation_path": "/opt/sonarr",
229
+ "appdata_path": "/var/lib/sonarr",
230
+ "systemd_services": ["sonarr"],
231
+ },
232
+ "vpn": {"serve_https": False, "https_restart_delay": 5},
233
+ }
234
+
235
+ def test_metadata_edit_updates_identity_and_saves(self):
236
+ with TemporaryDirectory() as tmpdir:
237
+ metadata_path = Path(tmpdir) / "sd_info.json"
238
+ metadata_path.write_text(json.dumps(self._metadata_payload()), encoding="utf-8")
239
+ editor = MetadataEditor(metadata_path=metadata_path)
240
+
241
+ with patch.object(editor, "_ask_select", side_effect=["Identity", "Save and exit"]), patch.object(
242
+ editor,
243
+ "_ask_text",
244
+ side_effect=["Sonarr Updated", "Updated description", "sonarr-updated"],
245
+ ), patch.object(editor, "_ask_confirm", return_value=True):
246
+ result = editor.run()
247
+
248
+ self.assertEqual(result, metadata_path)
249
+ updated = json.loads(metadata_path.read_text(encoding="utf-8"))
250
+ self.assertEqual(updated["name"], "Sonarr Updated")
251
+ self.assertEqual(updated["description"], "Updated description")
252
+ self.assertEqual(updated["id"], "sonarr-updated")
253
+
254
+ def test_metadata_edit_can_exit_without_saving(self):
255
+ with TemporaryDirectory() as tmpdir:
256
+ metadata_path = Path(tmpdir) / "sd_info.json"
257
+ original = self._metadata_payload()
258
+ metadata_path.write_text(json.dumps(original), encoding="utf-8")
259
+ editor = MetadataEditor(metadata_path=metadata_path)
260
+
261
+ with patch.object(editor, "_ask_select", return_value="Exit without saving"), patch.object(
262
+ editor, "_ask_confirm"
263
+ ) as confirm_mock:
264
+ result = editor.run()
265
+
266
+ self.assertEqual(result, metadata_path)
267
+ self.assertEqual(json.loads(metadata_path.read_text(encoding="utf-8")), original)
268
+ confirm_mock.assert_not_called()
269
+
270
+ def test_metadata_edit_raises_when_file_is_missing(self):
271
+ editor = MetadataEditor(metadata_path=Path("/tmp/does-not-exist-sd-info.json"))
272
+
273
+ with self.assertRaises(click.ClickException):
274
+ editor.run()
275
+
276
+
211
277
  if __name__ == "__main__":
212
278
  unittest.main()
@@ -4,7 +4,7 @@ from pathlib import Path
4
4
  from tempfile import TemporaryDirectory
5
5
  from types import SimpleNamespace
6
6
  import unittest
7
- from unittest.mock import Mock
7
+ from unittest.mock import Mock, patch
8
8
 
9
9
  from smanager.datatypes import (
10
10
  DockerSection,
@@ -161,3 +161,18 @@ class LocalManagerEventTests(unittest.TestCase):
161
161
  manager.stop()
162
162
 
163
163
  self.assertEqual(events, ["service.stop.before", "service.stop.after"])
164
+
165
+ def test_status_returns_inactive_when_systemctl_reports_inactive(self):
166
+ with TemporaryDirectory() as tmpdir:
167
+ service_dir = Path(tmpdir) / "local"
168
+ service_dir.mkdir()
169
+ service = make_local_service(service_dir, active=True)
170
+
171
+ manager = LocalManager(service, DummyVPN())
172
+
173
+ fake_result = SimpleNamespace(stdout="inactive\n", stderr="", returncode=4)
174
+ with patch("smanager.service_managers.local_manager.subprocess.run", return_value=fake_result) as run_mock:
175
+ result = manager.status()
176
+
177
+ self.assertEqual(result.statuses[0].status, "inactive")
178
+ run_mock.assert_called_once()
smanager-0.2.0/README.md DELETED
@@ -1,15 +0,0 @@
1
- # SManager
2
- > A simple server manager for self-hosted services, with VPN integration and support for both local and Docker-based services.
3
-
4
- ## Features
5
- - Manage local services and Docker containers with a unified interface.
6
- - Integrate with Tailscale VPN for secure remote access.
7
- - Monitor service status and logs in real-time.
8
- - Easy configuration using YAML files.
9
- - Extensible architecture for adding new service types and VPN providers.
10
-
11
- ## Interactive Metadata Wizard
12
- - Create a new service metadata file with `smanager metadata init`.
13
- - The wizard is interactive, shows defaults in context, and only asks Docker-specific or local-specific fields depending on the runtime you choose.
14
- - It uses Rich for a guided terminal experience and validates the generated metadata before saving it.
15
-
File without changes
File without changes
File without changes
File without changes
File without changes