sandboxctl 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sandboxctl/__init__.py +3 -0
- sandboxctl/cli.py +190 -0
- sandboxctl/config.py +202 -0
- sandboxctl/credentials.py +179 -0
- sandboxctl/health.py +178 -0
- sandboxctl/models.py +72 -0
- sandboxctl/openshell.py +208 -0
- sandboxctl/profile.py +79 -0
- sandboxctl/scoped_tokens.py +145 -0
- sandboxctl-1.0.2.dist-info/METADATA +39 -0
- sandboxctl-1.0.2.dist-info/RECORD +14 -0
- sandboxctl-1.0.2.dist-info/WHEEL +4 -0
- sandboxctl-1.0.2.dist-info/entry_points.txt +2 -0
- sandboxctl-1.0.2.dist-info/licenses/LICENSE +190 -0
sandboxctl/__init__.py
ADDED
sandboxctl/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from sandboxctl.config import CONFIG_TEMPLATE, ensure_config_dir, load_config
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="sandboxctl",
|
|
12
|
+
help="OpenShell sandbox management CLI.",
|
|
13
|
+
no_args_is_help=True,
|
|
14
|
+
rich_markup_mode="rich",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
config_app = typer.Typer(help="Manage sandboxctl configuration.")
|
|
18
|
+
app.add_typer(config_app, name="config")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.callback(invoke_without_command=True)
|
|
22
|
+
def main(
|
|
23
|
+
ctx: typer.Context,
|
|
24
|
+
show_version: bool = typer.Option(False, "--version", "-V", help="Show version and exit."),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""OpenShell sandbox management CLI."""
|
|
27
|
+
if show_version:
|
|
28
|
+
from sandboxctl import __version__
|
|
29
|
+
|
|
30
|
+
typer.echo(f"sandboxctl {__version__}")
|
|
31
|
+
raise typer.Exit()
|
|
32
|
+
if ctx.invoked_subcommand is None:
|
|
33
|
+
typer.echo(ctx.get_help())
|
|
34
|
+
raise typer.Exit()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@config_app.command("init")
|
|
38
|
+
def config_init() -> None:
|
|
39
|
+
"""Create default configuration file."""
|
|
40
|
+
cfg = load_config()
|
|
41
|
+
ensure_config_dir(cfg)
|
|
42
|
+
config_file = cfg.config_dir / "config.toml"
|
|
43
|
+
if config_file.exists():
|
|
44
|
+
typer.echo(f"Config already exists: {config_file}")
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
config_file.write_text(CONFIG_TEMPLATE)
|
|
47
|
+
typer.echo(f"Created {config_file}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@config_app.command("show")
|
|
51
|
+
def config_show() -> None:
|
|
52
|
+
"""Show current configuration."""
|
|
53
|
+
cfg = load_config()
|
|
54
|
+
typer.echo(f"Config dir: {cfg.config_dir}")
|
|
55
|
+
typer.echo(f"Profiles dir: {cfg.profiles_dir}")
|
|
56
|
+
typer.echo(f"SSH key: {cfg.ssh_key}")
|
|
57
|
+
typer.echo(f"Git user: {cfg.git_user_name or '(not set)'}")
|
|
58
|
+
typer.echo(f"Git email: {cfg.git_user_email or '(not set)'}")
|
|
59
|
+
typer.echo(f"Model: {cfg.default_model}")
|
|
60
|
+
typer.echo(f"Theme: {cfg.default_theme}")
|
|
61
|
+
typer.echo(f"Vertex project:{cfg.vertex_project_id or '(not set)'}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@config_app.command("path")
|
|
65
|
+
def config_path() -> None:
|
|
66
|
+
"""Print config file path."""
|
|
67
|
+
cfg = load_config()
|
|
68
|
+
typer.echo(cfg.config_dir / "config.toml")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.command("list")
|
|
72
|
+
def list_cmd() -> None:
|
|
73
|
+
"""List profiles and running sandboxes."""
|
|
74
|
+
from sandboxctl import openshell as osh
|
|
75
|
+
from sandboxctl.profile import list_profiles
|
|
76
|
+
|
|
77
|
+
cfg = load_config()
|
|
78
|
+
profiles = list_profiles(cfg)
|
|
79
|
+
if profiles:
|
|
80
|
+
typer.echo("Profiles:")
|
|
81
|
+
for p in profiles:
|
|
82
|
+
typer.echo(f" {p}")
|
|
83
|
+
else:
|
|
84
|
+
typer.echo("No profiles found.")
|
|
85
|
+
|
|
86
|
+
typer.echo()
|
|
87
|
+
try:
|
|
88
|
+
sandboxes = osh.sandbox_list()
|
|
89
|
+
if sandboxes:
|
|
90
|
+
table = Table(title="Running Sandboxes")
|
|
91
|
+
table.add_column("Name")
|
|
92
|
+
table.add_column("Created")
|
|
93
|
+
table.add_column("Phase")
|
|
94
|
+
for sb in sandboxes:
|
|
95
|
+
table.add_row(sb["name"], sb["created"], sb["phase"])
|
|
96
|
+
from rich.console import Console
|
|
97
|
+
|
|
98
|
+
Console().print(table)
|
|
99
|
+
else:
|
|
100
|
+
typer.echo("No running sandboxes.")
|
|
101
|
+
except Exception:
|
|
102
|
+
typer.echo("Could not list sandboxes (is openshell running?).")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command()
|
|
106
|
+
def status() -> None:
|
|
107
|
+
"""Show gateway and sandbox status."""
|
|
108
|
+
from sandboxctl import openshell as osh
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
gw = osh.gateway_status()
|
|
112
|
+
table = Table(title="Gateway")
|
|
113
|
+
table.add_column("Property")
|
|
114
|
+
table.add_column("Value")
|
|
115
|
+
for k, v in gw.items():
|
|
116
|
+
table.add_row(k, v)
|
|
117
|
+
from rich.console import Console
|
|
118
|
+
|
|
119
|
+
Console().print(table)
|
|
120
|
+
except Exception:
|
|
121
|
+
typer.echo("Could not reach gateway.")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@app.command("delete")
|
|
125
|
+
def delete_cmd(name: str = typer.Argument(help="Sandbox name.")) -> None:
|
|
126
|
+
"""Delete a sandbox."""
|
|
127
|
+
from sandboxctl import openshell as osh
|
|
128
|
+
|
|
129
|
+
typer.confirm(f"Delete sandbox '{name}'?", abort=True)
|
|
130
|
+
osh.sandbox_delete(name)
|
|
131
|
+
typer.echo(f"Deleted sandbox: {name}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command()
|
|
135
|
+
def validate(name: str = typer.Argument(help="Sandbox name.")) -> None:
|
|
136
|
+
"""Run validation tests inside a sandbox."""
|
|
137
|
+
from sandboxctl import openshell as osh
|
|
138
|
+
from sandboxctl.health import diagnose
|
|
139
|
+
|
|
140
|
+
report = diagnose(name, auto_recover=True)
|
|
141
|
+
if not report.healthy:
|
|
142
|
+
typer.echo(f"Sandbox '{name}' is not healthy. Run 'sandboxctl doctor {name}' for details.")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
typer.echo(f"Running validation on sandbox: {name}\n")
|
|
146
|
+
result = osh.sandbox_exec_pipe(name, "source /sandbox/.bashrc\nvalidate.sh")
|
|
147
|
+
typer.echo(result)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@app.command("init")
|
|
151
|
+
def init_cmd(name: str = typer.Argument(help="Profile name.")) -> None:
|
|
152
|
+
"""Create a new profile skeleton."""
|
|
153
|
+
from sandboxctl.profile import init_profile
|
|
154
|
+
|
|
155
|
+
cfg = load_config()
|
|
156
|
+
try:
|
|
157
|
+
path = init_profile(name, cfg)
|
|
158
|
+
typer.echo(f"Created profile: {path}")
|
|
159
|
+
except FileExistsError as e:
|
|
160
|
+
typer.echo(str(e))
|
|
161
|
+
raise typer.Exit(1) from e
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def upgrade() -> None:
|
|
166
|
+
"""Upgrade OpenShell to latest version."""
|
|
167
|
+
import subprocess
|
|
168
|
+
|
|
169
|
+
typer.echo("Upgrading OpenShell...")
|
|
170
|
+
subprocess.run(["openshell", "upgrade"], check=False)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def doctor(
|
|
175
|
+
name: str = typer.Argument(help="Sandbox name to diagnose."),
|
|
176
|
+
no_recover: bool = typer.Option(False, "--no-recover", help="Skip auto-recovery, diagnose only."),
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Diagnose and recover sandbox issues."""
|
|
179
|
+
from sandboxctl.health import diagnose
|
|
180
|
+
|
|
181
|
+
report = diagnose(name, auto_recover=not no_recover)
|
|
182
|
+
for detail in report.details:
|
|
183
|
+
typer.echo(f" {detail}")
|
|
184
|
+
if report.healthy:
|
|
185
|
+
typer.echo(f"\n[bold green]Sandbox '{name}' is healthy.[/bold green]")
|
|
186
|
+
else:
|
|
187
|
+
typer.echo(f"\n[bold red]Sandbox '{name}' is unhealthy.[/bold red]")
|
|
188
|
+
typer.echo(f" Recovery action: {report.recovery_action}")
|
|
189
|
+
if "needs_recreate" in report.recovery_action:
|
|
190
|
+
typer.echo(" Run 'sandboxctl restart' to recreate (will lose unsaved work).")
|
sandboxctl/config.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""XDG-compliant configuration using pydantic-settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, ClassVar
|
|
9
|
+
|
|
10
|
+
from pydantic import Field, model_validator
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict, TomlConfigSettingsSource
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_config_dir() -> Path:
|
|
15
|
+
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
16
|
+
if xdg:
|
|
17
|
+
return Path(xdg) / "sandboxctl"
|
|
18
|
+
return Path.home() / ".config" / "sandboxctl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _SubConfig(BaseSettings):
|
|
22
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IdentityConfig(_SubConfig):
|
|
26
|
+
user_name: str = ""
|
|
27
|
+
user_email: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DefaultsConfig(_SubConfig):
|
|
31
|
+
model: str = "claude-sonnet-4-20250514"
|
|
32
|
+
theme: str = "dark"
|
|
33
|
+
zoom: int = -1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProvidersConfig(_SubConfig):
|
|
37
|
+
vertex_project_id: str = ""
|
|
38
|
+
vertex_region: str = "global"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class PathsConfig(_SubConfig):
|
|
42
|
+
ssh_key: Path = Field(default_factory=lambda: Path.home() / ".ssh" / "sandboxctl_ed25519")
|
|
43
|
+
ca_bundle: Path | None = None
|
|
44
|
+
|
|
45
|
+
@model_validator(mode="after")
|
|
46
|
+
def _expand_paths(self) -> PathsConfig:
|
|
47
|
+
if "~" in str(self.ssh_key):
|
|
48
|
+
object.__setattr__(self, "ssh_key", self.ssh_key.expanduser())
|
|
49
|
+
if self.ca_bundle and "~" in str(self.ca_bundle):
|
|
50
|
+
object.__setattr__(self, "ca_bundle", self.ca_bundle.expanduser())
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class KeychainConfig(_SubConfig):
|
|
55
|
+
github_service: str = "sandboxctl-github-token"
|
|
56
|
+
gitlab_service: str = "sandboxctl-gitlab-token"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SandboxctlConfig(BaseSettings):
|
|
60
|
+
model_config = SettingsConfigDict(
|
|
61
|
+
env_prefix="SANDBOXCTL_",
|
|
62
|
+
env_nested_delimiter="__",
|
|
63
|
+
extra="ignore",
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
config_dir: Path = Field(default_factory=_default_config_dir)
|
|
67
|
+
profiles_dir: Path | None = None
|
|
68
|
+
|
|
69
|
+
identity: IdentityConfig = Field(default_factory=IdentityConfig)
|
|
70
|
+
defaults: DefaultsConfig = Field(default_factory=DefaultsConfig)
|
|
71
|
+
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
|
|
72
|
+
paths: PathsConfig = Field(default_factory=PathsConfig)
|
|
73
|
+
keychain: KeychainConfig = Field(default_factory=KeychainConfig)
|
|
74
|
+
|
|
75
|
+
_config_dir_override: ClassVar[Path | None] = None
|
|
76
|
+
|
|
77
|
+
@model_validator(mode="after")
|
|
78
|
+
def _resolve_profiles_dir(self) -> SandboxctlConfig:
|
|
79
|
+
if self.profiles_dir is None:
|
|
80
|
+
object.__setattr__(self, "profiles_dir", self.config_dir / "profiles")
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def settings_customise_sources(
|
|
85
|
+
cls,
|
|
86
|
+
settings_cls: type[BaseSettings],
|
|
87
|
+
init_settings: Any,
|
|
88
|
+
env_settings: Any,
|
|
89
|
+
dotenv_settings: Any,
|
|
90
|
+
file_secret_settings: Any,
|
|
91
|
+
) -> tuple[Any, ...]:
|
|
92
|
+
toml_path = cls._resolve_toml_path()
|
|
93
|
+
sources = [init_settings, env_settings]
|
|
94
|
+
if toml_path and toml_path.is_file():
|
|
95
|
+
sources.append(TomlConfigSettingsSource(settings_cls, toml_file=toml_path))
|
|
96
|
+
return tuple(sources)
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def _resolve_toml_path(cls) -> Path | None:
|
|
100
|
+
if cls._config_dir_override:
|
|
101
|
+
return cls._config_dir_override / "config.toml"
|
|
102
|
+
return _default_config_dir() / "config.toml"
|
|
103
|
+
|
|
104
|
+
# Convenience accessors for flat access patterns used by callers
|
|
105
|
+
@property
|
|
106
|
+
def git_user_name(self) -> str:
|
|
107
|
+
return self.identity.user_name
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def git_user_email(self) -> str:
|
|
111
|
+
return self.identity.user_email
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def default_model(self) -> str:
|
|
115
|
+
return self.defaults.model
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def default_theme(self) -> str:
|
|
119
|
+
return self.defaults.theme
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def default_zoom(self) -> int:
|
|
123
|
+
return self.defaults.zoom
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def vertex_project_id(self) -> str:
|
|
127
|
+
return self.providers.vertex_project_id
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def ssh_key(self) -> Path:
|
|
131
|
+
return self.paths.ssh_key
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def ca_bundle(self) -> Path | None:
|
|
135
|
+
return self.paths.ca_bundle
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def keychain_github(self) -> str:
|
|
139
|
+
return self.keychain.github_service
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def keychain_gitlab(self) -> str:
|
|
143
|
+
return self.keychain.gitlab_service
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_config(config_dir: Path | None = None) -> SandboxctlConfig:
|
|
147
|
+
"""Load config with optional config_dir override (mainly for testing)."""
|
|
148
|
+
SandboxctlConfig._config_dir_override = config_dir
|
|
149
|
+
try:
|
|
150
|
+
kwargs: dict[str, Any] = {}
|
|
151
|
+
if config_dir:
|
|
152
|
+
kwargs["config_dir"] = config_dir
|
|
153
|
+
kwargs["profiles_dir"] = config_dir / "profiles"
|
|
154
|
+
return SandboxctlConfig(**kwargs)
|
|
155
|
+
finally:
|
|
156
|
+
SandboxctlConfig._config_dir_override = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def ensure_config_dir(config: SandboxctlConfig) -> None:
|
|
160
|
+
"""Create config and profiles directories if they don't exist."""
|
|
161
|
+
config.config_dir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
if config.profiles_dir:
|
|
163
|
+
config.profiles_dir.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
CONFIG_TEMPLATE = """\
|
|
167
|
+
# sandboxctl configuration
|
|
168
|
+
# See: https://github.com/butler54/sandboxctl
|
|
169
|
+
|
|
170
|
+
[identity]
|
|
171
|
+
# Required: your git identity for commits inside sandboxes
|
|
172
|
+
# user_name = "Your Name"
|
|
173
|
+
# user_email = "you@example.com"
|
|
174
|
+
|
|
175
|
+
[defaults]
|
|
176
|
+
# model = "claude-sonnet-4-20250514"
|
|
177
|
+
# theme = "dark"
|
|
178
|
+
# zoom = -1
|
|
179
|
+
|
|
180
|
+
[providers]
|
|
181
|
+
# vertex_project_id = ""
|
|
182
|
+
# vertex_region = "global"
|
|
183
|
+
|
|
184
|
+
[paths]
|
|
185
|
+
# ssh_key = "~/.ssh/sandboxctl_ed25519"
|
|
186
|
+
# ca_bundle = ""
|
|
187
|
+
|
|
188
|
+
[keychain]
|
|
189
|
+
# github_service = "sandboxctl-github-token"
|
|
190
|
+
# gitlab_service = "sandboxctl-gitlab-token"
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def find_vscode_bin() -> Path | None:
|
|
195
|
+
"""Find the VS Code binary, checking PATH then platform-specific locations."""
|
|
196
|
+
path = shutil.which("code")
|
|
197
|
+
if path:
|
|
198
|
+
return Path(path)
|
|
199
|
+
mac_path = Path("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code")
|
|
200
|
+
if mac_path.exists():
|
|
201
|
+
return mac_path
|
|
202
|
+
return None
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Cross-platform credential storage abstraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CredentialBackend(ABC):
|
|
13
|
+
"""Abstract credential storage backend."""
|
|
14
|
+
|
|
15
|
+
@abstractmethod
|
|
16
|
+
def get(self, service: str, account: str) -> str | None:
|
|
17
|
+
"""Retrieve a credential. Returns None if not found."""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def store(self, service: str, account: str, secret: str) -> None:
|
|
21
|
+
"""Store a credential."""
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def delete(self, service: str, account: str) -> bool:
|
|
25
|
+
"""Delete a credential. Returns True if deleted, False if not found."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
@abstractmethod
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
"""Human-readable backend name."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MacOSKeychainBackend(CredentialBackend):
|
|
34
|
+
"""macOS Keychain via the security CLI."""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def name(self) -> str:
|
|
38
|
+
return "macOS Keychain"
|
|
39
|
+
|
|
40
|
+
def get(self, service: str, account: str) -> str | None:
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
["security", "find-generic-password", "-s", service, "-a", account, "-w"],
|
|
44
|
+
capture_output=True,
|
|
45
|
+
text=True,
|
|
46
|
+
check=True,
|
|
47
|
+
)
|
|
48
|
+
return result.stdout.strip()
|
|
49
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def store(self, service: str, account: str, secret: str) -> None:
|
|
53
|
+
self.delete(service, account)
|
|
54
|
+
subprocess.run(
|
|
55
|
+
["security", "add-generic-password", "-s", service, "-a", account, "-w", secret],
|
|
56
|
+
check=True,
|
|
57
|
+
capture_output=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def delete(self, service: str, account: str) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["security", "delete-generic-password", "-s", service, "-a", account],
|
|
64
|
+
check=True,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
)
|
|
67
|
+
return True
|
|
68
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class LinuxSecretToolBackend(CredentialBackend):
|
|
73
|
+
"""Linux secret-tool (libsecret) backend."""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def name(self) -> str:
|
|
77
|
+
return "secret-tool (libsecret)"
|
|
78
|
+
|
|
79
|
+
def get(self, service: str, account: str) -> str | None:
|
|
80
|
+
try:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["secret-tool", "lookup", "service", service, "account", account],
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
check=True,
|
|
86
|
+
)
|
|
87
|
+
val = result.stdout.strip()
|
|
88
|
+
return val if val else None
|
|
89
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def store(self, service: str, account: str, secret: str) -> None:
|
|
93
|
+
subprocess.run(
|
|
94
|
+
[
|
|
95
|
+
"secret-tool",
|
|
96
|
+
"store",
|
|
97
|
+
"--label",
|
|
98
|
+
f"{service}/{account}",
|
|
99
|
+
"service",
|
|
100
|
+
service,
|
|
101
|
+
"account",
|
|
102
|
+
account,
|
|
103
|
+
],
|
|
104
|
+
input=secret,
|
|
105
|
+
text=True,
|
|
106
|
+
check=True,
|
|
107
|
+
capture_output=True,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def delete(self, service: str, account: str) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
subprocess.run(
|
|
113
|
+
["secret-tool", "clear", "service", service, "account", account],
|
|
114
|
+
check=True,
|
|
115
|
+
capture_output=True,
|
|
116
|
+
)
|
|
117
|
+
return True
|
|
118
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EnvVarBackend(CredentialBackend):
|
|
123
|
+
"""Environment variable fallback backend."""
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def name(self) -> str:
|
|
127
|
+
return "environment variables"
|
|
128
|
+
|
|
129
|
+
def _env_key(self, service: str, _account: str) -> str:
|
|
130
|
+
return service.upper().replace("-", "_")
|
|
131
|
+
|
|
132
|
+
def get(self, service: str, account: str) -> str | None:
|
|
133
|
+
return os.environ.get(self._env_key(service, account))
|
|
134
|
+
|
|
135
|
+
def store(self, service: str, account: str, secret: str) -> None:
|
|
136
|
+
msg = f"Cannot persist credentials via env vars. Set {self._env_key(service, account)} in your shell profile."
|
|
137
|
+
raise RuntimeError(msg)
|
|
138
|
+
|
|
139
|
+
def delete(self, service: str, account: str) -> bool:
|
|
140
|
+
key = self._env_key(service, account)
|
|
141
|
+
if key in os.environ:
|
|
142
|
+
del os.environ[key]
|
|
143
|
+
return True
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _detect_backend() -> CredentialBackend:
|
|
148
|
+
"""Auto-detect the best available credential backend."""
|
|
149
|
+
if sys.platform == "darwin" and shutil.which("security"):
|
|
150
|
+
return MacOSKeychainBackend()
|
|
151
|
+
if sys.platform == "linux" and shutil.which("secret-tool"):
|
|
152
|
+
return LinuxSecretToolBackend()
|
|
153
|
+
return EnvVarBackend()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_backend: CredentialBackend | None = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_backend() -> CredentialBackend:
|
|
160
|
+
"""Get the credential backend (cached)."""
|
|
161
|
+
global _backend # noqa: PLW0603
|
|
162
|
+
if _backend is None:
|
|
163
|
+
_backend = _detect_backend()
|
|
164
|
+
return _backend
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_credential(service: str, account: str) -> str | None:
|
|
168
|
+
"""Retrieve a credential using the detected backend."""
|
|
169
|
+
return get_backend().get(service, account)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def store_credential(service: str, account: str, secret: str) -> None:
|
|
173
|
+
"""Store a credential using the detected backend."""
|
|
174
|
+
get_backend().store(service, account, secret)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def delete_credential(service: str, account: str) -> bool:
|
|
178
|
+
"""Delete a credential using the detected backend."""
|
|
179
|
+
return get_backend().delete(service, account)
|