envpass-cli 0.1.3__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.
envpass/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from ._version import __version__
4
+ from .client import Envpass, EnvpassError, ExportFormat, Me, ResolvedEntry, Source, load_env
5
+ from .config import DEFAULT_URL, ProjectLink
6
+ from .mask import mask_text
7
+
8
+ __all__ = [
9
+ "DEFAULT_URL",
10
+ "Envpass",
11
+ "EnvpassError",
12
+ "ExportFormat",
13
+ "Me",
14
+ "ProjectLink",
15
+ "ResolvedEntry",
16
+ "Source",
17
+ "__version__",
18
+ "load_env",
19
+ "mask_text",
20
+ ]
envpass/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
envpass/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("envpass-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0"
envpass/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import threading
11
+ from typing import IO, Any
12
+
13
+ from ._version import __version__
14
+ from .client import Envpass, EnvpassError
15
+ from .config import (
16
+ ProjectLink,
17
+ base_url,
18
+ forget_token,
19
+ resolve_scope,
20
+ save_token,
21
+ stored_token,
22
+ write_project_link,
23
+ )
24
+ from .device import device_login, open_browser
25
+ from .mask import Masker
26
+
27
+ NOT_SIGNED_IN = "Not signed in. Run `envpass login` or set ENVPASS_TOKEN."
28
+
29
+
30
+ def _fail(message: str, code: int = 1) -> int:
31
+ print(f"envpass: {message}", file=sys.stderr)
32
+ return code
33
+
34
+
35
+ def _parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(
37
+ prog="envpass",
38
+ description="Encrypted environment variables for every project, service and environment.",
39
+ )
40
+ parser.add_argument("--version", action="version", version=__version__)
41
+ parser.add_argument(
42
+ "--url", help="envpass server (default https://envpass.dev, or ENVPASS_URL)"
43
+ )
44
+ scope = argparse.ArgumentParser(add_help=False)
45
+ scope.add_argument("-w", "--workspace", help="workspace slug")
46
+ scope.add_argument("-p", "--project", help="project slug")
47
+ scope.add_argument("-e", "--env", dest="environment", help="environment slug, e.g. dev or prod")
48
+ scope.add_argument("-s", "--service", help='service slug, or "shared"')
49
+ scope.add_argument("--url", dest="url_sub", help=argparse.SUPPRESS)
50
+
51
+ commands = parser.add_subparsers(dest="command", metavar="command")
52
+ login = commands.add_parser("login", help="sign in through your browser", parents=[scope])
53
+ login.add_argument(
54
+ "--no-browser", action="store_true", help="print the link instead of opening a browser"
55
+ )
56
+ commands.add_parser("logout", help="forget the saved login", parents=[scope])
57
+ commands.add_parser("whoami", help="show who the current token belongs to", parents=[scope])
58
+ commands.add_parser("link", help="save defaults to .envpass.json", parents=[scope])
59
+ pull = commands.add_parser("pull", help="write resolved values to a file", parents=[scope])
60
+ pull.add_argument("-o", "--out", default=".env", help='output file, or "-" for stdout')
61
+ pull.add_argument("-f", "--format", default="dotenv", choices=["dotenv", "shell", "json"])
62
+ run = commands.add_parser("run", help="run a command with the values injected", parents=[scope])
63
+ run.add_argument("--no-mask", action="store_true", help="show secret values in the output")
64
+ run.add_argument("cmd", nargs=argparse.REMAINDER, help="-- command [args...]")
65
+ return parser
66
+
67
+
68
+ def _pump(source: IO[bytes], target: Any, masker: Masker) -> None:
69
+ while True:
70
+ chunk = os.read(source.fileno(), 65536)
71
+ if not chunk:
72
+ break
73
+ text = masker.feed(chunk.decode("utf-8", "replace"))
74
+ if text:
75
+ target.write(text)
76
+ target.flush()
77
+ rest = masker.flush()
78
+ if rest:
79
+ target.write(rest)
80
+ target.flush()
81
+
82
+
83
+ def _run(client: Envpass, scope: ProjectLink, cmd: list[str], mask: bool) -> int:
84
+ if cmd and cmd[0] == "--":
85
+ cmd = cmd[1:]
86
+ if not cmd:
87
+ return _fail("Usage: envpass run [-e env] -- <command> [args...]")
88
+ entries = client.resolve(scope)
89
+ env = {**os.environ, **{entry["key"]: entry["value"] for entry in entries}}
90
+ try:
91
+ if not mask:
92
+ return subprocess.call(cmd, env=env)
93
+ process = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
94
+ except FileNotFoundError:
95
+ return _fail(f"Could not start {cmd[0]}: command not found", 127)
96
+ secrets = [entry["value"] for entry in entries]
97
+ threads = [
98
+ threading.Thread(target=_pump, args=(process.stdout, sys.stdout, Masker(secrets))),
99
+ threading.Thread(target=_pump, args=(process.stderr, sys.stderr, Masker(secrets))),
100
+ ]
101
+ for thread in threads:
102
+ thread.start()
103
+
104
+ def forward(signum: int, _frame: object) -> None:
105
+ process.send_signal(signum)
106
+
107
+ signal.signal(signal.SIGINT, forward)
108
+ signal.signal(signal.SIGTERM, forward)
109
+ code = process.wait()
110
+ for thread in threads:
111
+ thread.join()
112
+ return code if code >= 0 else 128 - code
113
+
114
+
115
+ def main(argv: list[str] | None = None) -> int:
116
+ args = _parser().parse_args(argv)
117
+ if not args.command:
118
+ _parser().print_help()
119
+ return 0
120
+ url = base_url(getattr(args, "url_sub", None) or args.url)
121
+ flags = ProjectLink(
122
+ workspace=args.workspace,
123
+ project=args.project,
124
+ environment=args.environment,
125
+ service=args.service,
126
+ )
127
+ try:
128
+ if args.command == "login":
129
+
130
+ def show(code: dict[str, Any]) -> None:
131
+ link = code.get("verification_uri_complete") or code["verification_uri"]
132
+ message = f"\nOpen {link}\nand confirm the code: {code['user_code']}\n"
133
+ print(f"{message}\nWaiting for approval…", file=sys.stderr)
134
+ if not args.no_browser:
135
+ open_browser(link)
136
+
137
+ token = device_login(url, show)
138
+ path = save_token(url, token)
139
+ me = Envpass(url, token).me()
140
+ print(f"Signed in. Token saved to {path}", file=sys.stderr)
141
+ if me["workspaces"]:
142
+ slugs = ", ".join(w["slug"] for w in me["workspaces"])
143
+ print(f"Workspaces: {slugs}", file=sys.stderr)
144
+ return 0
145
+
146
+ if args.command == "logout":
147
+ saved = stored_token(url)
148
+ if saved:
149
+ with contextlib.suppress(EnvpassError):
150
+ Envpass(url, saved).request("POST", "/api/auth/sign-out", body={})
151
+ removed = forget_token(url)
152
+ print(
153
+ f"Signed out of {url}" if removed else f"No saved login for {url}", file=sys.stderr
154
+ )
155
+ return 0
156
+
157
+ if args.command == "link":
158
+ scope = resolve_scope(flags)
159
+ if not scope.get("workspace") or not scope.get("project"):
160
+ return _fail("Pass --workspace and --project.")
161
+ path = write_project_link(scope)
162
+ print(f"Saved {path}. It holds no secrets and is safe to commit.", file=sys.stderr)
163
+ return 0
164
+
165
+ client = Envpass(url)
166
+ if not client.has_token:
167
+ return _fail(NOT_SIGNED_IN)
168
+
169
+ if args.command == "whoami":
170
+ me = client.me()
171
+ token_info = me["token"]
172
+ if token_info:
173
+ print(
174
+ f"Service token {token_info['id']} ({token_info['access']}) "
175
+ f"for project {token_info['projectId']}"
176
+ )
177
+ else:
178
+ print(f"User {me['userId']}")
179
+ for ws in me["workspaces"]:
180
+ print(f" {ws['slug']}{' (owner)' if ws['isOwner'] else ''}")
181
+ return 0
182
+
183
+ if args.command == "pull":
184
+ if args.format == "json":
185
+ values = {e["key"]: e["value"] for e in client.resolve(flags)}
186
+ text = json.dumps(values, indent=2) + "\n"
187
+ else:
188
+ text = client.resolve_text(flags, args.format)
189
+ if args.out == "-":
190
+ sys.stdout.write(text)
191
+ return 0
192
+ fd = os.open(args.out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
193
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
194
+ handle.write(text)
195
+ count = sum(1 for line in text.splitlines() if "=" in line or ":" in line)
196
+ print(f"Wrote {args.out} ({count} values). Keep it out of git.", file=sys.stderr)
197
+ return 0
198
+
199
+ if args.command == "run":
200
+ return _run(client, flags, list(args.cmd), not args.no_mask)
201
+
202
+ return _fail(f'Unknown command "{args.command}". Run `envpass --help`.')
203
+ except EnvpassError as error:
204
+ return _fail(f"{error}{f' ({error.code})' if error.code else ''}")
205
+ except (RuntimeError, ValueError) as error:
206
+ return _fail(str(error))
207
+ except KeyboardInterrupt:
208
+ return 130
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
envpass/client.py ADDED
@@ -0,0 +1,156 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import urllib.error
6
+ import urllib.parse
7
+ import urllib.request
8
+ from typing import Any, Literal, TypedDict
9
+
10
+ from ._version import __version__
11
+ from .config import ProjectLink, base_url, resolve_scope, resolve_token
12
+
13
+ Source = Literal["shared", "service", "personal"]
14
+ ExportFormat = Literal["dotenv", "json", "shell"]
15
+
16
+
17
+ class ResolvedEntry(TypedDict):
18
+ key: str
19
+ value: str
20
+ source: Source
21
+
22
+
23
+ class Workspace(TypedDict):
24
+ id: str
25
+ name: str
26
+ slug: str
27
+ isOwner: bool
28
+
29
+
30
+ class TokenInfo(TypedDict):
31
+ id: str
32
+ workspaceId: str
33
+ projectId: str
34
+ serviceId: str | None
35
+ access: Literal["read", "write"]
36
+
37
+
38
+ class Me(TypedDict):
39
+ actor: Literal["user", "token"]
40
+ userId: str
41
+ token: TokenInfo | None
42
+ workspaces: list[Workspace]
43
+
44
+
45
+ class EnvpassError(Exception):
46
+ def __init__(self, message: str, status: int, code: str | None = None) -> None:
47
+ super().__init__(message)
48
+ self.status = status
49
+ self.code = code
50
+
51
+
52
+ class Envpass:
53
+ def __init__(
54
+ self,
55
+ base_url_: str | None = None,
56
+ token: str | None = None,
57
+ user_agent: str | None = None,
58
+ timeout: float = 30.0,
59
+ ) -> None:
60
+ self.url = base_url(base_url_)
61
+ self.token = resolve_token(self.url, token)
62
+ self.timeout = timeout
63
+ self.user_agent = user_agent or f"envpass-python/{__version__}"
64
+
65
+ @property
66
+ def has_token(self) -> bool:
67
+ return bool(self.token)
68
+
69
+ def request(
70
+ self,
71
+ method: str,
72
+ path: str,
73
+ query: dict[str, str | None] | None = None,
74
+ body: Any = None,
75
+ raw: bool = False,
76
+ ) -> Any:
77
+ url = f"{self.url}{path}"
78
+ params = {k: v for k, v in (query or {}).items() if v}
79
+ if params:
80
+ url = f"{url}?{urllib.parse.urlencode(params)}"
81
+ headers = {"user-agent": self.user_agent, "accept": "application/json"}
82
+ if self.token:
83
+ headers["authorization"] = f"Bearer {self.token}"
84
+ data = None
85
+ if body is not None:
86
+ data = json.dumps(body).encode("utf-8")
87
+ headers["content-type"] = "application/json"
88
+ request = urllib.request.Request(url, data=data, method=method, headers=headers)
89
+ try:
90
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
91
+ text = response.read().decode("utf-8")
92
+ except urllib.error.HTTPError as error:
93
+ payload = error.read().decode("utf-8", "replace")
94
+ try:
95
+ detail = json.loads(payload).get("error", {})
96
+ except ValueError:
97
+ detail = {}
98
+ raise EnvpassError(
99
+ detail.get("message") or f"Request failed with status {error.code}",
100
+ error.code,
101
+ detail.get("code"),
102
+ ) from None
103
+ except urllib.error.URLError as error:
104
+ raise EnvpassError(f"Could not reach {self.url}: {error.reason}", 0) from None
105
+ return text if raw else json.loads(text)
106
+
107
+ def me(self) -> Me:
108
+ result: Me = self.request("GET", "/api/v1/me")
109
+ return result
110
+
111
+ def _scope_query(self, scope: ProjectLink | None) -> dict[str, str | None]:
112
+ resolved = resolve_scope(scope or {})
113
+ if not resolved.get("environment"):
114
+ raise EnvpassError(
115
+ "No environment. Pass --env, set ENVPASS_ENV, or run `envpass link`.",
116
+ 400,
117
+ "invalid",
118
+ )
119
+ return {
120
+ "workspace": resolved.get("workspace"),
121
+ "project": resolved.get("project"),
122
+ "environment": resolved.get("environment"),
123
+ "service": resolved.get("service"),
124
+ }
125
+
126
+ def resolve(self, scope: ProjectLink | None = None) -> list[ResolvedEntry]:
127
+ query = {**self._scope_query(scope), "format": "json"}
128
+ result: list[ResolvedEntry] = self.request("GET", "/api/v1/resolve", query)["entries"]
129
+ return result
130
+
131
+ def resolve_text(
132
+ self, scope: ProjectLink | None = None, fmt: Literal["dotenv", "shell"] = "dotenv"
133
+ ) -> str:
134
+ query = {**self._scope_query(scope), "format": fmt}
135
+ text: str = self.request("GET", "/api/v1/resolve", query, raw=True)
136
+ return text
137
+
138
+
139
+ def load_env(
140
+ environment: str | None = None,
141
+ *,
142
+ workspace: str | None = None,
143
+ project: str | None = None,
144
+ service: str | None = None,
145
+ token: str | None = None,
146
+ base_url_: str | None = None,
147
+ override: bool = False,
148
+ ) -> dict[str, str]:
149
+ client = Envpass(base_url_, token)
150
+ entries = client.resolve(
151
+ ProjectLink(workspace=workspace, project=project, environment=environment, service=service)
152
+ )
153
+ for entry in entries:
154
+ if override or entry["key"] not in os.environ:
155
+ os.environ[entry["key"]] = entry["value"]
156
+ return {entry["key"]: entry["value"] for entry in entries}
envpass/config.py ADDED
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import TypedDict
7
+
8
+ DEFAULT_URL = "https://envpass.dev"
9
+ PROJECT_FILE = ".envpass.json"
10
+
11
+
12
+ class ProjectLink(TypedDict, total=False):
13
+ workspace: str | None
14
+ project: str | None
15
+ environment: str | None
16
+ service: str | None
17
+
18
+
19
+ def base_url(explicit: str | None = None) -> str:
20
+ return (explicit or os.environ.get("ENVPASS_URL") or DEFAULT_URL).rstrip("/")
21
+
22
+
23
+ def _credentials_path() -> Path:
24
+ root = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
25
+ return Path(root) / "envpass" / "credentials.json"
26
+
27
+
28
+ def _read_credentials() -> dict[str, dict[str, str]]:
29
+ path = _credentials_path()
30
+ if not path.exists():
31
+ return {}
32
+ try:
33
+ data = json.loads(path.read_text("utf-8"))
34
+ except (OSError, ValueError):
35
+ return {}
36
+ return data if isinstance(data, dict) else {}
37
+
38
+
39
+ def _write_credentials(credentials: dict[str, dict[str, str]]) -> Path:
40
+ path = _credentials_path()
41
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
42
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
43
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
44
+ handle.write(json.dumps(credentials, indent=2) + "\n")
45
+ os.chmod(path, 0o600)
46
+ return path
47
+
48
+
49
+ def save_token(url: str, token: str) -> Path:
50
+ from datetime import datetime, timezone
51
+
52
+ credentials = _read_credentials()
53
+ credentials[url] = {"token": token, "savedAt": datetime.now(timezone.utc).isoformat()}
54
+ return _write_credentials(credentials)
55
+
56
+
57
+ def forget_token(url: str) -> bool:
58
+ credentials = _read_credentials()
59
+ if url not in credentials:
60
+ return False
61
+ del credentials[url]
62
+ if credentials:
63
+ _write_credentials(credentials)
64
+ else:
65
+ _credentials_path().unlink(missing_ok=True)
66
+ return True
67
+
68
+
69
+ def stored_token(url: str) -> str | None:
70
+ entry = _read_credentials().get(url)
71
+ return entry.get("token") if isinstance(entry, dict) else None
72
+
73
+
74
+ def resolve_token(url: str, explicit: str | None = None) -> str | None:
75
+ return explicit or os.environ.get("ENVPASS_TOKEN") or stored_token(url)
76
+
77
+
78
+ def find_project_file(start: Path | None = None) -> Path | None:
79
+ directory = (start or Path.cwd()).resolve()
80
+ for candidate_dir in [directory, *directory.parents]:
81
+ candidate = candidate_dir / PROJECT_FILE
82
+ if candidate.exists():
83
+ return candidate
84
+ return None
85
+
86
+
87
+ def read_project_link(start: Path | None = None) -> ProjectLink:
88
+ path = find_project_file(start)
89
+ if path is None:
90
+ return {}
91
+ try:
92
+ data = json.loads(path.read_text("utf-8"))
93
+ except ValueError as error:
94
+ raise ValueError(f"{path} is not valid JSON") from error
95
+ if not isinstance(data, dict):
96
+ return {}
97
+ link: ProjectLink = {}
98
+ for key in ("workspace", "project", "environment", "service"):
99
+ value = data.get(key)
100
+ if isinstance(value, str):
101
+ link[key] = value # type: ignore[literal-required,unused-ignore]
102
+ return link
103
+
104
+
105
+ def write_project_link(link: ProjectLink, directory: Path | None = None) -> Path:
106
+ path = (directory or Path.cwd()) / PROJECT_FILE
107
+ clean = {k: v for k, v in link.items() if v}
108
+ path.write_text(json.dumps(clean, indent=2) + "\n", "utf-8")
109
+ return path
110
+
111
+
112
+ def resolve_scope(flags: ProjectLink, start: Path | None = None) -> ProjectLink:
113
+ file = read_project_link(start)
114
+ env = os.environ
115
+ return ProjectLink(
116
+ workspace=flags.get("workspace") or env.get("ENVPASS_WORKSPACE") or file.get("workspace"),
117
+ project=flags.get("project") or env.get("ENVPASS_PROJECT") or file.get("project"),
118
+ environment=flags.get("environment") or env.get("ENVPASS_ENV") or file.get("environment"),
119
+ service=flags.get("service") or env.get("ENVPASS_SERVICE") or file.get("service"),
120
+ )
envpass/device.py ADDED
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+ import webbrowser
8
+ from typing import Any, Callable
9
+
10
+ CLI_CLIENT_ID = "envpass-cli"
11
+
12
+
13
+ def _post(url: str, body: dict[str, Any]) -> tuple[int, dict[str, Any]]:
14
+ request = urllib.request.Request(
15
+ url,
16
+ data=json.dumps(body).encode("utf-8"),
17
+ method="POST",
18
+ headers={"content-type": "application/json", "accept": "application/json"},
19
+ )
20
+ try:
21
+ with urllib.request.urlopen(request, timeout=30) as response:
22
+ return response.status, json.loads(response.read().decode("utf-8"))
23
+ except urllib.error.HTTPError as error:
24
+ try:
25
+ return error.code, json.loads(error.read().decode("utf-8"))
26
+ except ValueError:
27
+ return error.code, {}
28
+
29
+
30
+ def open_browser(url: str) -> None:
31
+ try:
32
+ webbrowser.open(url)
33
+ except Exception:
34
+ return
35
+
36
+
37
+ def device_login(
38
+ url: str,
39
+ on_code: Callable[[dict[str, Any]], None],
40
+ post: Callable[[str, dict[str, Any]], tuple[int, dict[str, Any]]] = _post,
41
+ sleep: Callable[[float], None] = time.sleep,
42
+ ) -> str:
43
+ status, code = post(f"{url}/api/auth/device/code", {"client_id": CLI_CLIENT_ID})
44
+ if status >= 400 or "device_code" not in code:
45
+ raise RuntimeError(f"Could not start login ({status})")
46
+ on_code(code)
47
+ interval = max(1, int(code.get("interval", 5)))
48
+ deadline = time.monotonic() + int(code.get("expires_in", 900))
49
+ while time.monotonic() < deadline:
50
+ sleep(interval)
51
+ _, body = post(
52
+ f"{url}/api/auth/device/token",
53
+ {
54
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
55
+ "device_code": code["device_code"],
56
+ "client_id": CLI_CLIENT_ID,
57
+ },
58
+ )
59
+ token = body.get("access_token")
60
+ if isinstance(token, str):
61
+ return token
62
+ error = body.get("error")
63
+ if error == "authorization_pending":
64
+ continue
65
+ if error == "slow_down":
66
+ interval += 5
67
+ continue
68
+ if error == "access_denied":
69
+ raise RuntimeError("Login was denied in the browser.")
70
+ if error == "expired_token":
71
+ break
72
+ raise RuntimeError(str(body.get("error_description") or error or "Login failed"))
73
+ raise RuntimeError("The login code expired. Run `envpass login` again.")
envpass/mask.py ADDED
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ MASK = "***"
6
+ MIN_LENGTH = 4
7
+
8
+
9
+ def _secrets(values: Iterable[str]) -> list[str]:
10
+ return sorted({v for v in values if len(v) >= MIN_LENGTH}, key=len, reverse=True)
11
+
12
+
13
+ def mask_text(text: str, values: Iterable[str]) -> str:
14
+ for secret in _secrets(values):
15
+ text = text.replace(secret, MASK)
16
+ return text
17
+
18
+
19
+ def partial_suffix(text: str, secrets: list[str]) -> int:
20
+ longest = 0
21
+ for secret in secrets:
22
+ for k in range(min(len(secret) - 1, len(text)), longest, -1):
23
+ if text.endswith(secret[:k]):
24
+ longest = k
25
+ break
26
+ return longest
27
+
28
+
29
+ class Masker:
30
+ def __init__(self, values: Iterable[str]) -> None:
31
+ self.secrets = _secrets(values)
32
+ self.pending = ""
33
+
34
+ def feed(self, chunk: str) -> str:
35
+ self.pending = mask_text(self.pending + chunk, self.secrets)
36
+ keep = partial_suffix(self.pending, self.secrets)
37
+ emit = self.pending[: len(self.pending) - keep]
38
+ self.pending = self.pending[len(emit) :]
39
+ return emit
40
+
41
+ def flush(self) -> str:
42
+ rest, self.pending = self.pending, ""
43
+ return rest
envpass/py.typed ADDED
File without changes
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.4
2
+ Name: envpass-cli
3
+ Version: 0.1.3
4
+ Summary: Client and CLI for envpass: encrypted environment variables for every project, service and environment.
5
+ Keywords: env,dotenv,secrets,environment-variables,cli,envpass
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Environment :: Console
9
+ Classifier: Topic :: Security
10
+ Classifier: Topic :: Software Development :: Build Tools
11
+ Requires-Python: >=3.9
12
+ Project-URL: Homepage, https://envpass.dev
13
+ Project-URL: Documentation, https://envpass.dev/docs
14
+ Description-Content-Type: text/markdown
15
+
16
+ # envpass-cli
17
+
18
+ Python client and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project, service and environment. No dependencies.
19
+
20
+ ## CLI
21
+
22
+ ```bash
23
+ uvx envpass-cli login # sign in through your browser
24
+ uvx envpass-cli link -w acme -p shop -e dev # save defaults to .envpass.json (no secrets inside)
25
+ uvx envpass-cli pull # write .env
26
+ uvx envpass-cli pull -e prod -o - # print to stdout
27
+ uvx envpass-cli run -e dev -- python app.py # run with values injected, masked in output
28
+ ```
29
+
30
+ `pipx run envpass-cli …` and `pip install envpass-cli` work too.
31
+
32
+ In CI, use a service token instead of `login`:
33
+
34
+ ```bash
35
+ ENVPASS_TOKEN=ep_live_… uvx envpass-cli pull -e prod
36
+ ```
37
+
38
+ Options can also come from `ENVPASS_WORKSPACE`, `ENVPASS_PROJECT`, `ENVPASS_ENV`, `ENVPASS_SERVICE` and `ENVPASS_URL`.
39
+
40
+ ## Library
41
+
42
+ ```python
43
+ from envpass import Envpass, load_env
44
+
45
+ load_env("prod")
46
+
47
+ client = Envpass(token="ep_live_…")
48
+ entries = client.resolve({"environment": "prod", "service": "api"})
49
+ workspaces = client.request("GET", "/api/v1/workspaces")
50
+ ```
@@ -0,0 +1,13 @@
1
+ envpass/__init__.py,sha256=wHHAaNKDy07Gl8ZO-IuRe0J457bAXzc8BiiwlUxQ_cI,441
2
+ envpass/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
3
+ envpass/_version.py,sha256=BKS8g-HMyhNMGsNB1kT3WJhBZRMqe6Emds9ykOVrnaQ,199
4
+ envpass/cli.py,sha256=lll6PAX1F6y-udLUDlTeHdjTbnDfhOIztzf2dISTb78,8178
5
+ envpass/client.py,sha256=3-OCxLawD-3wu3jQOgT5VzE_f5hGaoL9X2YskHV5pW0,4949
6
+ envpass/config.py,sha256=GLfppVv2Cec50jc55nNrppFmtF5YUhHlmK7-paiw4Pc,3897
7
+ envpass/device.py,sha256=3hzCTV7VT8D58Qazr7AW18PMQfT4Sg-NQhK_6e2JT2E,2439
8
+ envpass/mask.py,sha256=GytVPU5hES5VK93rroAj6d6Bs5sNiWdCahqyBS9zKCw,1200
9
+ envpass/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ envpass_cli-0.1.3.dist-info/WHEEL,sha256=3OmCxFA0ffBIsX4x0o8CdfSvZAOvO3mzEG8U_z5TvV4,80
11
+ envpass_cli-0.1.3.dist-info/entry_points.txt,sha256=I2h-qx76ZSZ7Bx18thiuQrpnuhp8czjXZCosUMNmr3U,77
12
+ envpass_cli-0.1.3.dist-info/METADATA,sha256=yo_uc4lAaN1gjbewJqDY0OJgek4LsRysYxBIVCGCZDM,1713
13
+ envpass_cli-0.1.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.8
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ envpass = envpass.cli:main
3
+ envpass-cli = envpass.cli:main
4
+