depesha 0.1.0__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.
- depesha-0.1.0/PKG-INFO +35 -0
- depesha-0.1.0/README.md +17 -0
- depesha-0.1.0/depesha.egg-info/PKG-INFO +35 -0
- depesha-0.1.0/depesha.egg-info/SOURCES.txt +44 -0
- depesha-0.1.0/depesha.egg-info/dependency_links.txt +1 -0
- depesha-0.1.0/depesha.egg-info/entry_points.txt +2 -0
- depesha-0.1.0/depesha.egg-info/requires.txt +9 -0
- depesha-0.1.0/depesha.egg-info/top_level.txt +1 -0
- depesha-0.1.0/depesha_cli/__init__.py +1 -0
- depesha-0.1.0/depesha_cli/__main__.py +58 -0
- depesha-0.1.0/depesha_cli/auth.py +94 -0
- depesha-0.1.0/depesha_cli/cloudflare.py +124 -0
- depesha-0.1.0/depesha_cli/commands/__init__.py +20 -0
- depesha-0.1.0/depesha_cli/commands/_run.py +106 -0
- depesha-0.1.0/depesha_cli/commands/admin.py +95 -0
- depesha-0.1.0/depesha_cli/commands/auth.py +133 -0
- depesha-0.1.0/depesha_cli/commands/domain.py +192 -0
- depesha-0.1.0/depesha_cli/commands/gql.py +27 -0
- depesha-0.1.0/depesha_cli/commands/key.py +92 -0
- depesha-0.1.0/depesha_cli/commands/messages.py +65 -0
- depesha-0.1.0/depesha_cli/commands/project.py +63 -0
- depesha-0.1.0/depesha_cli/commands/recipient.py +45 -0
- depesha-0.1.0/depesha_cli/commands/schema.py +35 -0
- depesha-0.1.0/depesha_cli/commands/send.py +120 -0
- depesha-0.1.0/depesha_cli/commands/skill.py +192 -0
- depesha-0.1.0/depesha_cli/commands/suppressions.py +78 -0
- depesha-0.1.0/depesha_cli/commands/templates.py +156 -0
- depesha-0.1.0/depesha_cli/credentials.py +146 -0
- depesha-0.1.0/depesha_cli/errors.py +53 -0
- depesha-0.1.0/depesha_cli/introspection.py +243 -0
- depesha-0.1.0/depesha_cli/io.py +102 -0
- depesha-0.1.0/depesha_cli/skills/depesha/SKILL.md +52 -0
- depesha-0.1.0/depesha_cli/skills/depesha/references/cli.md +43 -0
- depesha-0.1.0/depesha_cli/skills/depesha/references/domains.md +29 -0
- depesha-0.1.0/depesha_cli/skills/depesha/references/sending.md +139 -0
- depesha-0.1.0/depesha_cli/skills/depesha/references/templates.md +61 -0
- depesha-0.1.0/depesha_cli/template_files.py +101 -0
- depesha-0.1.0/depesha_cli/transport.py +52 -0
- depesha-0.1.0/pyproject.toml +48 -0
- depesha-0.1.0/setup.cfg +4 -0
- depesha-0.1.0/tests/test_admin.py +193 -0
- depesha-0.1.0/tests/test_cli.py +72 -0
- depesha-0.1.0/tests/test_commands.py +398 -0
- depesha-0.1.0/tests/test_credentials.py +95 -0
- depesha-0.1.0/tests/test_skill.py +116 -0
- depesha-0.1.0/tests/test_transport.py +44 -0
depesha-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: depesha
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Depesha CLI: transactional email from the shell, with the Claude skill built in
|
|
5
|
+
Author: Depesha
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://depesha.com
|
|
8
|
+
Keywords: depesha,email,transactional,graphql,cli,agents
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Provides-Extra: admin
|
|
12
|
+
Requires-Dist: httpx>=0.28; extra == "admin"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: respx>=0.22; extra == "dev"
|
|
16
|
+
Requires-Dist: httpx>=0.28; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# depesha
|
|
20
|
+
|
|
21
|
+
The command-line client for [Depesha](https://depesha.com), transactional email for every project you run, with the `/depesha` Claude skill built in.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv tool install depesha # or: pipx install depesha
|
|
25
|
+
depesha skill install # the skill, into ~/.claude/skills/depesha
|
|
26
|
+
depesha login --token-file key.txt # a dp_live_, dp_test_ or dp_op_ key
|
|
27
|
+
depesha templates push templates/
|
|
28
|
+
depesha send sign-in-code --to you@example.com --var code=428913 --var-json expires_minutes=10
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`depesha <noun> <verb>` maps to GraphQL against `https://api.depesha.com/graphql`; `depesha gql` and `depesha schema --sdl` reach the whole API. Output is JSON; exit codes are 0 ok, 1 API error (with `extensions.code`), 2 transport, 3 usage or configuration. Credentials live in `~/.depesha/credentials.json` (mode 600); `DEPESHA_TOKEN`, `DEPESHA_URL`, `DEPESHA_PROFILE` and `DEPESHA_PROJECT` override them.
|
|
32
|
+
|
|
33
|
+
The core uses only the standard library. `depesha admin` (operator only, on the owner's machine) needs `depesha[admin]`.
|
|
34
|
+
|
|
35
|
+
Full reference: `depesha skill show references/cli.md`.
|
depesha-0.1.0/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# depesha
|
|
2
|
+
|
|
3
|
+
The command-line client for [Depesha](https://depesha.com), transactional email for every project you run, with the `/depesha` Claude skill built in.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
uv tool install depesha # or: pipx install depesha
|
|
7
|
+
depesha skill install # the skill, into ~/.claude/skills/depesha
|
|
8
|
+
depesha login --token-file key.txt # a dp_live_, dp_test_ or dp_op_ key
|
|
9
|
+
depesha templates push templates/
|
|
10
|
+
depesha send sign-in-code --to you@example.com --var code=428913 --var-json expires_minutes=10
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`depesha <noun> <verb>` maps to GraphQL against `https://api.depesha.com/graphql`; `depesha gql` and `depesha schema --sdl` reach the whole API. Output is JSON; exit codes are 0 ok, 1 API error (with `extensions.code`), 2 transport, 3 usage or configuration. Credentials live in `~/.depesha/credentials.json` (mode 600); `DEPESHA_TOKEN`, `DEPESHA_URL`, `DEPESHA_PROFILE` and `DEPESHA_PROJECT` override them.
|
|
14
|
+
|
|
15
|
+
The core uses only the standard library. `depesha admin` (operator only, on the owner's machine) needs `depesha[admin]`.
|
|
16
|
+
|
|
17
|
+
Full reference: `depesha skill show references/cli.md`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: depesha
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Depesha CLI: transactional email from the shell, with the Claude skill built in
|
|
5
|
+
Author: Depesha
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://depesha.com
|
|
8
|
+
Keywords: depesha,email,transactional,graphql,cli,agents
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Provides-Extra: admin
|
|
12
|
+
Requires-Dist: httpx>=0.28; extra == "admin"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
15
|
+
Requires-Dist: respx>=0.22; extra == "dev"
|
|
16
|
+
Requires-Dist: httpx>=0.28; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# depesha
|
|
20
|
+
|
|
21
|
+
The command-line client for [Depesha](https://depesha.com), transactional email for every project you run, with the `/depesha` Claude skill built in.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv tool install depesha # or: pipx install depesha
|
|
25
|
+
depesha skill install # the skill, into ~/.claude/skills/depesha
|
|
26
|
+
depesha login --token-file key.txt # a dp_live_, dp_test_ or dp_op_ key
|
|
27
|
+
depesha templates push templates/
|
|
28
|
+
depesha send sign-in-code --to you@example.com --var code=428913 --var-json expires_minutes=10
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`depesha <noun> <verb>` maps to GraphQL against `https://api.depesha.com/graphql`; `depesha gql` and `depesha schema --sdl` reach the whole API. Output is JSON; exit codes are 0 ok, 1 API error (with `extensions.code`), 2 transport, 3 usage or configuration. Credentials live in `~/.depesha/credentials.json` (mode 600); `DEPESHA_TOKEN`, `DEPESHA_URL`, `DEPESHA_PROFILE` and `DEPESHA_PROJECT` override them.
|
|
32
|
+
|
|
33
|
+
The core uses only the standard library. `depesha admin` (operator only, on the owner's machine) needs `depesha[admin]`.
|
|
34
|
+
|
|
35
|
+
Full reference: `depesha skill show references/cli.md`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
depesha.egg-info/PKG-INFO
|
|
4
|
+
depesha.egg-info/SOURCES.txt
|
|
5
|
+
depesha.egg-info/dependency_links.txt
|
|
6
|
+
depesha.egg-info/entry_points.txt
|
|
7
|
+
depesha.egg-info/requires.txt
|
|
8
|
+
depesha.egg-info/top_level.txt
|
|
9
|
+
depesha_cli/__init__.py
|
|
10
|
+
depesha_cli/__main__.py
|
|
11
|
+
depesha_cli/auth.py
|
|
12
|
+
depesha_cli/cloudflare.py
|
|
13
|
+
depesha_cli/credentials.py
|
|
14
|
+
depesha_cli/errors.py
|
|
15
|
+
depesha_cli/introspection.py
|
|
16
|
+
depesha_cli/io.py
|
|
17
|
+
depesha_cli/template_files.py
|
|
18
|
+
depesha_cli/transport.py
|
|
19
|
+
depesha_cli/commands/__init__.py
|
|
20
|
+
depesha_cli/commands/_run.py
|
|
21
|
+
depesha_cli/commands/admin.py
|
|
22
|
+
depesha_cli/commands/auth.py
|
|
23
|
+
depesha_cli/commands/domain.py
|
|
24
|
+
depesha_cli/commands/gql.py
|
|
25
|
+
depesha_cli/commands/key.py
|
|
26
|
+
depesha_cli/commands/messages.py
|
|
27
|
+
depesha_cli/commands/project.py
|
|
28
|
+
depesha_cli/commands/recipient.py
|
|
29
|
+
depesha_cli/commands/schema.py
|
|
30
|
+
depesha_cli/commands/send.py
|
|
31
|
+
depesha_cli/commands/skill.py
|
|
32
|
+
depesha_cli/commands/suppressions.py
|
|
33
|
+
depesha_cli/commands/templates.py
|
|
34
|
+
depesha_cli/skills/depesha/SKILL.md
|
|
35
|
+
depesha_cli/skills/depesha/references/cli.md
|
|
36
|
+
depesha_cli/skills/depesha/references/domains.md
|
|
37
|
+
depesha_cli/skills/depesha/references/sending.md
|
|
38
|
+
depesha_cli/skills/depesha/references/templates.md
|
|
39
|
+
tests/test_admin.py
|
|
40
|
+
tests/test_cli.py
|
|
41
|
+
tests/test_commands.py
|
|
42
|
+
tests/test_credentials.py
|
|
43
|
+
tests/test_skill.py
|
|
44
|
+
tests/test_transport.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
depesha_cli
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from . import __version__, auth, commands
|
|
5
|
+
from .commands.skill import ADVERT
|
|
6
|
+
from .errors import DepeshaCliError
|
|
7
|
+
|
|
8
|
+
DOCS_HINT = (
|
|
9
|
+
"Run 'depesha --help' for the command list and 'depesha <noun> --help' for a group's verbs. "
|
|
10
|
+
"Docs: https://pypi.org/project/depesha/ · Depesha wiki page 'depesha/api/cli-reference'."
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DepeshaArgumentParser(argparse.ArgumentParser):
|
|
15
|
+
def error(self, message):
|
|
16
|
+
self.print_usage(sys.stderr)
|
|
17
|
+
self.exit(3, f"{self.prog}: error: {message}\n{DOCS_HINT}\n")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> DepeshaArgumentParser:
|
|
21
|
+
parser = DepeshaArgumentParser(
|
|
22
|
+
prog="depesha",
|
|
23
|
+
description="Depesha CLI: transactional email from the shell (argv -> GraphQL).",
|
|
24
|
+
epilog=(
|
|
25
|
+
"Exit codes: 0 ok, 1 GraphQL error (extensions.code), 2 transport, 3 usage or config. "
|
|
26
|
+
f"{DOCS_HINT} {ADVERT}."
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--version", action="version", version=f"depesha {__version__}")
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--profile",
|
|
32
|
+
metavar="NAME",
|
|
33
|
+
default=None,
|
|
34
|
+
help="credential profile from ~/.depesha/credentials.json (default: DEPESHA_PROFILE, else the current one)",
|
|
35
|
+
)
|
|
36
|
+
subparsers = parser.add_subparsers(
|
|
37
|
+
dest="command", metavar="<command>", required=True, parser_class=DepeshaArgumentParser
|
|
38
|
+
)
|
|
39
|
+
commands.register(subparsers)
|
|
40
|
+
help_parser = subparsers.add_parser("help", help="show this help message and exit")
|
|
41
|
+
help_parser.set_defaults(func=lambda _args: (parser.print_help(), 0)[1])
|
|
42
|
+
return parser
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv=None) -> int:
|
|
46
|
+
parser = build_parser()
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
auth.set_profile_override(args.profile)
|
|
49
|
+
try:
|
|
50
|
+
result = args.func(args)
|
|
51
|
+
except DepeshaCliError as exc:
|
|
52
|
+
print(exc.render(), file=sys.stderr)
|
|
53
|
+
return exc.exit_code
|
|
54
|
+
return int(result or 0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sys.exit(main())
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
4
|
+
|
|
5
|
+
from .credentials import DEFAULT_PROFILE, CredentialStore
|
|
6
|
+
from .errors import ConfigError
|
|
7
|
+
|
|
8
|
+
DEFAULT_URL = "https://api.depesha.com/graphql"
|
|
9
|
+
GRAPHQL_PATH = "/graphql"
|
|
10
|
+
LOGIN_HINT = "No Depesha credentials found. Run: depesha login (or set DEPESHA_TOKEN)"
|
|
11
|
+
KEY_PREFIXES = {"dp_live_": "live", "dp_test_": "test", "dp_op_": "operator"}
|
|
12
|
+
|
|
13
|
+
_profile_override: str | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def set_profile_override(name: str | None) -> None:
|
|
17
|
+
global _profile_override
|
|
18
|
+
_profile_override = name or None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def profile_name(payload: dict | None = None) -> str:
|
|
22
|
+
explicit = _profile_override or os.environ.get("DEPESHA_PROFILE")
|
|
23
|
+
if explicit:
|
|
24
|
+
return explicit
|
|
25
|
+
if payload is not None and payload.get("current_profile"):
|
|
26
|
+
return payload["current_profile"]
|
|
27
|
+
return DEFAULT_PROFILE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def key_kind(token: str) -> str | None:
|
|
31
|
+
for prefix, kind in KEY_PREFIXES.items():
|
|
32
|
+
if token.startswith(prefix):
|
|
33
|
+
return kind
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def key_prefix(token: str) -> str:
|
|
38
|
+
for prefix in KEY_PREFIXES:
|
|
39
|
+
if token.startswith(prefix):
|
|
40
|
+
return token[: len(prefix) + 8]
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def graphql_url(url: str) -> str:
|
|
45
|
+
parts = urlsplit(url.strip())
|
|
46
|
+
if parts.scheme not in ("http", "https") or not parts.netloc:
|
|
47
|
+
raise ConfigError(f"Not an http(s) URL: {url!r}")
|
|
48
|
+
path = parts.path.rstrip("/")
|
|
49
|
+
if not path.endswith(GRAPHQL_PATH):
|
|
50
|
+
path = path + GRAPHQL_PATH
|
|
51
|
+
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class Credential:
|
|
56
|
+
url: str
|
|
57
|
+
token: str
|
|
58
|
+
source: str
|
|
59
|
+
profile_name: str | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def kind(self) -> str | None:
|
|
63
|
+
return key_kind(self.token)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def select(store: CredentialStore | None = None) -> Credential:
|
|
67
|
+
store = store or CredentialStore()
|
|
68
|
+
env_url = os.environ.get("DEPESHA_URL")
|
|
69
|
+
env_token = os.environ.get("DEPESHA_TOKEN")
|
|
70
|
+
if env_token:
|
|
71
|
+
payload = store.read(strict=False)
|
|
72
|
+
profile = payload["profiles"].get(profile_name(payload)) or {}
|
|
73
|
+
url = env_url or profile.get("url") or DEFAULT_URL
|
|
74
|
+
return Credential(graphql_url(url), env_token, "DEPESHA_TOKEN env")
|
|
75
|
+
payload = store.read()
|
|
76
|
+
name = profile_name(payload)
|
|
77
|
+
profile = payload["profiles"].get(name)
|
|
78
|
+
if not profile or not profile.get("token"):
|
|
79
|
+
if payload["profiles"]:
|
|
80
|
+
raise ConfigError(f"No Depesha credentials for profile {name!r}. Run: depesha login --profile {name}")
|
|
81
|
+
raise ConfigError(LOGIN_HINT)
|
|
82
|
+
url = env_url or profile.get("url") or DEFAULT_URL
|
|
83
|
+
return Credential(graphql_url(url), profile["token"], f"profile {name!r} ({store.path})", name)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def default_project() -> str | None:
|
|
87
|
+
env_project = os.environ.get("DEPESHA_PROJECT")
|
|
88
|
+
if env_project:
|
|
89
|
+
return env_project
|
|
90
|
+
if os.environ.get("DEPESHA_TOKEN"):
|
|
91
|
+
return None
|
|
92
|
+
store = CredentialStore()
|
|
93
|
+
payload = store.read(strict=False)
|
|
94
|
+
return (payload["profiles"].get(profile_name(payload)) or {}).get("project")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
|
|
3
|
+
from .errors import ConfigError, TransportError
|
|
4
|
+
|
|
5
|
+
API = "https://api.cloudflare.com/client/v4"
|
|
6
|
+
TIMEOUT = 30.0
|
|
7
|
+
CREATED = "created"
|
|
8
|
+
UPDATED = "updated"
|
|
9
|
+
UNCHANGED = "unchanged"
|
|
10
|
+
CONFLICT = "conflict"
|
|
11
|
+
PLANNED_CREATE = "would create"
|
|
12
|
+
PLANNED_UPDATE = "would update"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Cloudflare:
|
|
16
|
+
def __init__(self, token: str, client: httpx.Client | None = None):
|
|
17
|
+
self.client = client or httpx.Client(
|
|
18
|
+
base_url=API, timeout=TIMEOUT, headers={"Authorization": f"Bearer {token}"}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def request(self, method: str, path: str, **kwargs) -> dict | list:
|
|
22
|
+
try:
|
|
23
|
+
response = self.client.request(method, path, **kwargs)
|
|
24
|
+
except httpx.HTTPError as exc:
|
|
25
|
+
raise TransportError(f"Could not reach Cloudflare: {exc}") from exc
|
|
26
|
+
if response.status_code >= 400:
|
|
27
|
+
raise TransportError(
|
|
28
|
+
f"Cloudflare answered HTTP {response.status_code} to {method} {path}",
|
|
29
|
+
status=response.status_code,
|
|
30
|
+
body=cloudflare_errors(response),
|
|
31
|
+
)
|
|
32
|
+
return response.json()["result"]
|
|
33
|
+
|
|
34
|
+
def find_zone(self, domain: str) -> dict:
|
|
35
|
+
labels = domain.split(".")
|
|
36
|
+
for start in range(len(labels) - 1):
|
|
37
|
+
candidate = ".".join(labels[start:])
|
|
38
|
+
zones = self.request("GET", "/zones", params={"name": candidate})
|
|
39
|
+
if zones:
|
|
40
|
+
return zones[0]
|
|
41
|
+
raise ConfigError(f"No Cloudflare zone for {domain} is visible to this token")
|
|
42
|
+
|
|
43
|
+
def records(self, zone_id: str, name: str) -> list[dict]:
|
|
44
|
+
return self.request("GET", f"/zones/{zone_id}/dns_records", params={"name": name, "per_page": 100})
|
|
45
|
+
|
|
46
|
+
def create(self, zone_id: str, record: dict) -> dict:
|
|
47
|
+
return self.request("POST", f"/zones/{zone_id}/dns_records", json=record)
|
|
48
|
+
|
|
49
|
+
def update(self, zone_id: str, record_id: str, changes: dict) -> dict:
|
|
50
|
+
return self.request("PATCH", f"/zones/{zone_id}/dns_records/{record_id}", json=changes)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cloudflare_errors(response: httpx.Response) -> str:
|
|
54
|
+
try:
|
|
55
|
+
errors = response.json().get("errors") or []
|
|
56
|
+
except ValueError:
|
|
57
|
+
return response.text[:500]
|
|
58
|
+
return "; ".join(f"{e.get('code')}: {e.get('message')}" for e in errors) or response.text[:500]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def normalize(record_type: str, content: str) -> str:
|
|
62
|
+
value = content.strip()
|
|
63
|
+
if record_type == "CNAME":
|
|
64
|
+
return value.rstrip(".").lower()
|
|
65
|
+
return value.strip('"')
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def plan_record(action: dict, existing: list[dict], tag: str) -> tuple[str, dict | None, str | None]:
|
|
69
|
+
wanted_type = action["type"]
|
|
70
|
+
wanted = normalize(wanted_type, action["content"])
|
|
71
|
+
same_type = [r for r in existing if r["type"] == wanted_type]
|
|
72
|
+
others = [r for r in existing if r["type"] != wanted_type]
|
|
73
|
+
if wanted_type == "CNAME" and others:
|
|
74
|
+
kinds = ", ".join(sorted({r["type"] for r in others}))
|
|
75
|
+
return CONFLICT, None, f"{action['name']} already has {kinds} records; a CNAME can't sit next to them"
|
|
76
|
+
if wanted_type == "TXT" and any(normalize("TXT", r["content"]).startswith("v=DMARC1") for r in same_type):
|
|
77
|
+
return CONFLICT, None, f"{action['name']} already has a DMARC record; Depesha never changes it"
|
|
78
|
+
for record in same_type:
|
|
79
|
+
if normalize(wanted_type, record["content"]) != wanted:
|
|
80
|
+
continue
|
|
81
|
+
if not record.get("proxied"):
|
|
82
|
+
return UNCHANGED, record, None
|
|
83
|
+
if record.get("comment") == tag:
|
|
84
|
+
return UPDATED, record, "switch to DNS only"
|
|
85
|
+
return CONFLICT, record, f"{action['name']} is proxied (orange cloud); switch it to DNS only by hand"
|
|
86
|
+
ours = [r for r in same_type if r.get("comment") == tag]
|
|
87
|
+
if ours:
|
|
88
|
+
return UPDATED, ours[0], None
|
|
89
|
+
if same_type and wanted_type == "CNAME":
|
|
90
|
+
found = ", ".join(r["content"] for r in same_type)
|
|
91
|
+
return CONFLICT, None, f"{action['name']} points to {found}, which Depesha didn't create; left untouched"
|
|
92
|
+
return CREATED, None, None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def apply_actions(cf: Cloudflare, zone: dict, domain: dict, actions: list[dict], *, dry_run: bool) -> list[dict]:
|
|
96
|
+
tag = f"depesha:{domain['id']}"
|
|
97
|
+
results = []
|
|
98
|
+
for action in actions:
|
|
99
|
+
outcome, record, detail = plan_record(action, cf.records(zone["id"], action["name"]), tag)
|
|
100
|
+
row = {"type": action["type"], "name": action["name"], "content": action["content"], "result": outcome}
|
|
101
|
+
if detail:
|
|
102
|
+
row["detail"] = detail
|
|
103
|
+
if outcome == CREATED:
|
|
104
|
+
if dry_run:
|
|
105
|
+
row["result"] = PLANNED_CREATE
|
|
106
|
+
else:
|
|
107
|
+
cf.create(
|
|
108
|
+
zone["id"],
|
|
109
|
+
{
|
|
110
|
+
"type": action["type"],
|
|
111
|
+
"name": action["name"],
|
|
112
|
+
"content": action["content"],
|
|
113
|
+
"ttl": 1,
|
|
114
|
+
"proxied": False,
|
|
115
|
+
"comment": tag,
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
elif outcome == UPDATED:
|
|
119
|
+
if dry_run:
|
|
120
|
+
row["result"] = PLANNED_UPDATE
|
|
121
|
+
else:
|
|
122
|
+
cf.update(zone["id"], record["id"], {"content": action["content"], "proxied": False, "comment": tag})
|
|
123
|
+
results.append(row)
|
|
124
|
+
return results
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from . import admin, auth, domain, gql, key, messages, project, recipient, schema, send, skill, suppressions, templates
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def register(subparsers) -> None:
|
|
5
|
+
for module in (
|
|
6
|
+
auth,
|
|
7
|
+
project,
|
|
8
|
+
key,
|
|
9
|
+
recipient,
|
|
10
|
+
domain,
|
|
11
|
+
templates,
|
|
12
|
+
send,
|
|
13
|
+
messages,
|
|
14
|
+
suppressions,
|
|
15
|
+
gql,
|
|
16
|
+
schema,
|
|
17
|
+
skill,
|
|
18
|
+
admin,
|
|
19
|
+
):
|
|
20
|
+
module.register(subparsers)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ..auth import default_project, select
|
|
6
|
+
from ..errors import ConfigError
|
|
7
|
+
from ..io import format_output
|
|
8
|
+
from ..transport import execute
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fetch(document: str, variables: dict) -> dict:
|
|
12
|
+
credential = select()
|
|
13
|
+
return execute(document, variables, url=credential.url, token=credential.token)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def fetch_data(document: str, variables: dict) -> dict:
|
|
17
|
+
return fetch(document, variables).get("data") or {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def emit(args, envelope: dict) -> None:
|
|
21
|
+
print(format_output(envelope, raw=getattr(args, "raw", False), compact=getattr(args, "compact", False)))
|
|
22
|
+
sys.stdout.flush()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hint(message: str | None) -> None:
|
|
26
|
+
if message:
|
|
27
|
+
print(message, file=sys.stderr)
|
|
28
|
+
sys.stderr.flush()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_op(args, document: str, variables: dict, *, after=None) -> int:
|
|
32
|
+
envelope = fetch(document, variables)
|
|
33
|
+
emit(args, envelope)
|
|
34
|
+
if after is not None:
|
|
35
|
+
hint(after(envelope.get("data") or {}))
|
|
36
|
+
return 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def project_arg(args, *, required: bool) -> str | None:
|
|
40
|
+
project = getattr(args, "project", None) or default_project()
|
|
41
|
+
if required and not project:
|
|
42
|
+
raise ConfigError("--project is required (or set DEPESHA_PROJECT, or log in with a project key)")
|
|
43
|
+
return project
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def add_project_arg(parser, *, required: bool) -> None:
|
|
47
|
+
suffix = "" if required else "; a project key may omit it"
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--project",
|
|
50
|
+
metavar="SLUG",
|
|
51
|
+
help=f"project slug (default: DEPESHA_PROJECT, else the profile's project{suffix})",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_secret_file_arg(parser) -> None:
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--secret-file",
|
|
58
|
+
metavar="PATH",
|
|
59
|
+
help="write the new secret to PATH (mode 600, must not exist) instead of printing it",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def open_secret_file(path: str):
|
|
64
|
+
target = Path(path).expanduser()
|
|
65
|
+
try:
|
|
66
|
+
descriptor = os.open(target, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
|
|
67
|
+
except FileExistsError as exc:
|
|
68
|
+
raise ConfigError(f"Refusing to overwrite {target}: it already exists (nothing was created)") from exc
|
|
69
|
+
except OSError as exc:
|
|
70
|
+
raise ConfigError(f"Cannot create {target}: {exc} (nothing was created)") from exc
|
|
71
|
+
return target, os.fdopen(descriptor, "w")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_secret_op(args, document: str, variables: dict, path: list[str]) -> int:
|
|
75
|
+
secret_file = getattr(args, "secret_file", None)
|
|
76
|
+
if not secret_file:
|
|
77
|
+
emit(args, fetch(document, variables))
|
|
78
|
+
return 0
|
|
79
|
+
target, handle = open_secret_file(secret_file)
|
|
80
|
+
try:
|
|
81
|
+
envelope = fetch(document, variables)
|
|
82
|
+
except BaseException:
|
|
83
|
+
handle.close()
|
|
84
|
+
target.unlink()
|
|
85
|
+
raise
|
|
86
|
+
node = envelope.get("data") or {}
|
|
87
|
+
for key in path[:-1]:
|
|
88
|
+
node = node.get(key) or {}
|
|
89
|
+
secret = node.get(path[-1])
|
|
90
|
+
if not secret:
|
|
91
|
+
handle.close()
|
|
92
|
+
target.unlink()
|
|
93
|
+
emit(args, envelope)
|
|
94
|
+
hint("depesha: the API returned no secret, so nothing was written")
|
|
95
|
+
return 1
|
|
96
|
+
try:
|
|
97
|
+
with handle:
|
|
98
|
+
handle.write(secret + "\n")
|
|
99
|
+
except OSError as exc:
|
|
100
|
+
emit(args, envelope)
|
|
101
|
+
hint(f"depesha: could not write {target}: {exc}; the secret is printed above instead, copy it now")
|
|
102
|
+
return 1
|
|
103
|
+
del node[path[-1]]
|
|
104
|
+
node["secretFile"] = str(target)
|
|
105
|
+
emit(args, envelope)
|
|
106
|
+
return 0
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import importlib.util
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..errors import ConfigError
|
|
8
|
+
from ..io import read_path
|
|
9
|
+
from ._run import fetch_data
|
|
10
|
+
from .domain import resolve_domain_id
|
|
11
|
+
|
|
12
|
+
DEFAULT_TOKEN_FILE = "~/.cloudflared/cf-dns-token"
|
|
13
|
+
ADMIN_EXTRA_HINT = (
|
|
14
|
+
"depesha admin needs the [admin] extra: uv tool install 'depesha[admin]' (or pip install 'depesha[admin]')"
|
|
15
|
+
)
|
|
16
|
+
DOMAIN_QUERY = "query Domain($id: ID!) { domain(id: $id) { id name state actions { action type name content } } }"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(subparsers) -> None:
|
|
20
|
+
group = subparsers.add_parser(
|
|
21
|
+
"admin",
|
|
22
|
+
help="operator only, on the owner's machine: local credentials, never through the API",
|
|
23
|
+
description=(
|
|
24
|
+
"operator-only work with local credentials. Nothing here is exposed by the API; it runs where the "
|
|
25
|
+
"credentials are (the owner's machine)."
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
verbs = group.add_subparsers(dest="admin_verb", metavar="<verb>", required=True)
|
|
29
|
+
dns = verbs.add_parser(
|
|
30
|
+
"dns",
|
|
31
|
+
help="create a domain's CNAMEs in the owner's Cloudflare zone",
|
|
32
|
+
description=(
|
|
33
|
+
"read the domain's action list from the API (operator credential) and create each ADD record in the "
|
|
34
|
+
"owner's Cloudflare zone with the all-zones DNS token: DNS only, tagged depesha:<domain-id>. Records "
|
|
35
|
+
"Depesha didn't create are never changed; they are reported as conflicts (exit 1). The DMARC TXT is "
|
|
36
|
+
"only created with --dmarc, after the owner agreed."
|
|
37
|
+
),
|
|
38
|
+
)
|
|
39
|
+
dns.add_argument("domain", metavar="DOMAIN", help="the domain id, or its name with --project")
|
|
40
|
+
dns.add_argument("--project", metavar="SLUG", help="project slug, to look the domain up by name")
|
|
41
|
+
dns.add_argument(
|
|
42
|
+
"--token-file",
|
|
43
|
+
default=DEFAULT_TOKEN_FILE,
|
|
44
|
+
metavar="PATH",
|
|
45
|
+
help=f"Cloudflare DNS token (default: {DEFAULT_TOKEN_FILE})",
|
|
46
|
+
)
|
|
47
|
+
dns.add_argument("--dmarc", action="store_true", help="also create the offered _dmarc TXT (owner consent)")
|
|
48
|
+
dns.add_argument("--dry-run", action="store_true", help="show what would change; write nothing")
|
|
49
|
+
dns.set_defaults(func=run_dns)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_cloudflare():
|
|
53
|
+
if importlib.util.find_spec("httpx") is None:
|
|
54
|
+
raise ConfigError(ADMIN_EXTRA_HINT)
|
|
55
|
+
return importlib.import_module("depesha_cli.cloudflare")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_token(path: str) -> str:
|
|
59
|
+
resolved = str(Path(path).expanduser())
|
|
60
|
+
if not Path(resolved).is_file():
|
|
61
|
+
raise ConfigError(f"No Cloudflare token at {resolved}; depesha admin runs on the owner's machine")
|
|
62
|
+
token = read_path(resolved).strip()
|
|
63
|
+
if not token:
|
|
64
|
+
raise ConfigError(f"{resolved} is empty")
|
|
65
|
+
return token
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def run_dns(args) -> int:
|
|
69
|
+
cloudflare = load_cloudflare()
|
|
70
|
+
token = read_token(args.token_file)
|
|
71
|
+
domain = fetch_data(DOMAIN_QUERY, {"id": resolve_domain_id(args, args.domain)})["domain"]
|
|
72
|
+
actions = [
|
|
73
|
+
action
|
|
74
|
+
for action in domain["actions"]
|
|
75
|
+
if action["action"] == "ADD" and (action["type"] == "CNAME" or (args.dmarc and action["type"] == "TXT"))
|
|
76
|
+
]
|
|
77
|
+
client = cloudflare.Cloudflare(token)
|
|
78
|
+
zone = client.find_zone(domain["name"])
|
|
79
|
+
records = cloudflare.apply_actions(client, zone, domain, actions, dry_run=args.dry_run)
|
|
80
|
+
report = {
|
|
81
|
+
"domain": domain["name"],
|
|
82
|
+
"domainId": domain["id"],
|
|
83
|
+
"zone": zone["name"],
|
|
84
|
+
"dryRun": args.dry_run,
|
|
85
|
+
"records": records,
|
|
86
|
+
}
|
|
87
|
+
print(json.dumps(report, indent=2))
|
|
88
|
+
sys.stdout.flush()
|
|
89
|
+
conflicts = [r for r in records if r["result"] == cloudflare.CONFLICT]
|
|
90
|
+
if conflicts:
|
|
91
|
+
print(f"depesha: {len(conflicts)} record(s) left untouched; fix them by hand", file=sys.stderr)
|
|
92
|
+
return 1
|
|
93
|
+
if not args.dry_run:
|
|
94
|
+
print(f"Next: depesha domain verify {domain['id']}", file=sys.stderr)
|
|
95
|
+
return 0
|