npmctl-cloudflare 0.3.6__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.
@@ -0,0 +1 @@
1
+ Apache-2.0
@@ -0,0 +1,191 @@
1
+ Metadata-Version: 2.4
2
+ Name: npmctl-cloudflare
3
+ Version: 0.3.6
4
+ Summary: Cloudflare DNS provider extension for npmctl.
5
+ Keywords: cloudflare,dns,nginx-proxy-manager,npmctl
6
+ Author: Jacob Stewart
7
+ Author-email: Jacob Stewart <jacob@swarmauri.com>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 1 - Planning
11
+ Classifier: Intended Audience :: System Administrators
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: Name Service (DNS)
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Requires-Dist: requests>=2.32.0
22
+ Requires-Python: >=3.10, <3.15
23
+ Project-URL: Homepage, https://github.com/groupsum/npmctl
24
+ Project-URL: Repository, https://github.com/groupsum/npmctl
25
+ Project-URL: Documentation, https://github.com/groupsum/npmctl/tree/master/packages/npmctl-cloudflare
26
+ Project-URL: Issues, https://github.com/groupsum/npmctl/issues
27
+ Description-Content-Type: text/markdown
28
+
29
+ <h1 align="center">npmctl-cloudflare</h1>
30
+
31
+ <p align="center"><strong>Cloudflare DNS provider plugin for npmctl</strong></p>
32
+
33
+ <p align="center">
34
+ Extend <code>npmctl</code> with Cloudflare-backed DNS record management for declarative workflows, provider discovery, and DNS-aware automation.
35
+ </p>
36
+
37
+ <p align="center">
38
+ <a href="https://pypi.org/project/npmctl-cloudflare/"><img src="https://img.shields.io/pypi/v/npmctl-cloudflare.svg" alt="PyPI version"></a>
39
+ <a href="https://pypi.org/project/npmctl-cloudflare/"><img src="https://img.shields.io/pypi/pyversions/npmctl-cloudflare.svg" alt="Python versions"></a>
40
+ <a href="https://github.com/groupsum/npmctl/actions/workflows/ci.yml"><img src="https://github.com/groupsum/npmctl/actions/workflows/ci.yml/badge.svg?branch=master" alt="CI"></a>
41
+ <a href="https://github.com/groupsum/npmctl/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache 2.0 License"></a>
42
+ </p>
43
+
44
+ <p align="center">
45
+ <a href="https://hits.sh/github.com/groupsum/npmctl/blob/master/packages/npmctl-cloudflare/README.md/"><img src="https://hits.sh/github.com/groupsum/npmctl/blob/master/packages/npmctl-cloudflare/README.md.svg?label=npmctl-cloudflare%20package%20hits" alt="npmctl-cloudflare package hits"></a>
46
+ <a href="https://pepy.tech/projects/npmctl-cloudflare"><img src="https://static.pepy.tech/badge/npmctl-cloudflare" alt="npmctl-cloudflare downloads"></a>
47
+ </p>
48
+
49
+ <p align="center">
50
+ <img src="https://raw.githubusercontent.com/groupsum/npmctl/master/docs/images/marketing/npmctl-architecture-infographic.png" alt="npmctl architecture infographic">
51
+ </p>
52
+
53
+ `npmctl-cloudflare` is the Cloudflare DNS provider package for `npmctl`. Install it when you want desired-state DNS records or DNS diagnostics to resolve through Cloudflare instead of using only the base `npmctl` package.
54
+
55
+ ## Supported Python Versions
56
+
57
+ `npmctl-cloudflare` supports Python `3.10`, `3.11`, `3.12`, `3.13`, and `3.14`.
58
+
59
+ ## Why npmctl-cloudflare
60
+
61
+ - Adds Cloudflare DNS provider discovery to `npmctl`
62
+ - Lets DNS workflows live beside proxy and certificate desired state
63
+ - Keeps Cloudflare API tokens out of the core CLI package
64
+ - Supports operator diagnostics through `npmctl dns doctor`
65
+ - Provides client helpers for Cloudflare A and CNAME record workflows
66
+
67
+ ## FAQ
68
+
69
+ ### What is npmctl-cloudflare?
70
+
71
+ **Answer:** `npmctl-cloudflare` is a plugin package that teaches `npmctl` how to talk to the Cloudflare DNS Records API for DNS record operations and DNS provider diagnostics.
72
+
73
+ ### When do I need npmctl-cloudflare?
74
+
75
+ **Answer:** You need `npmctl-cloudflare` when your `npmctl` workflow includes Cloudflare-managed DNS records or when you want `npmctl` to validate Cloudflare DNS connectivity and credentials.
76
+
77
+ ### Does npmctl-cloudflare work without npmctl?
78
+
79
+ **Answer:** No. `npmctl-cloudflare` is an extension package for `npmctl`, not a standalone CLI.
80
+
81
+ ### Can npmctl-cloudflare set A and CNAME records?
82
+
83
+ **Answer:** Yes. The Cloudflare DNS Records API supports A and CNAME records, and this package exposes helpers for create, replace, patch, and delete operations.
84
+
85
+ ### What credentials are required?
86
+
87
+ **Answer:** Cloudflare API access requires `CLOUDFLARE_API_TOKEN`. For diagnostics, grant zone read and DNS read access. For record changes, grant DNS write access to the target zone.
88
+
89
+ ## Install
90
+
91
+ Install the base CLI and the Cloudflare provider package together:
92
+
93
+ ```bash
94
+ pipx install npmctl
95
+ pipx inject npmctl npmctl-cloudflare
96
+ npmctl plugins list
97
+ ```
98
+
99
+ With `uv`:
100
+
101
+ ```bash
102
+ uv tool install npmctl
103
+ uv tool install npmctl-cloudflare
104
+ npmctl plugins list
105
+ ```
106
+
107
+ Inside a virtual environment:
108
+
109
+ ```bash
110
+ python -m venv .venv
111
+ . .venv/bin/activate
112
+ python -m pip install npmctl npmctl-cloudflare
113
+ npmctl plugins list
114
+ ```
115
+
116
+ ## Configure Cloudflare
117
+
118
+ Set the required environment variable:
119
+
120
+ ```bash
121
+ export CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
122
+ ```
123
+
124
+ Optional for tests, proxies, or alternate endpoints:
125
+
126
+ ```bash
127
+ export CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
128
+ ```
129
+
130
+ ## Verify Plugin Discovery
131
+
132
+ Check that `npmctl` can discover the provider:
133
+
134
+ ```bash
135
+ npmctl plugins list
136
+ npmctl dns doctor --provider cloudflare
137
+ ```
138
+
139
+ ## Minimal DNS Workflow
140
+
141
+ Once the provider is installed and configured, `npmctl` can validate or diagnose Cloudflare-backed DNS behavior through the base CLI:
142
+
143
+ ```bash
144
+ npmctl dns providers
145
+ npmctl dns zones --provider cloudflare
146
+ npmctl dns records --provider cloudflare --zone example.com
147
+ ```
148
+
149
+ ## Cloudflare API Surface
150
+
151
+ The provider follows the Cloudflare DNS Records API:
152
+
153
+ - `GET /zones`: discover zones available to the token.
154
+ - `GET /zones/{zone_id}/dns_records`: list DNS records in one zone.
155
+ - `POST /zones/{zone_id}/dns_records`: create A, CNAME, and other supported records.
156
+ - `PUT /zones/{zone_id}/dns_records/{dns_record_id}`: overwrite an existing record.
157
+ - `PATCH /zones/{zone_id}/dns_records/{dns_record_id}`: partially update an existing record.
158
+ - `DELETE /zones/{zone_id}/dns_records/{dns_record_id}`: delete a record.
159
+
160
+ ## Programmatic Record Operations
161
+
162
+ The npmctl DNS provider contract requires `zones()` and `records(zone)`. This package also exposes client helpers for API-backed record mutation:
163
+
164
+ ```python
165
+ from npmctl_cloudflare import CloudflareClient, CloudflareConfig
166
+
167
+ client = CloudflareClient(CloudflareConfig.from_env())
168
+ record = client.create_record("example.com", type="A", name="www", value="192.0.2.10", ttl=300)
169
+ client.patch_record("example.com", str(record.record_id), value="192.0.2.11")
170
+ client.delete_record("example.com", str(record.record_id))
171
+ ```
172
+
173
+ CNAME creation uses the same method:
174
+
175
+ ```python
176
+ client.create_record("example.com", type="CNAME", name="app", value="target.example.net", ttl=300)
177
+ ```
178
+
179
+ ## Safety Notes
180
+
181
+ - Only operate on zones that are authoritative in Cloudflare.
182
+ - Use least-privilege API tokens scoped to the intended zone.
183
+ - Keep `CLOUDFLARE_API_TOKEN` out of desired-state files and logs.
184
+ - Cloudflare prevents CNAME records from coexisting with A or AAAA records on the same name.
185
+ - Use npmctl owner metadata for desired DNS records so future apply support can remain owner-scoped.
186
+
187
+ ## More Documentation
188
+
189
+ - Related PyPI package: https://pypi.org/project/npmctl/
190
+ - Repository: https://github.com/groupsum/npmctl
191
+ - DNS provider docs: https://github.com/groupsum/npmctl/tree/master/docs/dns-providers.md
@@ -0,0 +1,163 @@
1
+ <h1 align="center">npmctl-cloudflare</h1>
2
+
3
+ <p align="center"><strong>Cloudflare DNS provider plugin for npmctl</strong></p>
4
+
5
+ <p align="center">
6
+ Extend <code>npmctl</code> with Cloudflare-backed DNS record management for declarative workflows, provider discovery, and DNS-aware automation.
7
+ </p>
8
+
9
+ <p align="center">
10
+ <a href="https://pypi.org/project/npmctl-cloudflare/"><img src="https://img.shields.io/pypi/v/npmctl-cloudflare.svg" alt="PyPI version"></a>
11
+ <a href="https://pypi.org/project/npmctl-cloudflare/"><img src="https://img.shields.io/pypi/pyversions/npmctl-cloudflare.svg" alt="Python versions"></a>
12
+ <a href="https://github.com/groupsum/npmctl/actions/workflows/ci.yml"><img src="https://github.com/groupsum/npmctl/actions/workflows/ci.yml/badge.svg?branch=master" alt="CI"></a>
13
+ <a href="https://github.com/groupsum/npmctl/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="Apache 2.0 License"></a>
14
+ </p>
15
+
16
+ <p align="center">
17
+ <a href="https://hits.sh/github.com/groupsum/npmctl/blob/master/packages/npmctl-cloudflare/README.md/"><img src="https://hits.sh/github.com/groupsum/npmctl/blob/master/packages/npmctl-cloudflare/README.md.svg?label=npmctl-cloudflare%20package%20hits" alt="npmctl-cloudflare package hits"></a>
18
+ <a href="https://pepy.tech/projects/npmctl-cloudflare"><img src="https://static.pepy.tech/badge/npmctl-cloudflare" alt="npmctl-cloudflare downloads"></a>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <img src="https://raw.githubusercontent.com/groupsum/npmctl/master/docs/images/marketing/npmctl-architecture-infographic.png" alt="npmctl architecture infographic">
23
+ </p>
24
+
25
+ `npmctl-cloudflare` is the Cloudflare DNS provider package for `npmctl`. Install it when you want desired-state DNS records or DNS diagnostics to resolve through Cloudflare instead of using only the base `npmctl` package.
26
+
27
+ ## Supported Python Versions
28
+
29
+ `npmctl-cloudflare` supports Python `3.10`, `3.11`, `3.12`, `3.13`, and `3.14`.
30
+
31
+ ## Why npmctl-cloudflare
32
+
33
+ - Adds Cloudflare DNS provider discovery to `npmctl`
34
+ - Lets DNS workflows live beside proxy and certificate desired state
35
+ - Keeps Cloudflare API tokens out of the core CLI package
36
+ - Supports operator diagnostics through `npmctl dns doctor`
37
+ - Provides client helpers for Cloudflare A and CNAME record workflows
38
+
39
+ ## FAQ
40
+
41
+ ### What is npmctl-cloudflare?
42
+
43
+ **Answer:** `npmctl-cloudflare` is a plugin package that teaches `npmctl` how to talk to the Cloudflare DNS Records API for DNS record operations and DNS provider diagnostics.
44
+
45
+ ### When do I need npmctl-cloudflare?
46
+
47
+ **Answer:** You need `npmctl-cloudflare` when your `npmctl` workflow includes Cloudflare-managed DNS records or when you want `npmctl` to validate Cloudflare DNS connectivity and credentials.
48
+
49
+ ### Does npmctl-cloudflare work without npmctl?
50
+
51
+ **Answer:** No. `npmctl-cloudflare` is an extension package for `npmctl`, not a standalone CLI.
52
+
53
+ ### Can npmctl-cloudflare set A and CNAME records?
54
+
55
+ **Answer:** Yes. The Cloudflare DNS Records API supports A and CNAME records, and this package exposes helpers for create, replace, patch, and delete operations.
56
+
57
+ ### What credentials are required?
58
+
59
+ **Answer:** Cloudflare API access requires `CLOUDFLARE_API_TOKEN`. For diagnostics, grant zone read and DNS read access. For record changes, grant DNS write access to the target zone.
60
+
61
+ ## Install
62
+
63
+ Install the base CLI and the Cloudflare provider package together:
64
+
65
+ ```bash
66
+ pipx install npmctl
67
+ pipx inject npmctl npmctl-cloudflare
68
+ npmctl plugins list
69
+ ```
70
+
71
+ With `uv`:
72
+
73
+ ```bash
74
+ uv tool install npmctl
75
+ uv tool install npmctl-cloudflare
76
+ npmctl plugins list
77
+ ```
78
+
79
+ Inside a virtual environment:
80
+
81
+ ```bash
82
+ python -m venv .venv
83
+ . .venv/bin/activate
84
+ python -m pip install npmctl npmctl-cloudflare
85
+ npmctl plugins list
86
+ ```
87
+
88
+ ## Configure Cloudflare
89
+
90
+ Set the required environment variable:
91
+
92
+ ```bash
93
+ export CLOUDFLARE_API_TOKEN=your-cloudflare-api-token
94
+ ```
95
+
96
+ Optional for tests, proxies, or alternate endpoints:
97
+
98
+ ```bash
99
+ export CLOUDFLARE_API_BASE_URL=https://api.cloudflare.com/client/v4
100
+ ```
101
+
102
+ ## Verify Plugin Discovery
103
+
104
+ Check that `npmctl` can discover the provider:
105
+
106
+ ```bash
107
+ npmctl plugins list
108
+ npmctl dns doctor --provider cloudflare
109
+ ```
110
+
111
+ ## Minimal DNS Workflow
112
+
113
+ Once the provider is installed and configured, `npmctl` can validate or diagnose Cloudflare-backed DNS behavior through the base CLI:
114
+
115
+ ```bash
116
+ npmctl dns providers
117
+ npmctl dns zones --provider cloudflare
118
+ npmctl dns records --provider cloudflare --zone example.com
119
+ ```
120
+
121
+ ## Cloudflare API Surface
122
+
123
+ The provider follows the Cloudflare DNS Records API:
124
+
125
+ - `GET /zones`: discover zones available to the token.
126
+ - `GET /zones/{zone_id}/dns_records`: list DNS records in one zone.
127
+ - `POST /zones/{zone_id}/dns_records`: create A, CNAME, and other supported records.
128
+ - `PUT /zones/{zone_id}/dns_records/{dns_record_id}`: overwrite an existing record.
129
+ - `PATCH /zones/{zone_id}/dns_records/{dns_record_id}`: partially update an existing record.
130
+ - `DELETE /zones/{zone_id}/dns_records/{dns_record_id}`: delete a record.
131
+
132
+ ## Programmatic Record Operations
133
+
134
+ The npmctl DNS provider contract requires `zones()` and `records(zone)`. This package also exposes client helpers for API-backed record mutation:
135
+
136
+ ```python
137
+ from npmctl_cloudflare import CloudflareClient, CloudflareConfig
138
+
139
+ client = CloudflareClient(CloudflareConfig.from_env())
140
+ record = client.create_record("example.com", type="A", name="www", value="192.0.2.10", ttl=300)
141
+ client.patch_record("example.com", str(record.record_id), value="192.0.2.11")
142
+ client.delete_record("example.com", str(record.record_id))
143
+ ```
144
+
145
+ CNAME creation uses the same method:
146
+
147
+ ```python
148
+ client.create_record("example.com", type="CNAME", name="app", value="target.example.net", ttl=300)
149
+ ```
150
+
151
+ ## Safety Notes
152
+
153
+ - Only operate on zones that are authoritative in Cloudflare.
154
+ - Use least-privilege API tokens scoped to the intended zone.
155
+ - Keep `CLOUDFLARE_API_TOKEN` out of desired-state files and logs.
156
+ - Cloudflare prevents CNAME records from coexisting with A or AAAA records on the same name.
157
+ - Use npmctl owner metadata for desired DNS records so future apply support can remain owner-scoped.
158
+
159
+ ## More Documentation
160
+
161
+ - Related PyPI package: https://pypi.org/project/npmctl/
162
+ - Repository: https://github.com/groupsum/npmctl
163
+ - DNS provider docs: https://github.com/groupsum/npmctl/tree/master/docs/dns-providers.md
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "npmctl-cloudflare"
3
+ version = "0.3.6"
4
+ description = "Cloudflare DNS provider extension for npmctl."
5
+ readme = "README.md"
6
+ requires-python = ">=3.10,<3.15"
7
+ license = "Apache-2.0"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Jacob Stewart", email = "jacob@swarmauri.com" }]
10
+ classifiers = [
11
+ "Development Status :: 1 - Planning",
12
+ "Intended Audience :: System Administrators",
13
+ "License :: OSI Approved :: Apache Software License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Internet :: Name Service (DNS)",
21
+ "Topic :: System :: Systems Administration",
22
+ ]
23
+ keywords = ["cloudflare", "dns", "nginx-proxy-manager", "npmctl"]
24
+ dependencies = [
25
+ "requests>=2.32.0",
26
+ ]
27
+
28
+ [project.entry-points."npmctl.dns_providers"]
29
+ cloudflare = "npmctl_cloudflare.provider:CloudflareDnsProvider"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/groupsum/npmctl"
33
+ Repository = "https://github.com/groupsum/npmctl"
34
+ Documentation = "https://github.com/groupsum/npmctl/tree/master/packages/npmctl-cloudflare"
35
+ Issues = "https://github.com/groupsum/npmctl/issues"
36
+
37
+ [build-system]
38
+ requires = ["uv-build>=0.11.8,<0.12"]
39
+ build-backend = "uv_build"
@@ -0,0 +1,7 @@
1
+ """Cloudflare DNS extension for npmctl."""
2
+
3
+ from npmctl_cloudflare.client import CloudflareClient
4
+ from npmctl_cloudflare.config import CloudflareConfig
5
+ from npmctl_cloudflare.provider import CloudflareDnsProvider
6
+
7
+ __all__ = ["CloudflareClient", "CloudflareConfig", "CloudflareDnsProvider"]
@@ -0,0 +1,121 @@
1
+ """Small Cloudflare DNS API client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from npmctl_cloudflare.config import CloudflareConfig
10
+ from npmctl_cloudflare.errors import CloudflareError
11
+ from npmctl_cloudflare.models import CloudflareRecord, CloudflareZone
12
+
13
+
14
+ class CloudflareClient:
15
+ """HTTP client for Cloudflare DNS records."""
16
+
17
+ def __init__(self, config: CloudflareConfig, *, timeout_s: float = 15.0) -> None:
18
+ self.config = config
19
+ self.timeout_s = timeout_s
20
+ self.session = requests.Session()
21
+
22
+ def zones(self) -> tuple[str, ...]:
23
+ return tuple(zone.name for zone in self._zones())
24
+
25
+ def records(self, zone: str) -> tuple[CloudflareRecord, ...]:
26
+ zone_id = self._zone_id(zone)
27
+ return tuple(
28
+ CloudflareRecord.from_mapping(item)
29
+ for item in self._request("GET", f"/zones/{zone_id}/dns_records").get("result", [])
30
+ )
31
+
32
+ def create_record(
33
+ self,
34
+ zone: str,
35
+ *,
36
+ type: str,
37
+ name: str,
38
+ value: str,
39
+ ttl: int | None = None,
40
+ proxied: bool | None = None,
41
+ ) -> CloudflareRecord:
42
+ payload: dict[str, object] = {"type": type.upper(), "name": name, "content": value}
43
+ if ttl is not None:
44
+ payload["ttl"] = ttl
45
+ if proxied is not None:
46
+ payload["proxied"] = proxied
47
+ data = self._request("POST", f"/zones/{self._zone_id(zone)}/dns_records", json=payload)
48
+ return CloudflareRecord.from_mapping(data.get("result", {}))
49
+
50
+ def put_record(
51
+ self,
52
+ zone: str,
53
+ record_id: str,
54
+ *,
55
+ type: str,
56
+ name: str,
57
+ value: str,
58
+ ttl: int | None = None,
59
+ proxied: bool | None = None,
60
+ ) -> CloudflareRecord:
61
+ payload: dict[str, object] = {"type": type.upper(), "name": name, "content": value}
62
+ if ttl is not None:
63
+ payload["ttl"] = ttl
64
+ if proxied is not None:
65
+ payload["proxied"] = proxied
66
+ data = self._request("PUT", f"/zones/{self._zone_id(zone)}/dns_records/{record_id}", json=payload)
67
+ return CloudflareRecord.from_mapping(data.get("result", {}))
68
+
69
+ def patch_record(self, zone: str, record_id: str, **changes: object) -> CloudflareRecord:
70
+ payload = _cloudflare_payload(changes)
71
+ data = self._request("PATCH", f"/zones/{self._zone_id(zone)}/dns_records/{record_id}", json=payload)
72
+ return CloudflareRecord.from_mapping(data.get("result", {}))
73
+
74
+ def delete_record(self, zone: str, record_id: str) -> str | None:
75
+ data = self._request("DELETE", f"/zones/{self._zone_id(zone)}/dns_records/{record_id}")
76
+ result = data.get("result")
77
+ if isinstance(result, dict):
78
+ deleted_id = result.get("id")
79
+ return None if deleted_id is None else str(deleted_id)
80
+ return None
81
+
82
+ def _zones(self) -> tuple[CloudflareZone, ...]:
83
+ data = self._request("GET", "/zones")
84
+ return tuple(CloudflareZone.from_mapping(item) for item in data.get("result", []))
85
+
86
+ def _zone_id(self, zone: str) -> str:
87
+ target = zone.lower().rstrip(".")
88
+ for item in self._zones():
89
+ if item.name == target:
90
+ return item.zone_id
91
+ raise CloudflareError(f"Cloudflare zone not found: {zone}")
92
+
93
+ def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
94
+ response = self.session.request(
95
+ method,
96
+ f"{self.config.api_base_url}{path}",
97
+ headers={"Authorization": f"Bearer {self.config.api_token}"},
98
+ timeout=self.timeout_s,
99
+ **kwargs,
100
+ )
101
+ if response.status_code < 200 or response.status_code >= 300:
102
+ raise CloudflareError(f"Cloudflare API failed: HTTP {response.status_code}")
103
+ try:
104
+ data = response.json()
105
+ except ValueError as exc:
106
+ raise CloudflareError("Cloudflare API returned invalid JSON") from exc
107
+ if data.get("success") is False:
108
+ messages = [
109
+ str(item.get("message", "unknown error")) for item in data.get("errors", []) if isinstance(item, dict)
110
+ ]
111
+ raise CloudflareError("; ".join(messages) or "Cloudflare API request failed")
112
+ return data
113
+
114
+
115
+ def _cloudflare_payload(changes: dict[str, object]) -> dict[str, object]:
116
+ payload = dict(changes)
117
+ if "value" in payload:
118
+ payload["content"] = payload.pop("value")
119
+ if "type" in payload and isinstance(payload["type"], str):
120
+ payload["type"] = payload["type"].upper()
121
+ return payload
@@ -0,0 +1,29 @@
1
+ """Configuration loading for the Cloudflare DNS provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from typing import Mapping
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class CloudflareConfig:
12
+ """Cloudflare API configuration."""
13
+
14
+ api_token: str
15
+ api_base_url: str = "https://api.cloudflare.com/client/v4"
16
+
17
+ @classmethod
18
+ def from_env(cls, env: Mapping[str, str] | None = None) -> CloudflareConfig:
19
+ values = os.environ if env is None else env
20
+ api_token = values.get("CLOUDFLARE_API_TOKEN")
21
+ if not api_token:
22
+ raise ValueError("missing Cloudflare config: api_token")
23
+ return cls(
24
+ api_token=api_token,
25
+ api_base_url=values.get("CLOUDFLARE_API_BASE_URL", "https://api.cloudflare.com/client/v4"),
26
+ )
27
+
28
+ def redacted(self) -> dict[str, str | bool]:
29
+ return {"api_token": bool(self.api_token), "api_base_url": self.api_base_url}
@@ -0,0 +1,7 @@
1
+ """Cloudflare provider errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CloudflareError(RuntimeError):
7
+ """Raised when the Cloudflare API returns an error."""
@@ -0,0 +1,69 @@
1
+ """Cloudflare DNS response models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any, Mapping
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class CloudflareZone:
11
+ """One Cloudflare zone."""
12
+
13
+ zone_id: str
14
+ name: str
15
+
16
+ @classmethod
17
+ def from_mapping(cls, raw: Mapping[str, Any]) -> CloudflareZone:
18
+ return cls(zone_id=str(raw.get("id", "")), name=str(raw.get("name", "")).lower().rstrip("."))
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class CloudflareRecord:
23
+ """One Cloudflare DNS record."""
24
+
25
+ record_id: str | None
26
+ name: str
27
+ type: str
28
+ value: str
29
+ ttl: int | None = None
30
+ proxied: bool | None = None
31
+
32
+ @classmethod
33
+ def from_mapping(cls, raw: Mapping[str, Any]) -> CloudflareRecord:
34
+ return cls(
35
+ record_id=_optional_str(raw.get("id")),
36
+ name=str(raw.get("name", "")).lower().rstrip("."),
37
+ type=str(raw.get("type", "")).upper(),
38
+ value=str(raw.get("content", "")),
39
+ ttl=_optional_int(raw.get("ttl")),
40
+ proxied=_optional_bool(raw.get("proxied")),
41
+ )
42
+
43
+ def to_dict(self) -> dict[str, str | int | bool | None]:
44
+ return {
45
+ "id": self.record_id,
46
+ "name": self.name,
47
+ "type": self.type,
48
+ "value": self.value,
49
+ "ttl": self.ttl,
50
+ "proxied": self.proxied,
51
+ }
52
+
53
+
54
+ def _optional_bool(value: Any) -> bool | None:
55
+ if value is None:
56
+ return None
57
+ return bool(value)
58
+
59
+
60
+ def _optional_int(value: Any) -> int | None:
61
+ if value in (None, ""):
62
+ return None
63
+ return int(value)
64
+
65
+
66
+ def _optional_str(value: Any) -> str | None:
67
+ if value in (None, ""):
68
+ return None
69
+ return str(value)
@@ -0,0 +1,27 @@
1
+ """npmctl DNS provider implementation for Cloudflare."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from npmctl_cloudflare.client import CloudflareClient
6
+ from npmctl_cloudflare.config import CloudflareConfig
7
+
8
+
9
+ class CloudflareDnsProvider:
10
+ """DNS provider backed by the Cloudflare DNS Records API."""
11
+
12
+ name = "cloudflare"
13
+
14
+ def __init__(self, client: CloudflareClient | None = None) -> None:
15
+ self._client = client
16
+
17
+ @property
18
+ def client(self) -> CloudflareClient:
19
+ if self._client is None:
20
+ self._client = CloudflareClient(CloudflareConfig.from_env())
21
+ return self._client
22
+
23
+ def zones(self) -> tuple[str, ...]:
24
+ return self.client.zones()
25
+
26
+ def records(self, zone: str) -> tuple[dict[str, object], ...]:
27
+ return tuple(record.to_dict() for record in self.client.records(zone))