envpass-cli 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- envpass_cli-0.1.3/PKG-INFO +50 -0
- envpass_cli-0.1.3/README.md +35 -0
- envpass_cli-0.1.3/pyproject.toml +63 -0
- envpass_cli-0.1.3/pyproject.toml.orig +45 -0
- envpass_cli-0.1.3/src/envpass/__init__.py +20 -0
- envpass_cli-0.1.3/src/envpass/__main__.py +3 -0
- envpass_cli-0.1.3/src/envpass/_version.py +8 -0
- envpass_cli-0.1.3/src/envpass/cli.py +212 -0
- envpass_cli-0.1.3/src/envpass/client.py +156 -0
- envpass_cli-0.1.3/src/envpass/config.py +120 -0
- envpass_cli-0.1.3/src/envpass/device.py +73 -0
- envpass_cli-0.1.3/src/envpass/mask.py +43 -0
- envpass_cli-0.1.3/src/envpass/py.typed +0 -0
|
@@ -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,35 @@
|
|
|
1
|
+
# envpass-cli
|
|
2
|
+
|
|
3
|
+
Python client and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project, service and environment. No dependencies.
|
|
4
|
+
|
|
5
|
+
## CLI
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uvx envpass-cli login # sign in through your browser
|
|
9
|
+
uvx envpass-cli link -w acme -p shop -e dev # save defaults to .envpass.json (no secrets inside)
|
|
10
|
+
uvx envpass-cli pull # write .env
|
|
11
|
+
uvx envpass-cli pull -e prod -o - # print to stdout
|
|
12
|
+
uvx envpass-cli run -e dev -- python app.py # run with values injected, masked in output
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`pipx run envpass-cli …` and `pip install envpass-cli` work too.
|
|
16
|
+
|
|
17
|
+
In CI, use a service token instead of `login`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
ENVPASS_TOKEN=ep_live_… uvx envpass-cli pull -e prod
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Options can also come from `ENVPASS_WORKSPACE`, `ENVPASS_PROJECT`, `ENVPASS_ENV`, `ENVPASS_SERVICE` and `ENVPASS_URL`.
|
|
24
|
+
|
|
25
|
+
## Library
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from envpass import Envpass, load_env
|
|
29
|
+
|
|
30
|
+
load_env("prod")
|
|
31
|
+
|
|
32
|
+
client = Envpass(token="ep_live_…")
|
|
33
|
+
entries = client.resolve({"environment": "prod", "service": "api"})
|
|
34
|
+
workspaces = client.request("GET", "/api/v1/workspaces")
|
|
35
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "envpass-cli"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "Client and CLI for envpass: encrypted environment variables for every project, service and environment."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = []
|
|
9
|
+
keywords = [
|
|
10
|
+
"env",
|
|
11
|
+
"dotenv",
|
|
12
|
+
"secrets",
|
|
13
|
+
"environment-variables",
|
|
14
|
+
"cli",
|
|
15
|
+
"envpass",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Environment :: Console",
|
|
20
|
+
"Topic :: Security",
|
|
21
|
+
"Topic :: Software Development :: Build Tools",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://envpass.dev"
|
|
26
|
+
Documentation = "https://envpass.dev/docs"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
envpass = "envpass.cli:main"
|
|
30
|
+
envpass-cli = "envpass.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.uv.build-backend]
|
|
33
|
+
module-name = "envpass"
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py39"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = [
|
|
41
|
+
"E",
|
|
42
|
+
"F",
|
|
43
|
+
"I",
|
|
44
|
+
"UP",
|
|
45
|
+
"B",
|
|
46
|
+
"SIM",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[tool.mypy]
|
|
50
|
+
strict = true
|
|
51
|
+
python_version = "3.10"
|
|
52
|
+
files = ["src"]
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["uv_build>=0.9,<0.14"]
|
|
56
|
+
build-backend = "uv_build"
|
|
57
|
+
|
|
58
|
+
[dependency-groups]
|
|
59
|
+
dev = [
|
|
60
|
+
"pytest>=8",
|
|
61
|
+
"ruff>=0.8",
|
|
62
|
+
"mypy>=1.13",
|
|
63
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "envpass-cli"
|
|
3
|
+
version = "0.1.3"
|
|
4
|
+
description = "Client and CLI for envpass: encrypted environment variables for every project, service and environment."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = []
|
|
9
|
+
keywords = ["env", "dotenv", "secrets", "environment-variables", "cli", "envpass"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Programming Language :: Python :: 3",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Topic :: Security",
|
|
14
|
+
"Topic :: Software Development :: Build Tools",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Homepage = "https://envpass.dev"
|
|
19
|
+
Documentation = "https://envpass.dev/docs"
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
envpass = "envpass.cli:main"
|
|
23
|
+
envpass-cli = "envpass.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.uv.build-backend]
|
|
26
|
+
module-name = "envpass"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["uv_build>=0.9,<0.14"]
|
|
30
|
+
build-backend = "uv_build"
|
|
31
|
+
|
|
32
|
+
[dependency-groups]
|
|
33
|
+
dev = ["pytest>=8", "ruff>=0.8", "mypy>=1.13"]
|
|
34
|
+
|
|
35
|
+
[tool.ruff]
|
|
36
|
+
line-length = 100
|
|
37
|
+
target-version = "py39"
|
|
38
|
+
|
|
39
|
+
[tool.ruff.lint]
|
|
40
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
41
|
+
|
|
42
|
+
[tool.mypy]
|
|
43
|
+
strict = true
|
|
44
|
+
python_version = "3.10"
|
|
45
|
+
files = ["src"]
|
|
@@ -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
|
+
]
|
|
@@ -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())
|
|
@@ -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}
|
|
@@ -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
|
+
)
|
|
@@ -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.")
|
|
@@ -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
|
|
File without changes
|