smanager 0.1.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.
smanager/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from .global_manager import GlobalManager
4
+ from .datatypes import GlobalConfig, Service, ServiceStatus, MultiStatus
5
+
6
+ __all__ = [
7
+ "__version__",
8
+ "GlobalManager",
9
+ "GlobalConfig",
10
+ "Service",
11
+ "ServiceStatus",
12
+ "MultiStatus",
13
+ ]
File without changes
@@ -0,0 +1,10 @@
1
+ ACTIONS = ("start", "stop", "restart", "enable", "disable")
2
+
3
+ from .service import service_cmd
4
+ from .group import group_cmd
5
+ from .dependency import dependency_cmd
6
+ from .all import all_cmd
7
+ from .status import status_cmd
8
+ from .config import config_cmd
9
+
10
+ __all__ = ["ACTIONS", "service_cmd", "group_cmd", "dependency_cmd", "all_cmd", "status_cmd", "config_cmd"]
@@ -0,0 +1,35 @@
1
+ import click
2
+
3
+ from . import ACTIONS
4
+ from ..config import get_manager
5
+
6
+
7
+ @click.command("all")
8
+ @click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
9
+ @click.option(
10
+ "--yes",
11
+ is_flag=True,
12
+ default=False,
13
+ help="Confirm the operation on all services.",
14
+ )
15
+ @click.pass_context
16
+ def all_cmd(ctx: click.Context, action: str, yes: bool) -> None:
17
+ """Perform ACTION on ALL managed services.
18
+
19
+ \b
20
+ Actions: start, stop, restart, enable, disable
21
+
22
+ Requires --yes to prevent accidental execution.
23
+ """
24
+ if not yes:
25
+ raise click.UsageError(
26
+ f"This will run '{action}' on ALL services. Pass --yes to confirm."
27
+ )
28
+ manager = get_manager(ctx)
29
+ try:
30
+ manager.manage_all(action)
31
+ click.echo(f"[OK] '{action}' on all services.")
32
+ except ValueError as e:
33
+ raise click.ClickException(str(e))
34
+ except RuntimeError as e:
35
+ raise click.ClickException(str(e))
@@ -0,0 +1,61 @@
1
+ import os
2
+
3
+ import click
4
+
5
+ from ..config import DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, resolve_config_path
6
+
7
+ _TEMPLATE = """\
8
+ global:
9
+ # Absolute path to the directory where service sd_info.json files live.
10
+ services_dir: "/path/to/your/services"
11
+
12
+ # VPN provider to use. Must match a registered provider (e.g. "tailscale").
13
+ vpn_provider: "tailscale"
14
+ """
15
+
16
+
17
+ @click.group("config")
18
+ def config_cmd() -> None:
19
+ """Manage the smanager configuration file."""
20
+
21
+
22
+ @config_cmd.command("init")
23
+ @click.option("--force", is_flag=True, help="Overwrite existing config.")
24
+ def init_cmd(force: bool) -> None:
25
+ """Create a template config at the default location.
26
+
27
+ \b
28
+ Default: ~/.config/smanager/smanager.yaml
29
+ """
30
+ if os.path.exists(DEFAULT_CONFIG_PATH) and not force:
31
+ raise click.ClickException(
32
+ f"Config already exists at {DEFAULT_CONFIG_PATH}. "
33
+ "Use --force to overwrite."
34
+ )
35
+ os.makedirs(DEFAULT_CONFIG_DIR, exist_ok=True)
36
+ with open(DEFAULT_CONFIG_PATH, "w") as f:
37
+ f.write(_TEMPLATE)
38
+ click.echo(f"Config created: {DEFAULT_CONFIG_PATH}")
39
+
40
+
41
+ @config_cmd.command("edit")
42
+ @click.pass_context
43
+ def edit_cmd(ctx: click.Context) -> None:
44
+ """Open the config file in $EDITOR (falls back to nano).
45
+
46
+ \b
47
+ Resolves the config path in the same order as all other commands:
48
+ 1. --config PATH
49
+ 2. $SMANAGER_CONFIG
50
+ 3. ~/.config/smanager/smanager.yaml
51
+ 4. /etc/smanager/smanager.yaml
52
+ 5. ./smanager.yaml
53
+ """
54
+ try:
55
+ path = resolve_config_path(ctx.obj.get("config_path"))
56
+ except click.ClickException:
57
+ raise click.ClickException(
58
+ "No config file found. Run 'smanager config init' first."
59
+ )
60
+ editor = os.environ.get("EDITOR", "nano")
61
+ click.edit(filename=path, editor=editor)
@@ -0,0 +1,24 @@
1
+ import click
2
+
3
+ from . import ACTIONS
4
+ from ..config import get_manager
5
+
6
+
7
+ @click.command()
8
+ @click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
9
+ @click.argument("dependency_name")
10
+ @click.pass_context
11
+ def dependency_cmd(ctx: click.Context, action: str, dependency_name: str) -> None:
12
+ """Perform ACTION on all services that depend on DEPENDENCY_NAME.
13
+
14
+ \b
15
+ Actions: start, stop, restart, enable, disable
16
+ """
17
+ manager = get_manager(ctx)
18
+ try:
19
+ manager.manage_by_dependency(dependency_name, action)
20
+ click.echo(f"[OK] '{action}' on services depending on '{dependency_name}'.")
21
+ except ValueError as e:
22
+ raise click.ClickException(str(e))
23
+ except RuntimeError as e:
24
+ raise click.ClickException(str(e))
@@ -0,0 +1,24 @@
1
+ import click
2
+
3
+ from . import ACTIONS
4
+ from ..config import get_manager
5
+
6
+
7
+ @click.command()
8
+ @click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
9
+ @click.argument("group_name")
10
+ @click.pass_context
11
+ def group_cmd(ctx: click.Context, action: str, group_name: str) -> None:
12
+ """Perform ACTION on all services in GROUP_NAME.
13
+
14
+ \b
15
+ Actions: start, stop, restart, enable, disable
16
+ """
17
+ manager = get_manager(ctx)
18
+ try:
19
+ manager.manage_group(group_name, action)
20
+ click.echo(f"[OK] '{action}' on group '{group_name}'.")
21
+ except ValueError as e:
22
+ raise click.ClickException(str(e))
23
+ except RuntimeError as e:
24
+ raise click.ClickException(str(e))
@@ -0,0 +1,24 @@
1
+ import click
2
+
3
+ from . import ACTIONS
4
+ from ..config import get_manager
5
+
6
+
7
+ @click.command()
8
+ @click.argument("action", type=click.Choice(ACTIONS, case_sensitive=False))
9
+ @click.argument("service_id")
10
+ @click.pass_context
11
+ def service_cmd(ctx: click.Context, action: str, service_id: str) -> None:
12
+ """Perform ACTION on a single service.
13
+
14
+ \b
15
+ Actions: start, stop, restart, enable, disable
16
+ """
17
+ manager = get_manager(ctx)
18
+ try:
19
+ manager.manage_service(service_id, action)
20
+ click.echo(f"[OK] '{action}' on service '{service_id}'.")
21
+ except ValueError as e:
22
+ raise click.ClickException(str(e))
23
+ except RuntimeError as e:
24
+ raise click.ClickException(str(e))
@@ -0,0 +1,85 @@
1
+ import dataclasses
2
+ import json as json_module
3
+
4
+ import click
5
+
6
+ from ..config import get_manager
7
+ from ...datatypes import MultiStatus, ServiceStatus
8
+
9
+
10
+ def _print_service_status(status: ServiceStatus) -> None:
11
+ click.echo(f"Service: {status.id} [{status.type}]")
12
+ for s in status.statuses:
13
+ marker = "●" if s.status in ("running", "active") else "○"
14
+ click.echo(f" {marker} {s.name}: {s.status}")
15
+
16
+
17
+ def _print_multi_status(multi: MultiStatus) -> None:
18
+ for i, svc in enumerate(multi.services):
19
+ if i > 0:
20
+ click.echo("")
21
+ _print_service_status(svc)
22
+
23
+
24
+ @click.command()
25
+ @click.option("--service", "service_id", default=None, metavar="ID",
26
+ help="Status of a single service.")
27
+ @click.option("--group", "group_name", default=None, metavar="NAME",
28
+ help="Status of all services in a group.")
29
+ @click.option("--dependency", "dependency_name", default=None, metavar="NAME",
30
+ help="Status of services by dependency.")
31
+ @click.option("--all", "all_services", is_flag=True, default=False,
32
+ help="Status of all services.")
33
+ @click.option("--json", "as_json", is_flag=True, default=False,
34
+ help="Output as JSON.")
35
+ @click.pass_context
36
+ def status_cmd(
37
+ ctx: click.Context,
38
+ service_id: str | None,
39
+ group_name: str | None,
40
+ dependency_name: str | None,
41
+ all_services: bool,
42
+ as_json: bool,
43
+ ) -> None:
44
+ """Show the status of services.
45
+
46
+ \b
47
+ Provide exactly one scope:
48
+ --service ID single service
49
+ --group NAME all services in a group
50
+ --dependency NAME all services by dependency
51
+ --all every managed service
52
+ """
53
+ scopes = [x for x in [service_id, group_name, dependency_name] if x] + (
54
+ [True] if all_services else []
55
+ )
56
+ if len(scopes) == 0:
57
+ raise click.UsageError(
58
+ "Provide one of: --service, --group, --dependency, --all"
59
+ )
60
+ if len(scopes) > 1:
61
+ raise click.UsageError(
62
+ "Only one of --service, --group, --dependency, --all can be used at a time."
63
+ )
64
+
65
+ manager = get_manager(ctx)
66
+ try:
67
+ if service_id:
68
+ result = manager.get_status(service_id)
69
+ if as_json:
70
+ click.echo(json_module.dumps(dataclasses.asdict(result), indent=2))
71
+ else:
72
+ _print_service_status(result)
73
+ else:
74
+ if group_name:
75
+ result = manager.get_group_status(group_name)
76
+ elif dependency_name:
77
+ result = manager.get_dependency_status(dependency_name)
78
+ else:
79
+ result = manager.get_all_status()
80
+ if as_json:
81
+ click.echo(json_module.dumps(dataclasses.asdict(result), indent=2))
82
+ else:
83
+ _print_multi_status(result)
84
+ except ValueError as e:
85
+ raise click.ClickException(str(e))
smanager/cli/config.py ADDED
@@ -0,0 +1,75 @@
1
+ """
2
+ Config loading and GlobalManager initialisation for the CLI.
3
+ """
4
+ import os
5
+
6
+ import click
7
+
8
+ from ..datatypes import GlobalConfig
9
+ from ..global_manager import GlobalManager
10
+ from ..vpn import get_vpn
11
+
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # Default paths (XDG-compliant)
15
+ # ---------------------------------------------------------------------------
16
+ _XDG_CONFIG_HOME = os.environ.get(
17
+ "XDG_CONFIG_HOME", os.path.expanduser("~/.config")
18
+ )
19
+ DEFAULT_CONFIG_DIR = os.path.join(_XDG_CONFIG_HOME, "smanager")
20
+ DEFAULT_CONFIG_PATH = os.path.join(DEFAULT_CONFIG_DIR, "smanager.yaml")
21
+ SYSTEM_CONFIG_PATH = "/etc/smanager/smanager.yaml"
22
+
23
+ # Ordered list of locations tried when no explicit path is given.
24
+ _SEARCH_PATHS = [
25
+ DEFAULT_CONFIG_PATH,
26
+ SYSTEM_CONFIG_PATH,
27
+ "smanager.yaml", # legacy local fallback
28
+ ]
29
+
30
+
31
+ def resolve_config_path(config_path: str | None) -> str:
32
+ """Return the resolved config file path or raise ClickException."""
33
+ if config_path is not None:
34
+ return config_path
35
+ env = os.environ.get("SMANAGER_CONFIG")
36
+ if env:
37
+ return env
38
+ for path in _SEARCH_PATHS:
39
+ if os.path.isfile(path):
40
+ return path
41
+ raise click.ClickException(
42
+ f"No config file found. Run 'smanager config init' to create one at "
43
+ f"{DEFAULT_CONFIG_PATH}."
44
+ )
45
+
46
+
47
+ def build_manager(config_path: str | None) -> GlobalManager:
48
+ """Resolve a config file and return an initialised GlobalManager."""
49
+ path = resolve_config_path(config_path)
50
+ with open(path) as f:
51
+ try:
52
+ config = GlobalConfig.from_yaml(f.read())
53
+ except ValueError as e:
54
+ raise click.ClickException(str(e))
55
+ if not config.services_dir:
56
+ raise click.ClickException("'services_dir' is not set in the config file.")
57
+ if not os.path.isdir(config.services_dir):
58
+ raise click.ClickException(
59
+ f"services_dir does not exist: {config.services_dir}"
60
+ )
61
+ try:
62
+ vpn_cls = get_vpn(config.vpn_provider)
63
+ vpn = vpn_cls()
64
+ except ValueError as e:
65
+ raise click.ClickException(
66
+ f"Unknown VPN provider '{config.vpn_provider}': {e}"
67
+ )
68
+ return GlobalManager(config.services_dir, config, vpn)
69
+
70
+
71
+ def get_manager(ctx: click.Context) -> GlobalManager:
72
+ """Return the GlobalManager stored in *ctx*, building it lazily on first call."""
73
+ if ctx.obj.get("_manager") is None:
74
+ ctx.obj["_manager"] = build_manager(ctx.obj.get("config_path"))
75
+ return ctx.obj["_manager"]
smanager/cli/main.py ADDED
@@ -0,0 +1,30 @@
1
+ import click
2
+
3
+ from .commands import all_cmd, dependency_cmd, group_cmd, service_cmd, status_cmd
4
+ from .commands import all_cmd, config_cmd, dependency_cmd, group_cmd, service_cmd, status_cmd
5
+
6
+
7
+ @click.group()
8
+ @click.option(
9
+ "--config",
10
+ "config_path",
11
+ envvar="SMANAGER_CONFIG",
12
+ default=None,
13
+ metavar="PATH",
14
+ help="Path to config file (or set SMANAGER_CONFIG). Default: ./smanager.yaml",
15
+ )
16
+ @click.version_option(package_name="smanager")
17
+ @click.pass_context
18
+ def cli(ctx: click.Context, config_path: str | None) -> None:
19
+ """SManager — manage self-hosted services and Docker containers."""
20
+ ctx.ensure_object(dict)
21
+ ctx.obj["config_path"] = config_path
22
+ ctx.obj["_manager"] = None
23
+
24
+
25
+ cli.add_command(service_cmd, "service")
26
+ cli.add_command(group_cmd, "group")
27
+ cli.add_command(dependency_cmd, "dependency")
28
+ cli.add_command(all_cmd, "all")
29
+ cli.add_command(status_cmd, "status")
30
+ cli.add_command(config_cmd, "config")
@@ -0,0 +1,5 @@
1
+ from .sd_finder import ServiceDiscoverer
2
+
3
+ __all__ = [
4
+ "ServiceDiscoverer"
5
+ ]
@@ -0,0 +1,32 @@
1
+ """
2
+ Finds Service Discovery files in a given directory and parses them into Service objects.
3
+ """
4
+
5
+ import os
6
+ from ..datatypes import ServiceMetadata, Service
7
+
8
+ class ServiceDiscoverer:
9
+ def __init__(self, directory: str, searched_filename: str = "sd_info.json"):
10
+ self.directory = directory
11
+ self.searched_filename = searched_filename
12
+
13
+ def discover_services(self) -> list[Service]:
14
+ """Walks through the directory to find SDConfig files, returns a list
15
+ of Service objects.
16
+ """
17
+ services = []
18
+ for root, _, files in os.walk(self.directory):
19
+ if self.searched_filename in files:
20
+ sd_abspath = os.path.join(root, self.searched_filename)
21
+ with open(sd_abspath, 'r') as f:
22
+ try:
23
+ metadata = ServiceMetadata.from_json(f.read())
24
+ service = Service(
25
+ metadata=metadata,
26
+ service_abspath=root,
27
+ metadata_filename=self.searched_filename
28
+ )
29
+ services.append(service)
30
+ except Exception as e:
31
+ print(f"Error parsing {sd_abspath}: {e}")
32
+ return services
@@ -0,0 +1,19 @@
1
+ from .sd_info_v2 import (
2
+ ServiceMetadata, RuntimeSection, PortSection,
3
+ DockerSection, LocalSection, Service
4
+ )
5
+ from .config import GlobalConfig
6
+ from .service_status import ServiceStatus, SingleStatus, MultiStatus
7
+
8
+ __all__ = [
9
+ "ServiceMetadata",
10
+ "RuntimeSection",
11
+ "PortSection",
12
+ "DockerSection",
13
+ "LocalSection",
14
+ "GlobalConfig",
15
+ "Service",
16
+ "ServiceStatus",
17
+ "SingleStatus",
18
+ "MultiStatus"
19
+ ]
@@ -0,0 +1,20 @@
1
+ import yaml
2
+ from dataclasses import dataclass, field
3
+
4
+ @dataclass
5
+ class GlobalConfig:
6
+ services_dir: str
7
+ vpn_provider: str = field(default="tailscale")
8
+
9
+ @classmethod
10
+ def from_yaml(cls, yaml_str: str) -> 'GlobalConfig':
11
+ """Parses a YAML string into a GlobalConfig object."""
12
+ try:
13
+ data = yaml.safe_load(yaml_str)
14
+ global_data = data.get('global', {})
15
+ return cls(
16
+ services_dir=global_data.get('services_dir', ''),
17
+ vpn_provider=global_data.get('vpn_provider', 'tailscale'),
18
+ )
19
+ except yaml.YAMLError as e:
20
+ raise ValueError(f"Error parsing YAML: {e}")
@@ -0,0 +1,134 @@
1
+ """
2
+ This module defines the data structures for the SDConfig v2
3
+ """
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+ import json
7
+ import os
8
+
9
+
10
+ @dataclass
11
+ class PortSection:
12
+ webui: int
13
+
14
+ # Dinamically loads dict keys as attributes
15
+ def __post_init__(self):
16
+ for key, value in self.__dict__.items():
17
+ setattr(self, key, value)
18
+
19
+ if not hasattr(self, 'webui'):
20
+ raise ValueError("PortSection must have a 'webui' key.")
21
+
22
+ @dataclass
23
+ class RuntimeSection:
24
+ type: str
25
+ ports: PortSection
26
+ sys_user: str
27
+ sys_group: str
28
+
29
+ def __post_init__(self):
30
+ if not self.type:
31
+ raise ValueError("RuntimeSection must have a 'type' field.")
32
+ if not self.ports:
33
+ raise ValueError("RuntimeSection must have a 'ports' field.")
34
+ if not self.sys_user:
35
+ raise ValueError("RuntimeSection must have a 'sys_user' field.")
36
+ if not self.sys_group:
37
+ raise ValueError("RuntimeSection must have a 'sys_group' field.")
38
+
39
+ @dataclass
40
+ class DockerSection:
41
+ containers: Optional[list] = None
42
+ compose_file: Optional[str] = None
43
+
44
+ @dataclass
45
+ class LocalSection:
46
+ installation_path: Optional[str] = None
47
+ app_path: Optional[str] = None
48
+ systemd_services: Optional[list] = None
49
+
50
+ @dataclass
51
+ class ServiceMetadata:
52
+ name: str
53
+ description: str
54
+ schema_version: int
55
+ id: str
56
+ active: bool
57
+ runtime: RuntimeSection
58
+ groups: Optional[list] = None
59
+ docker: Optional[DockerSection] = None
60
+ local: Optional[LocalSection] = None
61
+ depends_on: Optional[list] = None
62
+
63
+ def __post_init__(self):
64
+ # Validate required fields
65
+ if not self.name:
66
+ raise ValueError("SDConfig must have a 'name' field.")
67
+ if not self.description:
68
+ raise ValueError("SDConfig must have a 'description' field.")
69
+ if not self.schema_version:
70
+ raise ValueError("SDConfig must have a 'schema_version' field.")
71
+ if not self.id:
72
+ raise ValueError("SDConfig must have an 'id' field.")
73
+ if self.active is None:
74
+ raise ValueError("SDConfig must have an 'active' field.")
75
+ if not self.runtime:
76
+ raise ValueError("SDConfig must have a 'runtime' field.")
77
+
78
+ # Validate at least one of docker or local is provided
79
+ if not self.docker and not self.local:
80
+ raise ValueError("SDConfig must have at least one of 'docker' or 'local' sections.")
81
+
82
+ @classmethod
83
+ def from_dict(cls, data: dict) -> 'ServiceMetadata':
84
+ runtime_data = data.get('runtime')
85
+ if not runtime_data:
86
+ raise ValueError("SDConfig must have a 'runtime' section.")
87
+
88
+ runtime = RuntimeSection(
89
+ type=runtime_data.get('type'),
90
+ ports=PortSection(**runtime_data.get('ports', {})),
91
+ sys_user=runtime_data.get('sys_user'),
92
+ sys_group=runtime_data.get('sys_group')
93
+ )
94
+
95
+ docker_data = data.get('docker')
96
+ docker = DockerSection(**docker_data) if docker_data else None
97
+
98
+ local_data = data.get('local')
99
+ local = LocalSection(**local_data) if local_data else None
100
+
101
+ return cls(
102
+ name=data.get('name'),
103
+ description=data.get('description'),
104
+ schema_version=data.get('schema_version'),
105
+ id=data.get('id'),
106
+ groups=data.get('groups'),
107
+ active=data.get('active'),
108
+ runtime=runtime,
109
+ docker=docker,
110
+ local=local,
111
+ depends_on=data.get('depends_on')
112
+ )
113
+
114
+ @classmethod
115
+ def from_json(cls, json_str: str) -> 'ServiceMetadata':
116
+ return cls.from_dict(json.loads(json_str))
117
+
118
+ def write_to_json(self, file_path: str):
119
+ with open(file_path, 'w') as f:
120
+ json.dump(self.__dict__, f, default=lambda o: o.__dict__, indent=4)
121
+
122
+ @dataclass
123
+ class Service:
124
+ metadata: ServiceMetadata
125
+ service_abspath: str
126
+ metadata_filename: str
127
+
128
+ def __post_init__(self):
129
+ if not os.path.isabs(self.service_abspath):
130
+ raise ValueError("Service 'service_abspath' must be an absolute path.")
131
+ if not os.path.exists(self.service_abspath):
132
+ raise ValueError(f"Service 'service_abspath' does not exist: {self.service_abspath}")
133
+ if not self.metadata_filename:
134
+ raise ValueError("Service must have a 'metadata_filename' field.")
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+ @dataclass
4
+ class SingleStatus:
5
+ name: str
6
+ status: str
7
+
8
+ @dataclass
9
+ class ServiceStatus:
10
+ id: str
11
+ type: str # e.g. "docker", "local"
12
+ statuses: list[SingleStatus]
13
+
14
+ @dataclass
15
+ class MultiStatus:
16
+ services: list[ServiceStatus]
@@ -0,0 +1,7 @@
1
+ from .base_global_manager import BaseGlobalManager
2
+ from .global_manager import GlobalManager
3
+
4
+ __all__ = [
5
+ "BaseGlobalManager",
6
+ "GlobalManager",
7
+ ]
@@ -0,0 +1,81 @@
1
+ from ..service_managers import BaseManager
2
+ from ..datatypes import Service, GlobalConfig, ServiceStatus, MultiStatus
3
+ from ..crawler import ServiceDiscoverer
4
+ from ..vpn import VPNService
5
+ from abc import abstractmethod
6
+
7
+ class BaseGlobalManager:
8
+ """BaseGlobalManager is an abstract class that defines the interface for a global manager.
9
+ It provides methods for searching and managing services, but does not implement the
10
+ actual logic. Subclasses should implement the specific logic for managing services.
11
+ """
12
+
13
+ def __init__(self, services_dir: str, global_config: GlobalConfig, vpn: VPNService):
14
+ self.services_dir = services_dir
15
+ self.global_config = global_config
16
+ self.vpn = vpn
17
+ self.crawler = ServiceDiscoverer(services_dir)
18
+ self.services = self.discover_services()
19
+
20
+ def discover_services(self) -> list[Service]:
21
+ """Discover services in the specified services directory."""
22
+ return self.crawler.discover_services()
23
+
24
+ @abstractmethod
25
+ def get_service_manager(self, service: Service) -> BaseManager:
26
+ """Get the appropriate service manager for the given service."""
27
+ raise NotImplementedError("get_service_manager method must be implemented by subclasses.")
28
+
29
+ @abstractmethod
30
+ def get_group(self, group_name: str) -> list[Service]:
31
+ """Get all services that belong to the specified group."""
32
+ raise NotImplementedError("get_group method must be implemented by subclasses.")
33
+
34
+ @abstractmethod
35
+ def get_services_by_dependency(self, dependency_name: str) -> list[Service]:
36
+ """Get all services that depend on the specified dependency."""
37
+ raise NotImplementedError("get_services_by_dependency method must be implemented by subclasses.")
38
+
39
+ @abstractmethod
40
+ def manage_service(self, service_id: str, action: str):
41
+ """Manage a service by performing the specified action (start, stop, restart, enable, disable)."""
42
+ raise NotImplementedError("manage_service method must be implemented by subclasses.")
43
+
44
+ @abstractmethod
45
+ def manage_group(self, group_name: str, action: str):
46
+ """Manage all services in a group by performing the specified action
47
+ (start, stop, restart).
48
+ """
49
+ raise NotImplementedError("manage_group method must be implemented by subclasses.")
50
+
51
+ @abstractmethod
52
+ def manage_by_dependency(self, dependency_name: str, action: str):
53
+ """Manage all services that depend on a specific dependency by performing the
54
+ specified action (start, stop, restart, enable, disable).
55
+ """
56
+ raise NotImplementedError("manage_by_dependency method must be implemented by subclasses.")
57
+
58
+ @abstractmethod
59
+ def manage_all(self, action: str):
60
+ """Manage all services by performing the specified action (start, stop, restart, enable, disable)."""
61
+ raise NotImplementedError("manage_all method must be implemented by subclasses.")
62
+
63
+ @abstractmethod
64
+ def get_status(self, service_id: str) -> ServiceStatus:
65
+ """Get the status of a specific service by its ID."""
66
+ raise NotImplementedError("get_status method must be implemented by subclasses.")
67
+
68
+ @abstractmethod
69
+ def get_group_status(self, group_name: str) -> MultiStatus:
70
+ """Get the status of all services in a group."""
71
+ raise NotImplementedError("get_group_status method must be implemented by subclasses.")
72
+
73
+ @abstractmethod
74
+ def get_dependency_status(self, dependency_name: str) -> MultiStatus:
75
+ """Get the status of all services that depend on a specific dependency."""
76
+ raise NotImplementedError("get_dependency_status method must be implemented by subclasses.")
77
+
78
+ @abstractmethod
79
+ def get_all_status(self) -> MultiStatus:
80
+ """Get the status of all services."""
81
+ raise NotImplementedError("get_all_status method must be implemented by subclasses.")
@@ -0,0 +1,103 @@
1
+ from ..service_managers import DockerManager, LocalManager, BaseManager
2
+ from ..datatypes import Service, GlobalConfig, ServiceStatus, MultiStatus
3
+ from ..vpn import VPNService
4
+ from . import BaseGlobalManager
5
+
6
+ class GlobalManager(BaseGlobalManager):
7
+ """GlobalManager is responsible for searching and managing all services found in the
8
+ specified services directory. It uses the appropriate service manager (DockerManager
9
+ or LocalManager) based on the service metadata.
10
+ """
11
+
12
+ def __init__(self, services_dir: str, global_config: GlobalConfig, vpn: VPNService):
13
+ super().__init__(services_dir, global_config, vpn)
14
+
15
+ def _find_service_by_id(self, service_id: str) -> Service:
16
+ """Helper method to find a service by its unique identifier."""
17
+ for service in self.services:
18
+ if service.metadata.id == service_id:
19
+ return service
20
+ raise ValueError(f"Service with ID '{service_id}' not found.")
21
+
22
+ def get_service_manager(self, service: Service, restart_delay: int = 5) -> BaseManager:
23
+ """Get the appropriate service manager for the given service based on its metadata."""
24
+ if service.metadata.runtime.type == "docker":
25
+ return DockerManager(service, self.vpn, restart_delay=restart_delay)
26
+ elif service.metadata.runtime.type == "local":
27
+ return LocalManager(service, vpn=self.vpn, restart_delay=restart_delay)
28
+ else:
29
+ raise ValueError(f"Unsupported runtime type: {service.metadata.runtime.type}")
30
+
31
+ def get_group(self, group_name: str) -> list[Service]:
32
+ """Get all services that belong to the specified group."""
33
+ return [s for s in self.services if s.metadata.groups and group_name in s.metadata.groups]
34
+
35
+ def get_services_by_dependency(self, dependency_name: str) -> list[Service]:
36
+ """Get all services that depend on the specified dependency."""
37
+ return [s for s in self.services if s.metadata.depends_on and dependency_name in s.metadata.depends_on]
38
+
39
+ def manage_service(self, service_id: str, action: str):
40
+ """Manage a service by performing the specified action (start, stop, restart,
41
+ enable, disable).
42
+ """
43
+ service = self._find_service_by_id(service_id)
44
+ manager = self.get_service_manager(service)
45
+
46
+ if action == "start":
47
+ manager.start()
48
+ elif action == "stop":
49
+ manager.stop()
50
+ elif action == "restart":
51
+ manager.restart()
52
+ elif action == "enable":
53
+ manager.enable()
54
+ elif action == "disable":
55
+ manager.disable()
56
+ else:
57
+ raise ValueError(f"Unsupported action: {action}")
58
+
59
+ def manage_group(self, group_name: str, action: str):
60
+ """Manage all services in a group by performing the specified action
61
+ (start, stop, restart, enable, disable).
62
+ """
63
+ services = self.get_group(group_name)
64
+ for service in services:
65
+ self.manage_service(service.metadata.id, action)
66
+
67
+ def manage_by_dependency(self, dependency_name: str, action: str):
68
+ """Manage all services that depend on a specific dependency by performing the
69
+ specified action (start, stop, restart, enable, disable).
70
+ """
71
+ services = self.get_services_by_dependency(dependency_name)
72
+ for service in services:
73
+ self.manage_service(service.metadata.id, action)
74
+
75
+ def manage_all(self, action: str):
76
+ """Manage all services by performing the specified action (start, stop, restart,
77
+ enable, disable)."""
78
+ for service in self.services:
79
+ self.manage_service(service.metadata.id, action)
80
+
81
+ def get_status(self, service_id: str) -> ServiceStatus:
82
+ """Get the status of a specific service by its ID."""
83
+ service = self._find_service_by_id(service_id)
84
+ manager = self.get_service_manager(service)
85
+ return manager.status()
86
+
87
+ def get_group_status(self, group_name: str) -> MultiStatus:
88
+ """Get the status of all services in a group."""
89
+ services = self.get_group(group_name)
90
+ statuses = [self.get_status(service.metadata.id) for service in services]
91
+ return MultiStatus(services=statuses)
92
+
93
+ def get_dependency_status(self, dependency_name: str) -> MultiStatus:
94
+ """Get the status of all services that depend on a specific dependency."""
95
+ services = self.get_services_by_dependency(dependency_name)
96
+ statuses = [self.get_status(service.metadata.id) for service in services]
97
+ return MultiStatus(services=statuses)
98
+
99
+ def get_all_status(self) -> MultiStatus:
100
+ """Get the status of all services."""
101
+ statuses = [self.get_status(service.metadata.id) for service in self.services]
102
+ return MultiStatus(services=statuses)
103
+
@@ -0,0 +1,11 @@
1
+ from .base_manager import BaseManager
2
+ from .base_sdv2_manager import BaseSDV2Manager
3
+ from .docker_manager import DockerManager
4
+ from .local_manager import LocalManager
5
+
6
+ __all__ = [
7
+ "BaseManager",
8
+ "BaseSDV2Manager",
9
+ "DockerManager",
10
+ "LocalManager"
11
+ ]
@@ -0,0 +1,62 @@
1
+ """
2
+ Base class for all simple service managers.
3
+ Defines the interface and common functionality for managing a single service.
4
+ """
5
+
6
+ from abc import abstractmethod
7
+ from ..datatypes import ServiceStatus
8
+
9
+ class BaseManager:
10
+ def __init__(self, config):
11
+ self.config = config
12
+
13
+ @abstractmethod
14
+ def start(self):
15
+ """Start the service."""
16
+ raise NotImplementedError("Start method must be implemented by subclasses.")
17
+
18
+ @abstractmethod
19
+ def stop(self):
20
+ """Stop the service."""
21
+ raise NotImplementedError("Stop method must be implemented by subclasses.")
22
+
23
+ def restart(self):
24
+ """Restart the service."""
25
+ self.stop()
26
+ self.start()
27
+
28
+ @abstractmethod
29
+ def status(self)-> ServiceStatus:
30
+ """Check the status of the service."""
31
+ raise NotImplementedError("Status method must be implemented by subclasses.")
32
+
33
+ @abstractmethod
34
+ def enable(self):
35
+ """Enable the service to be managed (start, stop, restart)."""
36
+ raise NotImplementedError("Enable method must be implemented by subclasses.")
37
+
38
+ @abstractmethod
39
+ def disable(self):
40
+ """Disable the service from being managed (start, stop, restart)."""
41
+ raise NotImplementedError("Disable method must be implemented by subclasses.")
42
+
43
+ @abstractmethod
44
+ def get_groups(self):
45
+ """Get the groups associated with this service."""
46
+ raise NotImplementedError("get_groups method must be implemented by subclasses.")
47
+
48
+ @abstractmethod
49
+ def get_dependencies(self):
50
+ """Get the dependencies of this service."""
51
+ raise NotImplementedError("get_dependencies method must be implemented by subclasses.")
52
+
53
+ @abstractmethod
54
+ def get_id(self):
55
+ """Get the unique identifier of this service."""
56
+ raise NotImplementedError("get_id method must be implemented by subclasses.")
57
+
58
+ @abstractmethod
59
+ def is_active(self):
60
+ """Check if the service is active."""
61
+ raise NotImplementedError("is_active method must be implemented by subclasses.")
62
+
@@ -0,0 +1,45 @@
1
+ """
2
+ The bass manager for SDV2 services.
3
+ It extends the BaseManager with SDV2-specific logic, such as handling the SDConfig
4
+ file and integrating with VPN services.
5
+ """
6
+
7
+ from . import BaseManager
8
+ from ..datatypes import Service
9
+ from ..vpn import VPNService
10
+ import os
11
+
12
+ class BaseSDV2Manager(BaseManager):
13
+ def __init__(self, service: Service, vpn: VPNService, restart_delay: int = 5):
14
+ super().__init__(service)
15
+ self.service = service
16
+ self.vpn = vpn
17
+ self.restart_delay = restart_delay
18
+
19
+ def enable(self):
20
+ """Change the JSON config to set the service as active."""
21
+ self.service.metadata.active = True
22
+ output_path = os.path.join(self.service.service_abspath, self.service.metadata_filename)
23
+ self.service.metadata.write_to_json(output_path)
24
+
25
+ def disable(self):
26
+ """Change the JSON config to set the service as inactive."""
27
+ self.service.metadata.active = False
28
+ output_path = os.path.join(self.service.service_abspath, self.service.metadata_filename)
29
+ self.service.metadata.write_to_json(output_path)
30
+
31
+ def get_groups(self):
32
+ """Get the groups associated with this service."""
33
+ return self.service.metadata.groups
34
+
35
+ def get_dependencies(self):
36
+ """Get the dependencies of this service."""
37
+ return self.service.metadata.depends_on
38
+
39
+ def get_id(self):
40
+ """Get the unique identifier of this service."""
41
+ return self.service.metadata.id
42
+
43
+ def is_active(self):
44
+ """Check if the service is active."""
45
+ return self.service.metadata.active
@@ -0,0 +1,63 @@
1
+ from . import BaseSDV2Manager
2
+ from ..datatypes import Service
3
+ import subprocess
4
+ import os
5
+ from ..vpn import VPNService
6
+ import time
7
+ from ..datatypes import ServiceStatus, SingleStatus
8
+
9
+ class DockerManager(BaseSDV2Manager):
10
+ def __init__(self, service: Service, vpn: VPNService, restart_delay: int = 5):
11
+ super().__init__()
12
+ self._compose_path = self.service.metadata.docker.compose_file
13
+
14
+ def _get_compose_file_path(self) -> str:
15
+ """Returns the path to the docker-compose.yml file for this service."""
16
+ return os.path.join(
17
+ self.service.service_abspath,
18
+ self.service.metadata.docker.compose_file
19
+ )
20
+
21
+ def start(self):
22
+ """Start the Docker service using docker-compose."""
23
+ compose_file = self._get_compose_file_path()
24
+ subprocess.run(["docker-compose", "-f", compose_file, "up", "-d"], check=True)
25
+
26
+ def stop(self):
27
+ """Stop the Docker service using docker-compose."""
28
+ compose_file = self._get_compose_file_path()
29
+ subprocess.run(["docker-compose", "-f", compose_file, "down"], check=True)
30
+
31
+ def restart(self):
32
+ """Restart the Docker service using docker-compose."""
33
+ self.stop()
34
+ self.vpn.stop_serving_https(self.service.metadata.runtime.ports.webui)
35
+ self.start()
36
+ time.sleep(self.restart_delay)
37
+ self.vpn.serve_https(self.service.metadata.runtime.ports.webui)
38
+
39
+ def status(self)-> ServiceStatus:
40
+ """Check the status of all containers in the Docker service and return a
41
+ ServiceStatus object.
42
+ """
43
+ compose_file = self._get_compose_file_path()
44
+ result = subprocess.run(["docker-compose", "-f", compose_file, "ps", "--services", "--filter", "status=running"], capture_output=True, text=True)
45
+ running_services = set(result.stdout.strip().splitlines())
46
+
47
+ all_services_result = subprocess.run(["docker-compose", "-f", compose_file, "config", "--services"], capture_output=True, text=True)
48
+ all_services = set(all_services_result.stdout.strip().splitlines())
49
+
50
+ service_statuses = []
51
+ for service_name in all_services:
52
+ status = SingleStatus(
53
+ name=service_name,
54
+ status="running" if service_name in running_services else "stopped"
55
+ )
56
+ service_statuses.append(status)
57
+
58
+ return ServiceStatus(
59
+ id=self.get_id(),
60
+ type=self.service.metadata.runtime.type,
61
+ services=service_statuses
62
+ )
63
+
@@ -0,0 +1,44 @@
1
+ from . import BaseSDV2Manager
2
+ from ..datatypes import Service
3
+ import subprocess
4
+ from ..vpn import VPNService
5
+ import time
6
+ from ..datatypes import ServiceStatus, SingleStatus
7
+
8
+ class LocalManager(BaseSDV2Manager):
9
+ def __init__(self, service: Service, vpn: VPNService, restart_delay: int = 5):
10
+ super().__init__()
11
+
12
+ def start(self):
13
+ """Start the local systemd service."""
14
+ for service_name in self.service.metadata.local.systemd_services:
15
+ subprocess.run(["systemctl", "start", service_name], check=True)
16
+
17
+ def stop(self):
18
+ """Stop the local systemd service."""
19
+ for service_name in self.service.metadata.local.systemd_services:
20
+ subprocess.run(["systemctl", "stop", service_name], check=True)
21
+
22
+ def restart(self):
23
+ """Restart the local systemd service."""
24
+ self.stop()
25
+ self.vpn.stop_serving_https(self.service.metadata.runtime.ports.webui)
26
+ self.start()
27
+ time.sleep(self.restart_delay)
28
+ self.vpn.serve_https(self.service.metadata.runtime.ports.webui)
29
+
30
+ def status(self)-> ServiceStatus:
31
+ """Check the status of the local systemd service and return a ServiceStatus object."""
32
+ service_statuses = []
33
+ for service_name in self.service.metadata.local.systemd_services:
34
+ result = subprocess.run(["systemctl", "is-active", service_name], capture_output=True, text=True)
35
+ status = SingleStatus(
36
+ name=service_name,
37
+ status=result.stdout.strip()
38
+ )
39
+ service_statuses.append(status)
40
+ return ServiceStatus(
41
+ id=self.get_id(),
42
+ type=self.service.metadata.runtime.type,
43
+ services=service_statuses
44
+ )
@@ -0,0 +1,11 @@
1
+ from .base_vpn import VPNService
2
+ from .registry import register_vpn, get_vpn, list_vpns
3
+ from .providers import TailscaleVPN
4
+
5
+ __all__ = [
6
+ "VPNService",
7
+ "register_vpn",
8
+ "get_vpn",
9
+ "list_vpns",
10
+ "TailscaleVPN"
11
+ ]
@@ -0,0 +1,32 @@
1
+ """
2
+ Defines the base VPN service interface that all VPN implementations must follow.
3
+ """
4
+ from abc import ABC, abstractmethod
5
+
6
+ class VPNService:
7
+
8
+ @abstractmethod
9
+ def connect(self):
10
+ """Connect to the VPN."""
11
+ raise NotImplementedError("Connect method must be implemented by subclasses.")
12
+
13
+ @abstractmethod
14
+ def disconnect(self):
15
+ """Disconnect from the VPN."""
16
+ raise NotImplementedError("Disconnect method must be implemented by subclasses.")
17
+
18
+ @abstractmethod
19
+ def is_connected(self) -> bool:
20
+ """Check if the VPN is currently connected."""
21
+ raise NotImplementedError("is_connected method must be implemented by subclasses.")
22
+
23
+ @abstractmethod
24
+ def serve_https(self, port: int):
25
+ """Serve the VPN connection over HTTPS on the specified port."""
26
+ raise NotImplementedError("serve_https method must be implemented by subclasses.")
27
+
28
+ @abstractmethod
29
+ def stop_serving_https(self, port: int):
30
+ """Stop serving the VPN connection over HTTPS on the specified port."""
31
+ raise NotImplementedError("stop_serving_https method must be implemented by subclasses.")
32
+
@@ -0,0 +1,5 @@
1
+ from .tailscale import TailscaleVPN
2
+
3
+ __all__ = [
4
+ "TailscaleVPN"
5
+ ]
@@ -0,0 +1,51 @@
1
+ """
2
+ Implements the Tailscale VPN service.
3
+ """
4
+
5
+ from .. import VPNService
6
+ from ..registry import register_vpn
7
+ import subprocess
8
+ import json
9
+
10
+ @register_vpn("tailscale")
11
+ class TailscaleVPN(VPNService):
12
+ def __init__(self):
13
+ self.connected = self.is_connected()
14
+
15
+ def connect(self):
16
+ """Connect to the Tailscale VPN."""
17
+ try:
18
+ subprocess.run(["sudo", "tailscale", "up"], check=True)
19
+ except subprocess.CalledProcessError as e:
20
+ raise RuntimeError("Failed to connect to Tailscale VPN") from e
21
+ self.connected = self.is_connected()
22
+
23
+ def disconnect(self):
24
+ """Disconnect from the Tailscale VPN."""
25
+ try:
26
+ subprocess.run(["sudo", "tailscale", "down"], check=True)
27
+ except subprocess.CalledProcessError as e:
28
+ raise RuntimeError("Failed to disconnect from Tailscale VPN") from e
29
+ self.connected = self.is_connected()
30
+
31
+ def is_connected(self) -> bool:
32
+ """Check if the VPN is currently connected."""
33
+ try:
34
+ status = subprocess.run(["tailscale", "status", "--json"], capture_output=True, text=True, check=True)
35
+ return json.loads(status.stdout).get("BackendState", "") == "Running"
36
+ except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError):
37
+ return False
38
+
39
+ def serve_https(self, port: int):
40
+ """Serve the VPN connection over HTTPS on the specified port."""
41
+ try:
42
+ subprocess.run(["sudo", "tailscale", "serve", "--bg", f"--https={port}", f"http://localhost:{port}"], check=True)
43
+ except subprocess.CalledProcessError as e:
44
+ raise RuntimeError(f"Failed to serve HTTPS on port {port} via Tailscale") from e
45
+
46
+ def stop_serving_https(self, port: int):
47
+ """Stop serving the VPN connection over HTTPS on the specified port."""
48
+ try:
49
+ subprocess.run(["sudo", "tailscale", "serve", f"--https={port}", "off"], check=True)
50
+ except subprocess.CalledProcessError as e:
51
+ raise RuntimeError(f"Failed to stop serving HTTPS on port {port} via Tailscale") from e
@@ -0,0 +1,28 @@
1
+ """
2
+ This module implements a registry for VPN providers.
3
+ It allows for dynamic registration and retrieval of VPN provider classes by name.
4
+ """
5
+ from . import VPNService
6
+ from typing import Dict, Type
7
+
8
+ # Global registry for VPN providers
9
+ _VPN_REGISTRY: Dict[str, Type[VPNService]] = {}
10
+
11
+ def register_vpn(name: str):
12
+ """Decorator to register a model class with a given name."""
13
+ def decorator(cls: Type[VPNService]):
14
+ if name in _VPN_REGISTRY:
15
+ raise ValueError(f"VPN provider \"{name}\" is already registered.")
16
+ _VPN_REGISTRY[name] = cls
17
+ return cls
18
+ return decorator
19
+
20
+ def get_vpn(name: str) -> Type[VPNService]:
21
+ """Retrieves a VPN provider class by name."""
22
+ if name not in _VPN_REGISTRY:
23
+ raise ValueError(f"VPN provider \"{name}\" is not registered.")
24
+ return _VPN_REGISTRY[name]
25
+
26
+ def list_vpns() -> list[str]:
27
+ """Returns a list of registered VPN provider names."""
28
+ return list(_VPN_REGISTRY.keys())
@@ -0,0 +1,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: smanager
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.9
5
+ Requires-Dist: PyYAML>=6.0
6
+ Requires-Dist: click>=8.0
@@ -0,0 +1,35 @@
1
+ smanager/__init__.py,sha256=pR6K9zQ-1X496aTgEGgwyIq9tWTd_cmXENJlpmQVf_o,268
2
+ smanager/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ smanager/cli/config.py,sha256=CddeZD9yDMd9Ik80leSInD83R77-0nnk2SSFGj0vxyU,2544
4
+ smanager/cli/main.py,sha256=GzFKCvQ7czhGcyztLMuBBcChC-FHLDfFoBmzbxVrKu0,945
5
+ smanager/cli/commands/__init__.py,sha256=QyeykJElRQtJvQQ8iy_ih0FEpoHzeHH2NMlWVq3Z09w,357
6
+ smanager/cli/commands/all.py,sha256=IDwHFMoZA2Ku61beTczXNikVqAavNqjqv-DwAm2ybVw,952
7
+ smanager/cli/commands/config.py,sha256=U2Q78tNFgMGbjNbbpu6WqCdChruskFHMl2LOZGedluY,1811
8
+ smanager/cli/commands/dependency.py,sha256=aOOAr9duhJr5kkZVv1dCF3zhip2dOjb9RFNX1E9kO1A,770
9
+ smanager/cli/commands/group.py,sha256=833lFSyCTzlEplzVvKk2SRssLmNVGL6Fk3A6YQ-GAu0,704
10
+ smanager/cli/commands/service.py,sha256=R252wqAdUTou9NrW7yvAhvgTgq1PNKfiSlcawywJUbA,700
11
+ smanager/cli/commands/status.py,sha256=d0v0mbh9vfNDQTTzirSTp8EG8RHOSh31V6ncKs3nTU8,2912
12
+ smanager/crawler/__init__.py,sha256=6eJqh3Q-sAsCAaQVIWlmjLhGtJhrJ_VH82OdpACIr5I,79
13
+ smanager/crawler/sd_finder.py,sha256=YfDmUCbJVnB-DxTTO_fWPoT7mPR3cdC2i9jREa-gvLM,1284
14
+ smanager/datatypes/__init__.py,sha256=LA3Eqml_0-O_XBCiFw5UiHLpnnTiOn_Pyend2D58oHU,435
15
+ smanager/datatypes/config.py,sha256=hQnLWtX_AeSDy6xItKL50q7Xz_76YEP9zpPPYpZ-EW4,673
16
+ smanager/datatypes/sd_info_v2.py,sha256=w1hbIAuoGg3k_s0criTUZexDK1UNg_ObCebbk-wXEjs,4446
17
+ smanager/datatypes/service_status.py,sha256=8a24YGFFDt0UkjsMYRHq06gpr2xAxFs5rvjO4-uK0CY,278
18
+ smanager/global_manager/__init__.py,sha256=Eog1ycSHPTWMSNC4J2iSa2TofzAnUYzu-BC1yWbTyNw,153
19
+ smanager/global_manager/base_global_manager.py,sha256=zcGsAo0dFhEMykLKrS5AjPTCR2F7HHCniLjOPhXQr4w,3955
20
+ smanager/global_manager/global_manager.py,sha256=YvPmnxgA-HDYjNw4wodbYMlYhnqt2DdDLq8MgNOw2VA,4889
21
+ smanager/service_managers/__init__.py,sha256=-0dqDXBRbUk7N7NN5-Ab0MZRdcEAr0IjLA1kwKDLvBs,263
22
+ smanager/service_managers/base_manager.py,sha256=jnwFMPMVFsTSFtYIHdzr4ghnhTkpZBPB-qinaeBhLCg,2090
23
+ smanager/service_managers/base_sdv2_manager.py,sha256=uPHhsSPaYpPB86HDsFI_xJ9w75B9bC4N3i3GIOyf4aE,1606
24
+ smanager/service_managers/docker_manager.py,sha256=GmjyYV1tqJuVIB2aCZfa4OsKJlmKL96Pw8pE2sQPrf4,2561
25
+ smanager/service_managers/local_manager.py,sha256=Hkx7eFGDAMEk5DTyXlccHou7XCVqGOfC4FyVPNrJ_Bs,1753
26
+ smanager/vpn/__init__.py,sha256=81bcFe0b2s1TBprRqNakXnoLSwBY0TzNHkJNutU_hug,227
27
+ smanager/vpn/base_vpn.py,sha256=IzumtdKQmdexfSJICHZV3fTdheDc9hfkA7K9T532T5k,1169
28
+ smanager/vpn/registry.py,sha256=UvtHLGYs-XFvIaGmSdieiI5Krl__1kBpLQKlmspgVJs,967
29
+ smanager/vpn/providers/__init__.py,sha256=s3yqGMrWc4vIqmI6Q4fpaLjew1WN5ml5yfNV0KQ05rg,69
30
+ smanager/vpn/providers/tailscale.py,sha256=FZNx5V3frQoD3KnPoKQOjJf7AdP7bEXYNIu4_70slow,2126
31
+ smanager-0.1.0.dist-info/METADATA,sha256=kWigLYhl7omwfbBcKi8MPe9jAV7bMX458OMjKFVqzgY,128
32
+ smanager-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
33
+ smanager-0.1.0.dist-info/entry_points.txt,sha256=1X7ZL8LtCbTmVZsJRYKUQeo9Cfp0BJWH9olFuCFSYaU,51
34
+ smanager-0.1.0.dist-info/top_level.txt,sha256=VPRTgGpVmdkozEZNG7ZibXzDZVe8MSmUM3ZdR0N0E84,9
35
+ smanager-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ smanager = smanager.cli.main:cli
@@ -0,0 +1 @@
1
+ smanager