envpass-cli 0.1.6__tar.gz → 0.2.7__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.6 → envpass_cli-0.2.7}/PKG-INFO +8 -5
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/README.md +6 -3
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/pyproject.toml +2 -2
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/pyproject.toml.orig +2 -2
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/cli.py +99 -6
- envpass_cli-0.2.7/src/envpass/client.py +397 -0
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/config.py +1 -3
- envpass_cli-0.2.7/src/envpass/integrations.py +219 -0
- envpass_cli-0.1.6/src/envpass/client.py +0 -156
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/__init__.py +0 -0
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/__main__.py +0 -0
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/_version.py +0 -0
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/device.py +0 -0
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/mask.py +0 -0
- {envpass_cli-0.1.6 → envpass_cli-0.2.7}/src/envpass/py.typed +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: envpass-cli
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Client and CLI for envpass: encrypted environment variables for every project
|
|
3
|
+
Version: 0.2.7
|
|
4
|
+
Summary: Client and CLI for envpass: encrypted environment variables for every project and environment.
|
|
5
5
|
Keywords: env,dotenv,secrets,environment-variables,cli,envpass
|
|
6
6
|
License-Expression: MIT
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
@@ -15,7 +15,7 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
|
|
16
16
|
# envpass-cli
|
|
17
17
|
|
|
18
|
-
Python client and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project
|
|
18
|
+
Python client and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project and environment. No dependencies.
|
|
19
19
|
|
|
20
20
|
## CLI
|
|
21
21
|
|
|
@@ -25,6 +25,8 @@ uvx envpass-cli link -w acme -p shop -e dev # save defaults to .envpass.json (
|
|
|
25
25
|
uvx envpass-cli pull # write .env
|
|
26
26
|
uvx envpass-cli pull -e prod -o - # print to stdout
|
|
27
27
|
uvx envpass-cli run -e dev -- python app.py # run with values injected, masked in output
|
|
28
|
+
uvx envpass-cli webhooks list # webhooks and syncs need `login`
|
|
29
|
+
uvx envpass-cli syncs run <id> # push a sync now
|
|
28
30
|
```
|
|
29
31
|
|
|
30
32
|
`pipx run envpass-cli …` and `pip install envpass-cli` work too.
|
|
@@ -35,7 +37,7 @@ In CI, use a service token instead of `login`:
|
|
|
35
37
|
ENVPASS_TOKEN=ep_live_… uvx envpass-cli pull -e prod
|
|
36
38
|
```
|
|
37
39
|
|
|
38
|
-
Options can also come from `ENVPASS_WORKSPACE`, `ENVPASS_PROJECT`, `ENVPASS_ENV
|
|
40
|
+
Options can also come from `ENVPASS_WORKSPACE`, `ENVPASS_PROJECT`, `ENVPASS_ENV` and `ENVPASS_URL`.
|
|
39
41
|
|
|
40
42
|
## Library
|
|
41
43
|
|
|
@@ -45,6 +47,7 @@ from envpass import Envpass, load_env
|
|
|
45
47
|
load_env("prod")
|
|
46
48
|
|
|
47
49
|
client = Envpass(token="ep_live_…")
|
|
48
|
-
entries = client.resolve({"environment": "prod"
|
|
50
|
+
entries = client.resolve({"environment": "prod"})
|
|
51
|
+
webhooks = client.list_webhooks({"workspace": "acme"})
|
|
49
52
|
workspaces = client.request("GET", "/api/v1/workspaces")
|
|
50
53
|
```
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# envpass-cli
|
|
2
2
|
|
|
3
|
-
Python client and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project
|
|
3
|
+
Python client and CLI for [envpass](https://envpass.dev): encrypted environment variables for every project and environment. No dependencies.
|
|
4
4
|
|
|
5
5
|
## CLI
|
|
6
6
|
|
|
@@ -10,6 +10,8 @@ uvx envpass-cli link -w acme -p shop -e dev # save defaults to .envpass.json (
|
|
|
10
10
|
uvx envpass-cli pull # write .env
|
|
11
11
|
uvx envpass-cli pull -e prod -o - # print to stdout
|
|
12
12
|
uvx envpass-cli run -e dev -- python app.py # run with values injected, masked in output
|
|
13
|
+
uvx envpass-cli webhooks list # webhooks and syncs need `login`
|
|
14
|
+
uvx envpass-cli syncs run <id> # push a sync now
|
|
13
15
|
```
|
|
14
16
|
|
|
15
17
|
`pipx run envpass-cli …` and `pip install envpass-cli` work too.
|
|
@@ -20,7 +22,7 @@ In CI, use a service token instead of `login`:
|
|
|
20
22
|
ENVPASS_TOKEN=ep_live_… uvx envpass-cli pull -e prod
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
Options can also come from `ENVPASS_WORKSPACE`, `ENVPASS_PROJECT`, `ENVPASS_ENV
|
|
25
|
+
Options can also come from `ENVPASS_WORKSPACE`, `ENVPASS_PROJECT`, `ENVPASS_ENV` and `ENVPASS_URL`.
|
|
24
26
|
|
|
25
27
|
## Library
|
|
26
28
|
|
|
@@ -30,6 +32,7 @@ from envpass import Envpass, load_env
|
|
|
30
32
|
load_env("prod")
|
|
31
33
|
|
|
32
34
|
client = Envpass(token="ep_live_…")
|
|
33
|
-
entries = client.resolve({"environment": "prod"
|
|
35
|
+
entries = client.resolve({"environment": "prod"})
|
|
36
|
+
webhooks = client.list_webhooks({"workspace": "acme"})
|
|
34
37
|
workspaces = client.request("GET", "/api/v1/workspaces")
|
|
35
38
|
```
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "envpass-cli"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Client and CLI for envpass: encrypted environment variables for every project
|
|
3
|
+
version = "0.2.7"
|
|
4
|
+
description = "Client and CLI for envpass: encrypted environment variables for every project and environment."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.9"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "envpass-cli"
|
|
3
|
-
version = "0.
|
|
4
|
-
description = "Client and CLI for envpass: encrypted environment variables for every project
|
|
3
|
+
version = "0.2.7"
|
|
4
|
+
description = "Client and CLI for envpass: encrypted environment variables for every project and environment."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
requires-python = ">=3.9"
|
|
@@ -22,6 +22,7 @@ from .config import (
|
|
|
22
22
|
write_project_link,
|
|
23
23
|
)
|
|
24
24
|
from .device import device_login, open_browser
|
|
25
|
+
from .integrations import syncs_command, webhooks_command
|
|
25
26
|
from .mask import Masker
|
|
26
27
|
|
|
27
28
|
NOT_SIGNED_IN = "Not signed in. Run `envpass login` or set ENVPASS_TOKEN."
|
|
@@ -35,7 +36,7 @@ def _fail(message: str, code: int = 1) -> int:
|
|
|
35
36
|
def _parser() -> argparse.ArgumentParser:
|
|
36
37
|
parser = argparse.ArgumentParser(
|
|
37
38
|
prog="envpass",
|
|
38
|
-
description="Encrypted environment variables for every project
|
|
39
|
+
description="Encrypted environment variables for every project and environment.",
|
|
39
40
|
)
|
|
40
41
|
parser.add_argument("--version", action="version", version=__version__)
|
|
41
42
|
parser.add_argument(
|
|
@@ -45,7 +46,6 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
45
46
|
scope.add_argument("-w", "--workspace", help="workspace slug")
|
|
46
47
|
scope.add_argument("-p", "--project", help="project slug")
|
|
47
48
|
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
49
|
scope.add_argument("--url", dest="url_sub", help=argparse.SUPPRESS)
|
|
50
50
|
|
|
51
51
|
commands = parser.add_subparsers(dest="command", metavar="command")
|
|
@@ -62,9 +62,97 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
62
62
|
run = commands.add_parser("run", help="run a command with the values injected", parents=[scope])
|
|
63
63
|
run.add_argument("--no-mask", action="store_true", help="show secret values in the output")
|
|
64
64
|
run.add_argument("cmd", nargs=argparse.REMAINDER, help="-- command [args...]")
|
|
65
|
+
_integration_parsers(commands)
|
|
65
66
|
return parser
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
def _integration_parsers(commands: Any) -> None:
|
|
70
|
+
server = argparse.ArgumentParser(add_help=False)
|
|
71
|
+
server.add_argument("--url", dest="url_sub", help=argparse.SUPPRESS)
|
|
72
|
+
workspace = argparse.ArgumentParser(add_help=False)
|
|
73
|
+
workspace.add_argument("-w", "--workspace", help="workspace slug")
|
|
74
|
+
project = argparse.ArgumentParser(add_help=False)
|
|
75
|
+
project.add_argument("-p", "--project", help="project slug")
|
|
76
|
+
listing = argparse.ArgumentParser(add_help=False)
|
|
77
|
+
listing.add_argument("--json", action="store_true", help="print raw JSON")
|
|
78
|
+
needs_login = "Needs `envpass login`, not a service token."
|
|
79
|
+
|
|
80
|
+
webhooks = commands.add_parser(
|
|
81
|
+
"webhooks",
|
|
82
|
+
help="list, add, remove and test the workspace's webhooks",
|
|
83
|
+
description=f"Manage the workspace's webhooks. {needs_login}",
|
|
84
|
+
)
|
|
85
|
+
hooks = webhooks.add_subparsers(dest="action", metavar="action")
|
|
86
|
+
hooks.add_parser("list", help="list webhooks", parents=[workspace, server, listing])
|
|
87
|
+
add = hooks.add_parser(
|
|
88
|
+
"add",
|
|
89
|
+
help="register a webhook and print its signing secret once",
|
|
90
|
+
description="Register a webhook. The signing secret is printed once: store it.",
|
|
91
|
+
parents=[workspace],
|
|
92
|
+
)
|
|
93
|
+
add.add_argument(
|
|
94
|
+
"--url",
|
|
95
|
+
dest="webhook_url",
|
|
96
|
+
metavar="URL",
|
|
97
|
+
help="endpoint to POST to (point at your envpass server with ENVPASS_URL)",
|
|
98
|
+
)
|
|
99
|
+
add.add_argument(
|
|
100
|
+
"--event",
|
|
101
|
+
dest="events",
|
|
102
|
+
action="append",
|
|
103
|
+
metavar="EVENT",
|
|
104
|
+
help="secrets.changed, sync.succeeded or sync.failed; repeat for more "
|
|
105
|
+
"(default secrets.changed and sync.failed)",
|
|
106
|
+
)
|
|
107
|
+
add.add_argument(
|
|
108
|
+
"-e",
|
|
109
|
+
"--env",
|
|
110
|
+
dest="environments",
|
|
111
|
+
action="append",
|
|
112
|
+
metavar="ENV",
|
|
113
|
+
help="only these environments; repeat for more (default all)",
|
|
114
|
+
)
|
|
115
|
+
add.add_argument(
|
|
116
|
+
"-p",
|
|
117
|
+
"--project",
|
|
118
|
+
dest="projects",
|
|
119
|
+
action="append",
|
|
120
|
+
metavar="PROJECT",
|
|
121
|
+
help="only these projects; repeat for more (default all)",
|
|
122
|
+
)
|
|
123
|
+
add.add_argument(
|
|
124
|
+
"-f", "--format", help="json or slack (default slack for Slack URLs, json otherwise)"
|
|
125
|
+
)
|
|
126
|
+
add.add_argument("--description", help="a note to tell webhooks apart")
|
|
127
|
+
remove = hooks.add_parser("remove", help="delete a webhook", parents=[workspace, server])
|
|
128
|
+
remove.add_argument("id", nargs="?", help="webhook id")
|
|
129
|
+
test = hooks.add_parser(
|
|
130
|
+
"test", help="send a test event (exit code 1 if it fails)", parents=[workspace, server]
|
|
131
|
+
)
|
|
132
|
+
test.add_argument("id", nargs="?", help="webhook id")
|
|
133
|
+
deliveries = hooks.add_parser(
|
|
134
|
+
"deliveries", help="show recent deliveries", parents=[workspace, server, listing]
|
|
135
|
+
)
|
|
136
|
+
deliveries.add_argument("id", nargs="?", help="webhook id")
|
|
137
|
+
deliveries.add_argument("--limit", help="how many to show, 1-100 (default 25)")
|
|
138
|
+
|
|
139
|
+
syncs = commands.add_parser(
|
|
140
|
+
"syncs",
|
|
141
|
+
help="list, run, pause and resume the project's syncs",
|
|
142
|
+
description=f"Manage the project's syncs. {needs_login}",
|
|
143
|
+
)
|
|
144
|
+
actions = syncs.add_subparsers(dest="action", metavar="action")
|
|
145
|
+
scope = [workspace, project, server]
|
|
146
|
+
actions.add_parser("list", help="list the project's syncs", parents=[*scope, listing])
|
|
147
|
+
for name, text in (
|
|
148
|
+
("run", "push a sync now"),
|
|
149
|
+
("pause", "stop a sync from running"),
|
|
150
|
+
("resume", "turn a paused sync back on"),
|
|
151
|
+
):
|
|
152
|
+
action = actions.add_parser(name, help=text, parents=scope)
|
|
153
|
+
action.add_argument("id", nargs="?", help="sync id")
|
|
154
|
+
|
|
155
|
+
|
|
68
156
|
def _pump(source: IO[bytes], target: Any, masker: Masker) -> None:
|
|
69
157
|
while True:
|
|
70
158
|
chunk = os.read(source.fileno(), 65536)
|
|
@@ -119,10 +207,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
119
207
|
return 0
|
|
120
208
|
url = base_url(getattr(args, "url_sub", None) or args.url)
|
|
121
209
|
flags = ProjectLink(
|
|
122
|
-
workspace=args
|
|
123
|
-
project=args
|
|
124
|
-
environment=args
|
|
125
|
-
service=args.service,
|
|
210
|
+
workspace=getattr(args, "workspace", None),
|
|
211
|
+
project=getattr(args, "project", None),
|
|
212
|
+
environment=getattr(args, "environment", None),
|
|
126
213
|
)
|
|
127
214
|
try:
|
|
128
215
|
if args.command == "login":
|
|
@@ -199,6 +286,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
199
286
|
if args.command == "run":
|
|
200
287
|
return _run(client, flags, list(args.cmd), not args.no_mask)
|
|
201
288
|
|
|
289
|
+
if args.command == "webhooks":
|
|
290
|
+
return webhooks_command(client, args, flags)
|
|
291
|
+
|
|
292
|
+
if args.command == "syncs":
|
|
293
|
+
return syncs_command(client, args, flags)
|
|
294
|
+
|
|
202
295
|
return _fail(f'Unknown command "{args.command}". Run `envpass --help`.')
|
|
203
296
|
except EnvpassError as error:
|
|
204
297
|
return _fail(f"{error}{f' ({error.code})' if error.code else ''}")
|
|
@@ -0,0 +1,397 @@
|
|
|
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", "personal"]
|
|
14
|
+
ExportFormat = Literal["dotenv", "json", "shell"]
|
|
15
|
+
WebhookEvent = Literal["secrets.changed", "sync.succeeded", "sync.failed"]
|
|
16
|
+
WebhookFormat = Literal["json", "slack"]
|
|
17
|
+
DeliveryStatus = Literal["pending", "retrying", "success", "failed"]
|
|
18
|
+
|
|
19
|
+
WEBHOOK_EVENTS: tuple[WebhookEvent, ...] = ("secrets.changed", "sync.succeeded", "sync.failed")
|
|
20
|
+
DEFAULT_WEBHOOK_EVENTS: tuple[WebhookEvent, ...] = ("secrets.changed", "sync.failed")
|
|
21
|
+
USER_LOGIN_REQUIRED = (
|
|
22
|
+
"Webhooks and syncs need a user login, not a service token. "
|
|
23
|
+
"Run `envpass login` and unset ENVPASS_TOKEN."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ResolvedEntry(TypedDict):
|
|
28
|
+
key: str
|
|
29
|
+
value: str
|
|
30
|
+
source: Source
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Workspace(TypedDict):
|
|
34
|
+
id: str
|
|
35
|
+
name: str
|
|
36
|
+
slug: str
|
|
37
|
+
isOwner: bool
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenInfo(TypedDict):
|
|
41
|
+
id: str
|
|
42
|
+
workspaceId: str
|
|
43
|
+
projectId: str
|
|
44
|
+
access: Literal["read", "write"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Me(TypedDict):
|
|
48
|
+
actor: Literal["user", "token"]
|
|
49
|
+
userId: str
|
|
50
|
+
token: TokenInfo | None
|
|
51
|
+
workspaces: list[Workspace]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Environment(TypedDict):
|
|
55
|
+
id: str
|
|
56
|
+
workspaceId: str
|
|
57
|
+
slug: str
|
|
58
|
+
name: str
|
|
59
|
+
position: int
|
|
60
|
+
isProduction: bool
|
|
61
|
+
allowPersonalOverrides: bool
|
|
62
|
+
createdAt: str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LastDelivery(TypedDict):
|
|
66
|
+
status: DeliveryStatus
|
|
67
|
+
createdAt: str
|
|
68
|
+
responseStatus: int | None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Webhook(TypedDict):
|
|
72
|
+
id: str
|
|
73
|
+
url: str
|
|
74
|
+
description: str | None
|
|
75
|
+
format: WebhookFormat
|
|
76
|
+
events: list[str]
|
|
77
|
+
projectIds: list[str]
|
|
78
|
+
environmentIds: list[str]
|
|
79
|
+
enabled: bool
|
|
80
|
+
disabledReason: str | None
|
|
81
|
+
consecutiveFailures: int
|
|
82
|
+
createdAt: str
|
|
83
|
+
updatedAt: str
|
|
84
|
+
lastDelivery: LastDelivery | None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CreatedWebhook(TypedDict):
|
|
88
|
+
webhook: Webhook
|
|
89
|
+
secret: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class WebhookTestResult(TypedDict):
|
|
93
|
+
ok: bool
|
|
94
|
+
responseStatus: int | None
|
|
95
|
+
durationMs: int
|
|
96
|
+
error: str | None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class WebhookDelivery(TypedDict):
|
|
100
|
+
id: str
|
|
101
|
+
eventId: str
|
|
102
|
+
eventType: str
|
|
103
|
+
status: DeliveryStatus
|
|
104
|
+
attempts: int
|
|
105
|
+
responseStatus: int | None
|
|
106
|
+
error: str | None
|
|
107
|
+
durationMs: int | None
|
|
108
|
+
payload: dict[str, Any]
|
|
109
|
+
createdAt: str
|
|
110
|
+
lastAttemptAt: str | None
|
|
111
|
+
deliveredAt: str | None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class Connection(TypedDict):
|
|
115
|
+
id: str
|
|
116
|
+
provider: Literal["vercel", "github"]
|
|
117
|
+
name: str
|
|
118
|
+
account: str
|
|
119
|
+
teamId: str | None
|
|
120
|
+
teamSlug: str | None
|
|
121
|
+
createdAt: str
|
|
122
|
+
lastUsedAt: str | None
|
|
123
|
+
createdByName: str | None
|
|
124
|
+
syncs: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Sync(TypedDict):
|
|
128
|
+
id: str
|
|
129
|
+
projectId: str
|
|
130
|
+
environmentId: str
|
|
131
|
+
environmentSlug: str
|
|
132
|
+
environmentName: str
|
|
133
|
+
connectionId: str
|
|
134
|
+
connectionName: str
|
|
135
|
+
provider: Literal["vercel", "github"]
|
|
136
|
+
target: dict[str, Any]
|
|
137
|
+
destination: str
|
|
138
|
+
enabled: bool
|
|
139
|
+
status: Literal["pending", "running", "ok", "error"]
|
|
140
|
+
lastError: str | None
|
|
141
|
+
lastResult: dict[str, Any] | None
|
|
142
|
+
lastSyncedAt: str | None
|
|
143
|
+
lastRunStartedAt: str | None
|
|
144
|
+
managedKeys: int
|
|
145
|
+
createdAt: str
|
|
146
|
+
createdByName: str | None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class EnvpassError(Exception):
|
|
150
|
+
def __init__(self, message: str, status: int, code: str | None = None) -> None:
|
|
151
|
+
super().__init__(message)
|
|
152
|
+
self.status = status
|
|
153
|
+
self.code = code
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _segment(value: str) -> str:
|
|
157
|
+
return urllib.parse.quote(value, safe="")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pick_ids(items: list[Any], refs: list[str], kind: str, workspace: Workspace) -> list[str]:
|
|
161
|
+
ids: list[str] = []
|
|
162
|
+
for ref in refs:
|
|
163
|
+
match = next((item for item in items if ref in (item["slug"], item["id"])), None)
|
|
164
|
+
if match is None:
|
|
165
|
+
raise EnvpassError(f'No {kind} "{ref}" in {workspace["slug"]}', 404, "not_found")
|
|
166
|
+
ids.append(str(match["id"]))
|
|
167
|
+
return ids
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Envpass:
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
base_url_: str | None = None,
|
|
174
|
+
token: str | None = None,
|
|
175
|
+
user_agent: str | None = None,
|
|
176
|
+
timeout: float = 30.0,
|
|
177
|
+
) -> None:
|
|
178
|
+
self.url = base_url(base_url_)
|
|
179
|
+
self.token = resolve_token(self.url, token)
|
|
180
|
+
self.timeout = timeout
|
|
181
|
+
self.user_agent = user_agent or f"envpass-python/{__version__}"
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def has_token(self) -> bool:
|
|
185
|
+
return bool(self.token)
|
|
186
|
+
|
|
187
|
+
def request(
|
|
188
|
+
self,
|
|
189
|
+
method: str,
|
|
190
|
+
path: str,
|
|
191
|
+
query: dict[str, str | None] | None = None,
|
|
192
|
+
body: Any = None,
|
|
193
|
+
raw: bool = False,
|
|
194
|
+
) -> Any:
|
|
195
|
+
url = f"{self.url}{path}"
|
|
196
|
+
params = {k: v for k, v in (query or {}).items() if v}
|
|
197
|
+
if params:
|
|
198
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
199
|
+
headers = {"user-agent": self.user_agent, "accept": "application/json"}
|
|
200
|
+
if self.token:
|
|
201
|
+
headers["authorization"] = f"Bearer {self.token}"
|
|
202
|
+
data = None
|
|
203
|
+
if body is not None:
|
|
204
|
+
data = json.dumps(body).encode("utf-8")
|
|
205
|
+
headers["content-type"] = "application/json"
|
|
206
|
+
request = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
207
|
+
try:
|
|
208
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
209
|
+
text = response.read().decode("utf-8")
|
|
210
|
+
except urllib.error.HTTPError as error:
|
|
211
|
+
payload = error.read().decode("utf-8", "replace")
|
|
212
|
+
try:
|
|
213
|
+
detail = json.loads(payload).get("error", {})
|
|
214
|
+
except ValueError:
|
|
215
|
+
detail = {}
|
|
216
|
+
raise EnvpassError(
|
|
217
|
+
detail.get("message") or f"Request failed with status {error.code}",
|
|
218
|
+
error.code,
|
|
219
|
+
detail.get("code"),
|
|
220
|
+
) from None
|
|
221
|
+
except urllib.error.URLError as error:
|
|
222
|
+
raise EnvpassError(f"Could not reach {self.url}: {error.reason}", 0) from None
|
|
223
|
+
return text if raw else json.loads(text)
|
|
224
|
+
|
|
225
|
+
def me(self) -> Me:
|
|
226
|
+
result: Me = self.request("GET", "/api/v1/me")
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
def _scope_query(self, scope: ProjectLink | None) -> dict[str, str | None]:
|
|
230
|
+
resolved = resolve_scope(scope or {})
|
|
231
|
+
if not resolved.get("environment"):
|
|
232
|
+
raise EnvpassError(
|
|
233
|
+
"No environment. Pass --env, set ENVPASS_ENV, or run `envpass link`.",
|
|
234
|
+
400,
|
|
235
|
+
"invalid",
|
|
236
|
+
)
|
|
237
|
+
return {
|
|
238
|
+
"workspace": resolved.get("workspace"),
|
|
239
|
+
"project": resolved.get("project"),
|
|
240
|
+
"environment": resolved.get("environment"),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
def resolve(self, scope: ProjectLink | None = None) -> list[ResolvedEntry]:
|
|
244
|
+
query = {**self._scope_query(scope), "format": "json"}
|
|
245
|
+
result: list[ResolvedEntry] = self.request("GET", "/api/v1/resolve", query)["entries"]
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
def resolve_text(
|
|
249
|
+
self, scope: ProjectLink | None = None, fmt: Literal["dotenv", "shell"] = "dotenv"
|
|
250
|
+
) -> str:
|
|
251
|
+
query = {**self._scope_query(scope), "format": fmt}
|
|
252
|
+
text: str = self.request("GET", "/api/v1/resolve", query, raw=True)
|
|
253
|
+
return text
|
|
254
|
+
|
|
255
|
+
def _require_user(self) -> None:
|
|
256
|
+
if self.token and self.token.startswith("ep_"):
|
|
257
|
+
raise EnvpassError(USER_LOGIN_REQUIRED, 403, "forbidden")
|
|
258
|
+
|
|
259
|
+
def workspace(self, scope: ProjectLink | None = None) -> Workspace:
|
|
260
|
+
self._require_user()
|
|
261
|
+
ref = resolve_scope(scope or {}).get("workspace")
|
|
262
|
+
workspaces: list[Workspace] = self.request("GET", "/api/v1/workspaces")["workspaces"]
|
|
263
|
+
if ref:
|
|
264
|
+
for candidate in workspaces:
|
|
265
|
+
if ref in (candidate["slug"], candidate["id"]):
|
|
266
|
+
return candidate
|
|
267
|
+
raise EnvpassError(f'No workspace "{ref}" for this login', 404, "not_found")
|
|
268
|
+
if len(workspaces) == 1:
|
|
269
|
+
return workspaces[0]
|
|
270
|
+
raise EnvpassError(
|
|
271
|
+
"No workspace. Pass --workspace, set ENVPASS_WORKSPACE, or run `envpass link`.",
|
|
272
|
+
400,
|
|
273
|
+
"invalid",
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _workspace_path(self, scope: ProjectLink | None) -> str:
|
|
277
|
+
return f"/api/v1/workspaces/{_segment(self.workspace(scope)['id'])}"
|
|
278
|
+
|
|
279
|
+
def _project_path(self, scope: ProjectLink | None) -> str:
|
|
280
|
+
workspace = self.workspace(scope)
|
|
281
|
+
ref = resolve_scope(scope or {}).get("project")
|
|
282
|
+
if not ref:
|
|
283
|
+
raise EnvpassError(
|
|
284
|
+
"No project. Pass --project, set ENVPASS_PROJECT, or run `envpass link`.",
|
|
285
|
+
400,
|
|
286
|
+
"invalid",
|
|
287
|
+
)
|
|
288
|
+
base = f"/api/v1/workspaces/{_segment(workspace['id'])}"
|
|
289
|
+
projects = self.request("GET", f"{base}/projects")["projects"]
|
|
290
|
+
[project_id] = _pick_ids(projects, [ref], "project", workspace)
|
|
291
|
+
return f"{base}/projects/{_segment(project_id)}"
|
|
292
|
+
|
|
293
|
+
def list_environments(self, scope: ProjectLink | None = None) -> list[Environment]:
|
|
294
|
+
path = f"{self._workspace_path(scope)}/environments"
|
|
295
|
+
result: list[Environment] = self.request("GET", path)["environments"]
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
def list_webhooks(self, scope: ProjectLink | None = None) -> list[Webhook]:
|
|
299
|
+
result: list[Webhook] = self.request("GET", f"{self._workspace_path(scope)}/webhooks")[
|
|
300
|
+
"webhooks"
|
|
301
|
+
]
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
def create_webhook(
|
|
305
|
+
self,
|
|
306
|
+
url: str,
|
|
307
|
+
*,
|
|
308
|
+
events: list[WebhookEvent] | None = None,
|
|
309
|
+
environments: list[str] | None = None,
|
|
310
|
+
projects: list[str] | None = None,
|
|
311
|
+
fmt: WebhookFormat | None = None,
|
|
312
|
+
description: str | None = None,
|
|
313
|
+
scope: ProjectLink | None = None,
|
|
314
|
+
) -> CreatedWebhook:
|
|
315
|
+
workspace = self.workspace(scope)
|
|
316
|
+
base = f"/api/v1/workspaces/{_segment(workspace['id'])}"
|
|
317
|
+
environment_ids: list[str] = []
|
|
318
|
+
project_ids: list[str] = []
|
|
319
|
+
if environments:
|
|
320
|
+
listed = self.request("GET", f"{base}/environments")["environments"]
|
|
321
|
+
environment_ids = _pick_ids(listed, environments, "environment", workspace)
|
|
322
|
+
if projects:
|
|
323
|
+
listed = self.request("GET", f"{base}/projects")["projects"]
|
|
324
|
+
project_ids = _pick_ids(listed, projects, "project", workspace)
|
|
325
|
+
body: dict[str, Any] = {
|
|
326
|
+
"url": url,
|
|
327
|
+
"events": list(events or DEFAULT_WEBHOOK_EVENTS),
|
|
328
|
+
"environmentIds": environment_ids,
|
|
329
|
+
"projectIds": project_ids,
|
|
330
|
+
}
|
|
331
|
+
if fmt is not None:
|
|
332
|
+
body["format"] = fmt
|
|
333
|
+
if description is not None:
|
|
334
|
+
body["description"] = description
|
|
335
|
+
result: CreatedWebhook = self.request("POST", f"{base}/webhooks", body=body)
|
|
336
|
+
return result
|
|
337
|
+
|
|
338
|
+
def _webhook_path(self, webhook_id: str, scope: ProjectLink | None) -> str:
|
|
339
|
+
return f"{self._workspace_path(scope)}/webhooks/{_segment(webhook_id)}"
|
|
340
|
+
|
|
341
|
+
def delete_webhook(self, webhook_id: str, scope: ProjectLink | None = None) -> None:
|
|
342
|
+
self.request("DELETE", self._webhook_path(webhook_id, scope))
|
|
343
|
+
|
|
344
|
+
def test_webhook(self, webhook_id: str, scope: ProjectLink | None = None) -> WebhookTestResult:
|
|
345
|
+
result: WebhookTestResult = self.request(
|
|
346
|
+
"POST", f"{self._webhook_path(webhook_id, scope)}/test"
|
|
347
|
+
)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
def list_webhook_deliveries(
|
|
351
|
+
self,
|
|
352
|
+
webhook_id: str,
|
|
353
|
+
limit: int | None = None,
|
|
354
|
+
before: str | None = None,
|
|
355
|
+
scope: ProjectLink | None = None,
|
|
356
|
+
) -> list[WebhookDelivery]:
|
|
357
|
+
query = {"limit": None if limit is None else str(limit), "before": before}
|
|
358
|
+
path = f"{self._webhook_path(webhook_id, scope)}/deliveries"
|
|
359
|
+
result: list[WebhookDelivery] = self.request("GET", path, query)["deliveries"]
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
def list_connections(self, scope: ProjectLink | None = None) -> list[Connection]:
|
|
363
|
+
path = f"{self._workspace_path(scope)}/connections"
|
|
364
|
+
result: list[Connection] = self.request("GET", path)["connections"]
|
|
365
|
+
return result
|
|
366
|
+
|
|
367
|
+
def list_syncs(self, scope: ProjectLink | None = None) -> list[Sync]:
|
|
368
|
+
result: list[Sync] = self.request("GET", f"{self._project_path(scope)}/syncs")["syncs"]
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
def run_sync(self, sync_id: str, scope: ProjectLink | None = None) -> None:
|
|
372
|
+
self.request("POST", f"{self._project_path(scope)}/syncs/{_segment(sync_id)}/run")
|
|
373
|
+
|
|
374
|
+
def set_sync_enabled(
|
|
375
|
+
self, sync_id: str, enabled: bool, scope: ProjectLink | None = None
|
|
376
|
+
) -> None:
|
|
377
|
+
path = f"{self._project_path(scope)}/syncs/{_segment(sync_id)}"
|
|
378
|
+
self.request("PATCH", path, body={"enabled": enabled})
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def load_env(
|
|
382
|
+
environment: str | None = None,
|
|
383
|
+
*,
|
|
384
|
+
workspace: str | None = None,
|
|
385
|
+
project: str | None = None,
|
|
386
|
+
token: str | None = None,
|
|
387
|
+
base_url_: str | None = None,
|
|
388
|
+
override: bool = False,
|
|
389
|
+
) -> dict[str, str]:
|
|
390
|
+
client = Envpass(base_url_, token)
|
|
391
|
+
entries = client.resolve(
|
|
392
|
+
ProjectLink(workspace=workspace, project=project, environment=environment)
|
|
393
|
+
)
|
|
394
|
+
for entry in entries:
|
|
395
|
+
if override or entry["key"] not in os.environ:
|
|
396
|
+
os.environ[entry["key"]] = entry["value"]
|
|
397
|
+
return {entry["key"]: entry["value"] for entry in entries}
|
|
@@ -13,7 +13,6 @@ class ProjectLink(TypedDict, total=False):
|
|
|
13
13
|
workspace: str | None
|
|
14
14
|
project: str | None
|
|
15
15
|
environment: str | None
|
|
16
|
-
service: str | None
|
|
17
16
|
|
|
18
17
|
|
|
19
18
|
def base_url(explicit: str | None = None) -> str:
|
|
@@ -95,7 +94,7 @@ def read_project_link(start: Path | None = None) -> ProjectLink:
|
|
|
95
94
|
if not isinstance(data, dict):
|
|
96
95
|
return {}
|
|
97
96
|
link: ProjectLink = {}
|
|
98
|
-
for key in ("workspace", "project", "environment"
|
|
97
|
+
for key in ("workspace", "project", "environment"):
|
|
99
98
|
value = data.get(key)
|
|
100
99
|
if isinstance(value, str):
|
|
101
100
|
link[key] = value # type: ignore[literal-required,unused-ignore]
|
|
@@ -116,5 +115,4 @@ def resolve_scope(flags: ProjectLink, start: Path | None = None) -> ProjectLink:
|
|
|
116
115
|
workspace=flags.get("workspace") or env.get("ENVPASS_WORKSPACE") or file.get("workspace"),
|
|
117
116
|
project=flags.get("project") or env.get("ENVPASS_PROJECT") or file.get("project"),
|
|
118
117
|
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
118
|
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from .client import WEBHOOK_EVENTS, Envpass, WebhookEvent, WebhookFormat, WebhookTestResult
|
|
10
|
+
from .config import ProjectLink
|
|
11
|
+
|
|
12
|
+
WEBHOOKS_USAGE = "Usage: envpass webhooks <list|add|remove|test|deliveries>"
|
|
13
|
+
SYNCS_USAGE = "Usage: envpass syncs <list|run|pause|resume>"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def table(header: list[str], rows: list[list[str]]) -> str:
|
|
17
|
+
every = [header, *rows]
|
|
18
|
+
widths = [max(len(row[i]) for row in every) for i in range(len(header))]
|
|
19
|
+
lines = [
|
|
20
|
+
" ".join(
|
|
21
|
+
cell if i == len(row) - 1 else cell.ljust(widths[i]) for i, cell in enumerate(row)
|
|
22
|
+
)
|
|
23
|
+
for row in every
|
|
24
|
+
]
|
|
25
|
+
return "\n".join(lines) + "\n"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _one_line(text: str | None) -> str:
|
|
29
|
+
return " ".join(text.split()) if text else "-"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _json(value: Any) -> str:
|
|
33
|
+
return json.dumps(value, indent=2, ensure_ascii=False) + "\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require_id(args: argparse.Namespace, usage: str) -> str:
|
|
37
|
+
value = getattr(args, "id", None)
|
|
38
|
+
if not value:
|
|
39
|
+
raise ValueError(usage)
|
|
40
|
+
return str(value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _describe_test(result: WebhookTestResult) -> str:
|
|
44
|
+
status = result["responseStatus"]
|
|
45
|
+
code = "no response" if status is None else f"HTTP {status}"
|
|
46
|
+
detail = f": {_one_line(result['error'])}" if result["error"] else ""
|
|
47
|
+
outcome = "Delivered" if result["ok"] else "Failed"
|
|
48
|
+
return f"{outcome}: {code} in {result['durationMs']}ms{detail}\n"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_limit(limit: str | None) -> int | None:
|
|
52
|
+
if limit is None:
|
|
53
|
+
return None
|
|
54
|
+
if not re.fullmatch(r"[0-9]+", limit) or not 1 <= int(limit) <= 100:
|
|
55
|
+
raise ValueError("--limit must be a whole number from 1 to 100")
|
|
56
|
+
return int(limit)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_events(events: list[str] | None) -> list[WebhookEvent] | None:
|
|
60
|
+
if not events:
|
|
61
|
+
return None
|
|
62
|
+
for event in events:
|
|
63
|
+
if event not in WEBHOOK_EVENTS:
|
|
64
|
+
raise ValueError(f"--event must be one of {', '.join(WEBHOOK_EVENTS)}")
|
|
65
|
+
return cast("list[WebhookEvent]", events)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_format(fmt: str | None) -> WebhookFormat | None:
|
|
69
|
+
if fmt is None:
|
|
70
|
+
return None
|
|
71
|
+
if fmt not in ("json", "slack"):
|
|
72
|
+
raise ValueError("--format must be json or slack")
|
|
73
|
+
return cast("WebhookFormat", fmt)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _out(text: str) -> None:
|
|
77
|
+
sys.stdout.write(text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _err(text: str) -> None:
|
|
81
|
+
sys.stderr.write(text)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def webhooks_command(client: Envpass, args: argparse.Namespace, flags: ProjectLink) -> int:
|
|
85
|
+
action = getattr(args, "action", None)
|
|
86
|
+
scope = ProjectLink(workspace=flags.get("workspace"))
|
|
87
|
+
if action == "list":
|
|
88
|
+
webhooks = client.list_webhooks(scope)
|
|
89
|
+
if args.json:
|
|
90
|
+
_out(_json(webhooks))
|
|
91
|
+
return 0
|
|
92
|
+
if not webhooks:
|
|
93
|
+
_err("No webhooks.\n")
|
|
94
|
+
return 0
|
|
95
|
+
slugs = {env["id"]: env["slug"] for env in client.list_environments(scope)}
|
|
96
|
+
rows = []
|
|
97
|
+
for hook in webhooks:
|
|
98
|
+
last = hook["lastDelivery"]
|
|
99
|
+
delivery = "-"
|
|
100
|
+
if last:
|
|
101
|
+
code = last["responseStatus"]
|
|
102
|
+
delivery = f"{last['status']} {'' if code is None else code}".strip()
|
|
103
|
+
environments = ",".join(slugs.get(i, i) for i in hook["environmentIds"]) or "all"
|
|
104
|
+
rows.append(
|
|
105
|
+
[
|
|
106
|
+
hook["id"],
|
|
107
|
+
hook["url"],
|
|
108
|
+
",".join(hook["events"]),
|
|
109
|
+
environments,
|
|
110
|
+
"on" if hook["enabled"] else "off",
|
|
111
|
+
delivery,
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
_out(table(["ID", "URL", "EVENTS", "ENVIRONMENTS", "ENABLED", "LAST DELIVERY"], rows))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
if action == "add":
|
|
118
|
+
if not args.webhook_url:
|
|
119
|
+
raise ValueError("Usage: envpass webhooks add --url <url> [options]")
|
|
120
|
+
created = client.create_webhook(
|
|
121
|
+
args.webhook_url,
|
|
122
|
+
events=_parse_events(args.events),
|
|
123
|
+
environments=args.environments,
|
|
124
|
+
projects=args.projects,
|
|
125
|
+
fmt=_parse_format(args.format),
|
|
126
|
+
description=args.description,
|
|
127
|
+
scope=scope,
|
|
128
|
+
)
|
|
129
|
+
webhook = created["webhook"]
|
|
130
|
+
_out(f"Webhook {webhook['id']} created for {webhook['url']}\n")
|
|
131
|
+
_out(f"Signing secret: {created['secret']}\n")
|
|
132
|
+
_err(
|
|
133
|
+
"Store the signing secret now: it is shown only once. "
|
|
134
|
+
"Use it to verify the envpass-signature header.\n"
|
|
135
|
+
)
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
if action == "remove":
|
|
139
|
+
webhook_id = _require_id(args, "Usage: envpass webhooks remove <id>")
|
|
140
|
+
client.delete_webhook(webhook_id, scope)
|
|
141
|
+
_err(f"Removed webhook {webhook_id}\n")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
if action == "test":
|
|
145
|
+
webhook_id = _require_id(args, "Usage: envpass webhooks test <id>")
|
|
146
|
+
result = client.test_webhook(webhook_id, scope)
|
|
147
|
+
_out(_describe_test(result))
|
|
148
|
+
return 0 if result["ok"] else 1
|
|
149
|
+
|
|
150
|
+
if action == "deliveries":
|
|
151
|
+
webhook_id = _require_id(args, "Usage: envpass webhooks deliveries <id> [--limit N]")
|
|
152
|
+
deliveries = client.list_webhook_deliveries(
|
|
153
|
+
webhook_id, limit=_parse_limit(args.limit), scope=scope
|
|
154
|
+
)
|
|
155
|
+
if args.json:
|
|
156
|
+
_out(_json(deliveries))
|
|
157
|
+
return 0
|
|
158
|
+
if not deliveries:
|
|
159
|
+
_err("No deliveries yet.\n")
|
|
160
|
+
return 0
|
|
161
|
+
rows = [
|
|
162
|
+
[
|
|
163
|
+
d["id"],
|
|
164
|
+
d["eventType"],
|
|
165
|
+
d["status"],
|
|
166
|
+
"-" if d["responseStatus"] is None else str(d["responseStatus"]),
|
|
167
|
+
str(d["attempts"]),
|
|
168
|
+
"-" if d["durationMs"] is None else f"{d['durationMs']}ms",
|
|
169
|
+
d["createdAt"],
|
|
170
|
+
_one_line(d["error"]),
|
|
171
|
+
]
|
|
172
|
+
for d in deliveries
|
|
173
|
+
]
|
|
174
|
+
header = ["ID", "EVENT", "STATUS", "CODE", "ATTEMPTS", "DURATION", "CREATED", "ERROR"]
|
|
175
|
+
_out(table(header, rows))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
raise ValueError(WEBHOOKS_USAGE)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def syncs_command(client: Envpass, args: argparse.Namespace, flags: ProjectLink) -> int:
|
|
182
|
+
action = getattr(args, "action", None)
|
|
183
|
+
scope = ProjectLink(workspace=flags.get("workspace"), project=flags.get("project"))
|
|
184
|
+
if action == "list":
|
|
185
|
+
syncs = client.list_syncs(scope)
|
|
186
|
+
if args.json:
|
|
187
|
+
_out(_json(syncs))
|
|
188
|
+
return 0
|
|
189
|
+
if not syncs:
|
|
190
|
+
_err("No syncs.\n")
|
|
191
|
+
return 0
|
|
192
|
+
rows = [
|
|
193
|
+
[
|
|
194
|
+
sync["id"],
|
|
195
|
+
sync["destination"],
|
|
196
|
+
sync["environmentSlug"],
|
|
197
|
+
sync["status"] if sync["enabled"] else "paused",
|
|
198
|
+
sync["lastSyncedAt"] or "never",
|
|
199
|
+
_one_line(sync["lastError"]),
|
|
200
|
+
]
|
|
201
|
+
for sync in syncs
|
|
202
|
+
]
|
|
203
|
+
header = ["ID", "DESTINATION", "ENVIRONMENT", "STATUS", "LAST SYNCED", "LAST ERROR"]
|
|
204
|
+
_out(table(header, rows))
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
if action == "run":
|
|
208
|
+
sync_id = _require_id(args, "Usage: envpass syncs run <id>")
|
|
209
|
+
client.run_sync(sync_id, scope)
|
|
210
|
+
_err(f"Queued sync {sync_id}\n")
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
if action in ("pause", "resume"):
|
|
214
|
+
sync_id = _require_id(args, f"Usage: envpass syncs {action} <id>")
|
|
215
|
+
client.set_sync_enabled(sync_id, action == "resume", scope)
|
|
216
|
+
_err(f"{'Resumed' if action == 'resume' else 'Paused'} sync {sync_id}\n")
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
raise ValueError(SYNCS_USAGE)
|
|
@@ -1,156 +0,0 @@
|
|
|
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}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|