envpass-cli 0.1.5__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.
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: envpass-cli
3
- Version: 0.1.5
4
- Summary: Client and CLI for envpass: encrypted environment variables for every project, service and environment.
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, service and environment. No dependencies.
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`, `ENVPASS_SERVICE` and `ENVPASS_URL`.
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", "service": "api"})
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, service and environment. No dependencies.
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`, `ENVPASS_SERVICE` and `ENVPASS_URL`.
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", "service": "api"})
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.1.5"
4
- description = "Client and CLI for envpass: encrypted environment variables for every project, service and environment."
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.1.5"
4
- description = "Client and CLI for envpass: encrypted environment variables for every project, service and environment."
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, service and environment.",
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.workspace,
123
- project=args.project,
124
- environment=args.environment,
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", "service"):
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}