borealhost-sdk 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.

Potentially problematic release.


This version of borealhost-sdk might be problematic. Click here for more details.

@@ -0,0 +1,36 @@
1
+ # Python
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ venv/
6
+ .pytest_cache/
7
+ db.sqlite3
8
+
9
+ # Node
10
+ node_modules/
11
+ dist/
12
+ bun.lock
13
+
14
+ # Secrets
15
+ .env
16
+ *.key
17
+ *.pem
18
+ secrets/
19
+ .ssh/
20
+
21
+ # Claude Code
22
+ .claude/
23
+ .mcp.json
24
+
25
+ # Deploy artifacts & large files
26
+ *.tar.gz
27
+ *.tar
28
+ *.mo
29
+
30
+ # Backups
31
+ *.bak.*
32
+
33
+ # OS
34
+ .DS_Store
35
+ *.swp
36
+ *~
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: borealhost-sdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the BorealHost.ai REST API — agent-native web hosting
5
+ Project-URL: Homepage, https://borealhost.ai
6
+ Project-URL: Documentation, https://borealhost.ai/api/v1/docs/
7
+ Project-URL: Repository, https://github.com/alainsvrd/platform
8
+ Author-email: BorealHost <hello@borealhost.ai>
9
+ License: MIT
10
+ Keywords: api,borealhost,hosting,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Internet :: WWW/HTTP
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.27.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # BorealHost Python SDK
24
+
25
+ Python SDK for the [BorealHost.ai](https://borealhost.ai) REST API.
26
+
27
+ BorealHost is an agent-native Quebec-based web hosting platform. This SDK wraps the full public REST API (`/api/v1/*`) — purchase hosting, deploy sites, manage DNS, register domains, run SSH commands, and scale infrastructure.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install borealhost-sdk
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ from borealhost_sdk import Client
39
+
40
+ client = Client(api_key="bh_...")
41
+
42
+ # List plans (no auth needed)
43
+ plans = client.list_plans()
44
+
45
+ # Manage sites
46
+ sites = client.list_sites()
47
+ status = client.get_site("my-site")
48
+ client.deploy("my-site")
49
+
50
+ # DNS
51
+ client.add_domain_dns("example.com", record_type="A", value="1.2.3.4", subdomain="www")
52
+ ```
53
+
54
+ ## Authentication
55
+
56
+ API keys are in the format `bh_<48 hex chars>`.
57
+
58
+ ```python
59
+ # Pass at construction
60
+ client = Client(api_key="bh_...")
61
+
62
+ # Or via environment variable
63
+ # export BOREALHOST_API_KEY="bh_..."
64
+ client = Client()
65
+
66
+ # Or set later
67
+ client = Client()
68
+ client.set_api_key("bh_...")
69
+ ```
70
+
71
+ ## Custom base URL
72
+
73
+ ```python
74
+ client = Client(api_key="bh_...", base_url="https://staging.borealhost.ai")
75
+ ```
76
+
77
+ ## Errors
78
+
79
+ All errors from the API raise `ApiError`:
80
+
81
+ ```python
82
+ from borealhost_sdk import Client, ApiError
83
+
84
+ try:
85
+ client.deploy("unknown-site")
86
+ except ApiError as e:
87
+ print(e.code) # "NOT_FOUND"
88
+ print(e.message) # "Site 'unknown-site' not found"
89
+ ```
90
+
91
+ ## Related packages
92
+
93
+ - [`borealhost`](https://pypi.org/project/borealhost/) — command-line tool (`bh`)
94
+ - [`borealhost-mcp`](https://pypi.org/project/borealhost-mcp/) — MCP server for AI agents
95
+
96
+ ## Links
97
+
98
+ - API docs: https://borealhost.ai/api/v1/docs/
99
+ - Homepage: https://borealhost.ai
@@ -0,0 +1,77 @@
1
+ # BorealHost Python SDK
2
+
3
+ Python SDK for the [BorealHost.ai](https://borealhost.ai) REST API.
4
+
5
+ BorealHost is an agent-native Quebec-based web hosting platform. This SDK wraps the full public REST API (`/api/v1/*`) — purchase hosting, deploy sites, manage DNS, register domains, run SSH commands, and scale infrastructure.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install borealhost-sdk
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ from borealhost_sdk import Client
17
+
18
+ client = Client(api_key="bh_...")
19
+
20
+ # List plans (no auth needed)
21
+ plans = client.list_plans()
22
+
23
+ # Manage sites
24
+ sites = client.list_sites()
25
+ status = client.get_site("my-site")
26
+ client.deploy("my-site")
27
+
28
+ # DNS
29
+ client.add_domain_dns("example.com", record_type="A", value="1.2.3.4", subdomain="www")
30
+ ```
31
+
32
+ ## Authentication
33
+
34
+ API keys are in the format `bh_<48 hex chars>`.
35
+
36
+ ```python
37
+ # Pass at construction
38
+ client = Client(api_key="bh_...")
39
+
40
+ # Or via environment variable
41
+ # export BOREALHOST_API_KEY="bh_..."
42
+ client = Client()
43
+
44
+ # Or set later
45
+ client = Client()
46
+ client.set_api_key("bh_...")
47
+ ```
48
+
49
+ ## Custom base URL
50
+
51
+ ```python
52
+ client = Client(api_key="bh_...", base_url="https://staging.borealhost.ai")
53
+ ```
54
+
55
+ ## Errors
56
+
57
+ All errors from the API raise `ApiError`:
58
+
59
+ ```python
60
+ from borealhost_sdk import Client, ApiError
61
+
62
+ try:
63
+ client.deploy("unknown-site")
64
+ except ApiError as e:
65
+ print(e.code) # "NOT_FOUND"
66
+ print(e.message) # "Site 'unknown-site' not found"
67
+ ```
68
+
69
+ ## Related packages
70
+
71
+ - [`borealhost`](https://pypi.org/project/borealhost/) — command-line tool (`bh`)
72
+ - [`borealhost-mcp`](https://pypi.org/project/borealhost-mcp/) — MCP server for AI agents
73
+
74
+ ## Links
75
+
76
+ - API docs: https://borealhost.ai/api/v1/docs/
77
+ - Homepage: https://borealhost.ai
@@ -0,0 +1,82 @@
1
+ """BorealHost Python SDK.
2
+
3
+ The primary interface is the :class:`Client` class::
4
+
5
+ from borealhost_sdk import Client
6
+ client = Client(api_key="bh_...")
7
+ client.list_plans()
8
+
9
+ A module-level flat function interface is also provided for backward
10
+ compatibility and convenience. These functions operate on a shared default
11
+ ``Client`` instance::
12
+
13
+ import borealhost_sdk as bh
14
+ bh.set_session_key("bh_...")
15
+ bh.list_plans()
16
+ """
17
+
18
+ from .client import Client
19
+ from .exceptions import ApiError
20
+
21
+ __all__ = ["Client", "ApiError", "set_session_key", "get_session_key"]
22
+
23
+ __version__ = "0.1.0"
24
+
25
+
26
+ # ── Shared default client for the module-level flat function interface ──
27
+
28
+ _default = Client()
29
+
30
+
31
+ def set_session_key(key):
32
+ """Set the API key on the shared default client.
33
+
34
+ Equivalent to ``Client().set_api_key(key)`` but mutates the shared
35
+ default client used by all module-level function calls.
36
+ """
37
+ _default.set_api_key(key)
38
+
39
+
40
+ def get_session_key():
41
+ """Return the API key of the shared default client (or empty string)."""
42
+ return _default.get_api_key()
43
+
44
+
45
+ def _get_default_client():
46
+ """Return the shared default client (internal use)."""
47
+ return _default
48
+
49
+
50
+ # ── Module-level flat function shims ──
51
+ #
52
+ # Every public method on ``Client`` is re-exposed as a top-level function
53
+ # that delegates to the shared default client. This preserves the flat
54
+ # ``borealhost_sdk.list_plans(...)`` interface without duplicating code.
55
+ #
56
+ # Adding a new method to ``Client`` automatically exposes it at the module
57
+ # level — no bookkeeping required.
58
+
59
+ def _make_shim(name):
60
+ method = getattr(Client, name)
61
+
62
+ def shim(*args, **kwargs):
63
+ return getattr(_default, name)(*args, **kwargs)
64
+
65
+ shim.__name__ = name
66
+ shim.__qualname__ = name
67
+ shim.__doc__ = method.__doc__ or f"Delegates to Client.{name}() on the shared default client."
68
+ return shim
69
+
70
+
71
+ for _name in dir(Client):
72
+ if _name.startswith("_"):
73
+ continue
74
+ if _name in ("set_api_key", "get_api_key"):
75
+ # These correspond to set_session_key / get_session_key above.
76
+ continue
77
+ _attr = getattr(Client, _name)
78
+ if callable(_attr):
79
+ globals()[_name] = _make_shim(_name)
80
+ __all__.append(_name)
81
+
82
+ del _name, _attr
@@ -0,0 +1,779 @@
1
+ """BorealHost REST API client.
2
+
3
+ Wraps the public REST API at https://borealhost.ai/api/v1/ with a single
4
+ ``Client`` class. All 100+ endpoints are exposed as instance methods.
5
+
6
+ Example:
7
+
8
+ from borealhost_sdk import Client
9
+
10
+ client = Client(api_key="bh_...")
11
+ plans = client.list_plans()
12
+ client.deploy("my-site")
13
+ """
14
+
15
+ import httpx
16
+
17
+ from .config import DEFAULT_API_KEY, DEFAULT_BASE_URL, DEFAULT_TIMEOUT
18
+ from .exceptions import ApiError
19
+
20
+
21
+ class Client:
22
+ """HTTP client for the BorealHost REST API.
23
+
24
+ Args:
25
+ api_key: API key in ``bh_<48 hex chars>`` format. Falls back to the
26
+ ``BOREALHOST_API_KEY`` env var.
27
+ base_url: API root (default ``https://borealhost.ai``, override with
28
+ ``BOREALHOST_BASE_URL`` env var).
29
+ timeout: Default HTTP timeout in seconds.
30
+ """
31
+
32
+ def __init__(self, api_key=None, base_url=None, timeout=DEFAULT_TIMEOUT):
33
+ self.api_key = api_key if api_key is not None else DEFAULT_API_KEY
34
+ self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
35
+ self.api_base = f"{self.base_url}/api/v1"
36
+ self.timeout = timeout
37
+ # Checkout secrets — keyed by checkout_id, auto-stored on create_checkout.
38
+ self._checkout_secrets = {}
39
+
40
+ # ── Session / auth helpers ──
41
+
42
+ def set_api_key(self, key):
43
+ """Set the API key used by subsequent authenticated calls."""
44
+ self.api_key = key
45
+
46
+ def get_api_key(self):
47
+ """Return the current API key (or empty string)."""
48
+ return self.api_key
49
+
50
+ def _headers(self, api_key=None):
51
+ h = {"Content-Type": "application/json"}
52
+ key = api_key if api_key is not None else self.api_key
53
+ if key:
54
+ h["Authorization"] = f"Bearer {key}"
55
+ return h
56
+
57
+ def _checkout_headers(self, checkout_id):
58
+ """Build headers with checkout_secret if available."""
59
+ h = {"Content-Type": "application/json"}
60
+ secret = self._checkout_secrets.get(checkout_id)
61
+ if secret:
62
+ h["X-Checkout-Secret"] = secret
63
+ return h
64
+
65
+ def _handle(self, resp):
66
+ """Parse API response envelope, raise ApiError on failure."""
67
+ try:
68
+ data = resp.json()
69
+ except ValueError:
70
+ raise ApiError(
71
+ code="INVALID_RESPONSE",
72
+ message=f"Non-JSON response (status {resp.status_code})",
73
+ status_code=resp.status_code,
74
+ )
75
+ if not data.get("ok", False):
76
+ error = data.get("error", {}) or {}
77
+ meta = data.get("meta", {}) or {}
78
+ raise ApiError(
79
+ code=error.get("code", "UNKNOWN"),
80
+ message=error.get("message", "Unknown error"),
81
+ status_code=resp.status_code,
82
+ request_id=meta.get("request_id"),
83
+ )
84
+ return data.get("data")
85
+
86
+ # ── Auth (unauthenticated) ──
87
+
88
+ def register(self, email=None, name="Agent Key"):
89
+ """Register an agent account and get an API key."""
90
+ body = {"name": name}
91
+ if email:
92
+ body["email"] = email
93
+ resp = httpx.post(
94
+ f"{self.api_base}/auth/register/",
95
+ headers={"Content-Type": "application/json"},
96
+ json=body,
97
+ timeout=self.timeout,
98
+ )
99
+ return self._handle(resp)
100
+
101
+ def whoami(self):
102
+ """Get current account info from API key."""
103
+ resp = httpx.get(f"{self.api_base}/auth/whoami/", headers=self._headers(), timeout=self.timeout)
104
+ return self._handle(resp)
105
+
106
+ # ── Plans (unauthenticated) ──
107
+
108
+ def list_plans(self, include_deprecated=False, track=None):
109
+ params = {}
110
+ if include_deprecated:
111
+ params["include_deprecated"] = "true"
112
+ if track:
113
+ params["track"] = track
114
+ resp = httpx.get(f"{self.api_base}/plans/", headers=self._headers(), params=params, timeout=self.timeout)
115
+ return self._handle(resp)
116
+
117
+ def get_plan(self, slug):
118
+ resp = httpx.get(f"{self.api_base}/plans/{slug}/", headers=self._headers(), timeout=self.timeout)
119
+ return self._handle(resp)
120
+
121
+ # ── ACP Checkout (unauthenticated) ──
122
+
123
+ def create_checkout(self, sku):
124
+ """Create an ACP checkout session for a plan SKU."""
125
+ resp = httpx.post(
126
+ f"{self.api_base}/acp/checkouts/",
127
+ headers={"Content-Type": "application/json"},
128
+ json={"sku": sku},
129
+ timeout=self.timeout,
130
+ )
131
+ result = self._handle(resp)
132
+ # Store checkout_secret for subsequent update/complete calls
133
+ if result and result.get("checkout_secret"):
134
+ self._checkout_secrets[result["id"]] = result["checkout_secret"]
135
+ return result
136
+
137
+ def get_checkout(self, checkout_id):
138
+ """Get ACP checkout session status."""
139
+ resp = httpx.get(
140
+ f"{self.api_base}/acp/checkouts/{checkout_id}/",
141
+ headers=self._checkout_headers(checkout_id),
142
+ timeout=self.timeout,
143
+ )
144
+ return self._handle(resp)
145
+
146
+ def update_checkout(self, checkout_id, buyer_email=None, requested_slug=None):
147
+ """Update ACP checkout with buyer info."""
148
+ body = {}
149
+ if buyer_email:
150
+ body["buyer_email"] = buyer_email
151
+ if requested_slug:
152
+ body["requested_slug"] = requested_slug
153
+ resp = httpx.post(
154
+ f"{self.api_base}/acp/checkouts/{checkout_id}/update/",
155
+ headers=self._checkout_headers(checkout_id),
156
+ json=body,
157
+ timeout=self.timeout,
158
+ )
159
+ return self._handle(resp)
160
+
161
+ def complete_checkout(self, checkout_id, payment_method="stripe_checkout", payment_method_id=None):
162
+ """Complete ACP checkout with payment."""
163
+ body = {"payment_method": payment_method}
164
+ if payment_method_id:
165
+ body["payment_method_id"] = payment_method_id
166
+ # admin_bypass needs auth header; others use checkout_secret
167
+ headers = self._headers() if payment_method == "admin_bypass" else self._checkout_headers(checkout_id)
168
+ resp = httpx.post(
169
+ f"{self.api_base}/acp/checkouts/{checkout_id}/complete/",
170
+ headers=headers,
171
+ json=body,
172
+ timeout=60,
173
+ )
174
+ return self._handle(resp)
175
+
176
+ # ── Sites (authenticated) ──
177
+
178
+ def create_site(self, checkout_id):
179
+ """Create a site from a completed ACP checkout."""
180
+ resp = httpx.post(
181
+ f"{self.api_base}/sites/create/",
182
+ headers=self._headers(),
183
+ json={"checkout_id": checkout_id},
184
+ timeout=60,
185
+ )
186
+ return self._handle(resp)
187
+
188
+ def list_sites(self):
189
+ resp = httpx.get(f"{self.api_base}/sites/", headers=self._headers(), timeout=self.timeout)
190
+ return self._handle(resp)
191
+
192
+ def get_site(self, slug):
193
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/", headers=self._headers(), timeout=self.timeout)
194
+ return self._handle(resp)
195
+
196
+ def manage_dns(self, slug, action, record_type, subdomain="", value="", ttl=3600, priority=None):
197
+ body = {"action": action, "type": record_type, "subdomain": subdomain, "value": value, "ttl": ttl}
198
+ if priority is not None:
199
+ body["priority"] = priority
200
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/dns/", headers=self._headers(), json=body, timeout=self.timeout)
201
+ return self._handle(resp)
202
+
203
+ def deploy(self, slug):
204
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/deploy/", headers=self._headers(), json={}, timeout=120)
205
+ return self._handle(resp)
206
+
207
+ def create_snapshot(self, slug, description=""):
208
+ body = {"description": description} if description else {}
209
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/snapshots/create/", headers=self._headers(), json=body, timeout=180)
210
+ return self._handle(resp)
211
+
212
+ def list_snapshots(self, slug):
213
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/snapshots/", headers=self._headers(), timeout=self.timeout)
214
+ return self._handle(resp)
215
+
216
+ def create_b2_snapshot(self, slug, description=""):
217
+ body = {"description": description} if description else {}
218
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/snapshots/b2/", headers=self._headers(), json=body, timeout=1800)
219
+ return self._handle(resp)
220
+
221
+ def delete_snapshot(self, slug, snapshot_id):
222
+ resp = httpx.delete(f"{self.api_base}/sites/{slug}/snapshots/{snapshot_id}/", headers=self._headers(), timeout=60)
223
+ return self._handle(resp)
224
+
225
+ def rollback_snapshot(self, slug, snapshot_id):
226
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/snapshots/{snapshot_id}/rollback/", headers=self._headers(), json={}, timeout=300)
227
+ return self._handle(resp)
228
+
229
+ def get_snapshot_usage(self, slug):
230
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/snapshots/usage/", headers=self._headers(), timeout=self.timeout)
231
+ return self._handle(resp)
232
+
233
+ def schedule_snapshot(self, slug, scheduled_at, description=""):
234
+ body = {"scheduled_at": scheduled_at, "description": description}
235
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/snapshots/schedule/", headers=self._headers(), json=body, timeout=self.timeout)
236
+ return self._handle(resp)
237
+
238
+ def cancel_scheduled_snapshot(self, slug, schedule_id):
239
+ resp = httpx.delete(f"{self.api_base}/sites/{slug}/snapshots/schedule/{schedule_id}/", headers=self._headers(), timeout=self.timeout)
240
+ return self._handle(resp)
241
+
242
+ # ── Backups (authenticated) ──
243
+
244
+ def list_backups(self, slug):
245
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/backups/", headers=self._headers(), timeout=self.timeout)
246
+ return self._handle(resp)
247
+
248
+ def create_backup(self, slug):
249
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/backups/create/", headers=self._headers(), json={}, timeout=self.timeout)
250
+ return self._handle(resp)
251
+
252
+ def restore_backup(self, slug, backup_id):
253
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/backups/{backup_id}/restore/", headers=self._headers(), json={}, timeout=self.timeout)
254
+ return self._handle(resp)
255
+
256
+ def get_metrics(self, slug, days=7):
257
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/metrics/", headers=self._headers(), params={"days": days}, timeout=self.timeout)
258
+ return self._handle(resp)
259
+
260
+ def scale(self, slug, new_plan):
261
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/scale/", headers=self._headers(), json={"new_plan": new_plan}, timeout=self.timeout)
262
+ return self._handle(resp)
263
+
264
+ def decommission(self, slug):
265
+ resp = httpx.delete(f"{self.api_base}/sites/{slug}/delete/", headers=self._headers(), timeout=self.timeout)
266
+ return self._handle(resp)
267
+
268
+ # ── Account Management (authenticated) ──
269
+
270
+ def update_account(self, email=None, language=None, first_name=None, last_name=None):
271
+ """Update account profile fields."""
272
+ body = {}
273
+ if email is not None:
274
+ body["email"] = email
275
+ if language is not None:
276
+ body["language"] = language
277
+ if first_name is not None:
278
+ body["first_name"] = first_name
279
+ if last_name is not None:
280
+ body["last_name"] = last_name
281
+ resp = httpx.post(f"{self.api_base}/account/update/", headers=self._headers(), json=body, timeout=self.timeout)
282
+ return self._handle(resp)
283
+
284
+ def delete_account(self):
285
+ """Soft-delete (anonymize) the account. Irreversible."""
286
+ resp = httpx.request(
287
+ "DELETE", f"{self.api_base}/account/delete/",
288
+ headers=self._headers(), json={"confirm": "DELETE"}, timeout=self.timeout,
289
+ )
290
+ return self._handle(resp)
291
+
292
+ def list_subscriptions(self):
293
+ """List all subscriptions for the account."""
294
+ resp = httpx.get(f"{self.api_base}/account/subscriptions/", headers=self._headers(), timeout=self.timeout)
295
+ return self._handle(resp)
296
+
297
+ def get_billing_portal(self, flow=None):
298
+ """Get Stripe billing portal URL."""
299
+ params = {}
300
+ if flow:
301
+ params["flow"] = flow
302
+ resp = httpx.get(f"{self.api_base}/account/billing-portal/", headers=self._headers(), params=params, timeout=self.timeout)
303
+ return self._handle(resp)
304
+
305
+ def rotate_key(self, key_id):
306
+ """Atomically rotate an API key."""
307
+ resp = httpx.post(f"{self.api_base}/keys/{key_id}/rotate/", headers=self._headers(), json={}, timeout=self.timeout)
308
+ return self._handle(resp)
309
+
310
+ def create_api_key(self, name, scopes, site_slug=None, disabled_tools=None):
311
+ """Create a new API key."""
312
+ body = {"name": name, "scopes": scopes}
313
+ if site_slug:
314
+ body["site_slug"] = site_slug
315
+ if disabled_tools:
316
+ body["disabled_tools"] = disabled_tools
317
+ resp = httpx.post(f"{self.api_base}/keys/create/", headers=self._headers(), json=body, timeout=self.timeout)
318
+ return self._handle(resp)
319
+
320
+ def list_api_keys(self):
321
+ """List all API keys for the account."""
322
+ resp = httpx.get(f"{self.api_base}/keys/", headers=self._headers(), timeout=self.timeout)
323
+ return self._handle(resp)
324
+
325
+ def revoke_api_key(self, key_id):
326
+ """Revoke (deactivate) an API key."""
327
+ resp = httpx.post(f"{self.api_base}/keys/{key_id}/revoke/", headers=self._headers(), json={}, timeout=self.timeout)
328
+ return self._handle(resp)
329
+
330
+ # ── File Management (authenticated) ──
331
+
332
+ def list_files(self, slug, path=""):
333
+ """List directory contents for a site."""
334
+ params = {"path": path} if path else {}
335
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/files/", headers=self._headers(), params=params, timeout=self.timeout)
336
+ return self._handle(resp)
337
+
338
+ def read_file(self, slug, path):
339
+ """Read a file from a site."""
340
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/files/read/", headers=self._headers(), params={"path": path}, timeout=self.timeout)
341
+ return self._handle(resp)
342
+
343
+ def write_file(self, slug, path, content):
344
+ """Write content to a file on a site."""
345
+ resp = httpx.post(
346
+ f"{self.api_base}/sites/{slug}/files/write/",
347
+ headers=self._headers(), json={"path": path, "content": content}, timeout=self.timeout,
348
+ )
349
+ return self._handle(resp)
350
+
351
+ def upload_file(self, slug, path, content_b64):
352
+ """Upload a base64-encoded file to a site."""
353
+ resp = httpx.post(
354
+ f"{self.api_base}/sites/{slug}/files/upload/",
355
+ headers=self._headers(), json={"path": path, "content_b64": content_b64}, timeout=self.timeout,
356
+ )
357
+ return self._handle(resp)
358
+
359
+ def mkdir(self, slug, path):
360
+ """Create a directory on a site."""
361
+ resp = httpx.post(
362
+ f"{self.api_base}/sites/{slug}/files/mkdir/",
363
+ headers=self._headers(), json={"path": path}, timeout=self.timeout,
364
+ )
365
+ return self._handle(resp)
366
+
367
+ def delete_file(self, slug, path):
368
+ """Delete a file or directory on a site."""
369
+ resp = httpx.post(
370
+ f"{self.api_base}/sites/{slug}/files/delete/",
371
+ headers=self._headers(), json={"path": path}, timeout=self.timeout,
372
+ )
373
+ return self._handle(resp)
374
+
375
+ # ── Apps (authenticated, VPS only) ──
376
+
377
+ def list_apps(self, slug):
378
+ """List installed apps on a site."""
379
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/apps/", headers=self._headers(), timeout=self.timeout)
380
+ return self._handle(resp)
381
+
382
+ def install_app(self, slug, template, app_name, db_type="none", domain="", display_name=""):
383
+ """Install an app template on a VPS site."""
384
+ body = {"template": template, "app_name": app_name, "db_type": db_type}
385
+ if domain:
386
+ body["domain"] = domain
387
+ if display_name:
388
+ body["display_name"] = display_name
389
+ resp = httpx.post(
390
+ f"{self.api_base}/sites/{slug}/apps/install/",
391
+ headers=self._headers(),
392
+ json=body,
393
+ timeout=60,
394
+ )
395
+ return self._handle(resp)
396
+
397
+ def get_app(self, slug, app_id):
398
+ """Get app status and install log."""
399
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/apps/{app_id}/", headers=self._headers(), timeout=self.timeout)
400
+ return self._handle(resp)
401
+
402
+ # ── Logs (authenticated) ──
403
+
404
+ def get_logs(self, slug, log_type="error", lines=100, search=None):
405
+ """Get container logs for a site."""
406
+ params = {"type": log_type, "lines": lines}
407
+ if search:
408
+ params["search"] = search
409
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/logs/", headers=self._headers(), params=params, timeout=self.timeout)
410
+ return self._handle(resp)
411
+
412
+ # ── Modules (authenticated) ──
413
+
414
+ def list_modules(self, slug):
415
+ """List modules and availability for a site."""
416
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/modules/", headers=self._headers(), timeout=self.timeout)
417
+ return self._handle(resp)
418
+
419
+ def toggle_module(self, slug, module_name):
420
+ """Toggle a module on/off for a site."""
421
+ resp = httpx.post(
422
+ f"{self.api_base}/sites/{slug}/modules/{module_name}/toggle/",
423
+ headers=self._headers(), json={}, timeout=self.timeout,
424
+ )
425
+ return self._handle(resp)
426
+
427
+ # ── Domains (authenticated) ──
428
+
429
+ def list_domains(self):
430
+ """List all domains owned by the user."""
431
+ resp = httpx.get(f"{self.api_base}/domains/", headers=self._headers(), timeout=self.timeout)
432
+ return self._handle(resp)
433
+
434
+ def search_domain(self, domain):
435
+ """Check domain availability and pricing."""
436
+ resp = httpx.get(f"{self.api_base}/domains/search/", headers=self._headers(), params={"domain": domain}, timeout=self.timeout)
437
+ return self._handle(resp)
438
+
439
+ def register_domain(self, domain, contact, period=1, subscription_id=None, ca_legal_type=None):
440
+ """Register a domain with contact info and Stripe billing."""
441
+ body = {"domain": domain, "period": period, "contact": contact}
442
+ if subscription_id:
443
+ body["subscription_id"] = subscription_id
444
+ if ca_legal_type:
445
+ body["ca_legal_type"] = ca_legal_type
446
+ resp = httpx.post(
447
+ f"{self.api_base}/domains/register/",
448
+ headers=self._headers(), json=body, timeout=60,
449
+ )
450
+ return self._handle(resp)
451
+
452
+ def get_domain_detail(self, domain_name):
453
+ """Get domain detail with infrastructure status."""
454
+ resp = httpx.get(f"{self.api_base}/domains/{domain_name}/", headers=self._headers(), timeout=self.timeout)
455
+ return self._handle(resp)
456
+
457
+ def list_domain_dns(self, domain_name):
458
+ """List DNS records for a domain."""
459
+ resp = httpx.get(f"{self.api_base}/domains/{domain_name}/dns/", headers=self._headers(), timeout=self.timeout)
460
+ return self._handle(resp)
461
+
462
+ def add_domain_dns(self, domain_name, record_type, value, subdomain="", ttl=3600, priority=None):
463
+ """Add a DNS record to a domain."""
464
+ body = {"type": record_type, "value": value, "subdomain": subdomain, "ttl": ttl}
465
+ if priority is not None:
466
+ body["priority"] = priority
467
+ resp = httpx.post(f"{self.api_base}/domains/{domain_name}/dns/add/", headers=self._headers(), json=body, timeout=self.timeout)
468
+ return self._handle(resp)
469
+
470
+ def delete_domain_dns(self, domain_name, record_id):
471
+ """Delete a DNS record from a domain."""
472
+ resp = httpx.post(
473
+ f"{self.api_base}/domains/{domain_name}/dns/delete/",
474
+ headers=self._headers(), json={"record_id": record_id}, timeout=self.timeout,
475
+ )
476
+ return self._handle(resp)
477
+
478
+ def link_domain(self, domain_name, site_slug):
479
+ """Link a domain to a site."""
480
+ resp = httpx.post(
481
+ f"{self.api_base}/domains/{domain_name}/link/",
482
+ headers=self._headers(), json={"site_slug": site_slug}, timeout=self.timeout,
483
+ )
484
+ return self._handle(resp)
485
+
486
+ def domain_settings(self, domain_name, auto_renew=None, whois_privacy=None, locked=None):
487
+ """Update domain settings."""
488
+ body = {}
489
+ if auto_renew is not None:
490
+ body["auto_renew"] = auto_renew
491
+ if whois_privacy is not None:
492
+ body["whois_privacy"] = whois_privacy
493
+ if locked is not None:
494
+ body["locked"] = locked
495
+ resp = httpx.post(
496
+ f"{self.api_base}/domains/{domain_name}/settings/",
497
+ headers=self._headers(), json=body, timeout=self.timeout,
498
+ )
499
+ return self._handle(resp)
500
+
501
+ # ── SSH Access (authenticated) ──
502
+
503
+ def get_ssh_info(self, slug):
504
+ """Get SSH connection info for a site."""
505
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/ssh/", headers=self._headers(), timeout=self.timeout)
506
+ return self._handle(resp)
507
+
508
+ def add_ssh_key(self, slug, public_key):
509
+ """Inject an SSH public key into a site's container."""
510
+ resp = httpx.post(
511
+ f"{self.api_base}/sites/{slug}/ssh/keys/",
512
+ headers=self._headers(), json={"public_key": public_key}, timeout=self.timeout,
513
+ )
514
+ return self._handle(resp)
515
+
516
+ # ── WordPress Management (authenticated) ──
517
+
518
+ def list_plugins(self, slug):
519
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/wordpress/plugins/", headers=self._headers(), timeout=self.timeout)
520
+ return self._handle(resp)
521
+
522
+ def list_themes(self, slug):
523
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/wordpress/themes/", headers=self._headers(), timeout=self.timeout)
524
+ return self._handle(resp)
525
+
526
+ def manage_plugin(self, slug, action, plugin):
527
+ resp = httpx.post(
528
+ f"{self.api_base}/sites/{slug}/wordpress/plugins/manage/",
529
+ headers=self._headers(), json={"action": action, "plugin": plugin}, timeout=60,
530
+ )
531
+ return self._handle(resp)
532
+
533
+ def manage_theme(self, slug, action, theme):
534
+ resp = httpx.post(
535
+ f"{self.api_base}/sites/{slug}/wordpress/themes/manage/",
536
+ headers=self._headers(), json={"action": action, "theme": theme}, timeout=60,
537
+ )
538
+ return self._handle(resp)
539
+
540
+ def wp_check_updates(self, slug):
541
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/wordpress/updates/", headers=self._headers(), timeout=self.timeout)
542
+ return self._handle(resp)
543
+
544
+ def wp_update_all(self, slug):
545
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/wordpress/update-all/", headers=self._headers(), json={}, timeout=120)
546
+ return self._handle(resp)
547
+
548
+ # ── Cron (authenticated) ──
549
+
550
+ def list_cron(self, slug):
551
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/cron/", headers=self._headers(), timeout=15)
552
+ return self._handle(resp)
553
+
554
+ def add_cron(self, slug, schedule, command):
555
+ resp = httpx.post(
556
+ f"{self.api_base}/sites/{slug}/cron/add/",
557
+ headers=self._headers(), json={"schedule": schedule, "command": command}, timeout=15,
558
+ )
559
+ return self._handle(resp)
560
+
561
+ def delete_cron(self, slug, line_number):
562
+ resp = httpx.post(
563
+ f"{self.api_base}/sites/{slug}/cron/delete/",
564
+ headers=self._headers(), json={"line_number": line_number}, timeout=15,
565
+ )
566
+ return self._handle(resp)
567
+
568
+ # ── SSL (authenticated) ──
569
+
570
+ def get_ssl_info(self, slug):
571
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/ssl/", headers=self._headers(), timeout=self.timeout)
572
+ return self._handle(resp)
573
+
574
+ def renew_ssl(self, slug):
575
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/ssl/renew/", headers=self._headers(), json={}, timeout=120)
576
+ return self._handle(resp)
577
+
578
+ # ── PHP (authenticated) ──
579
+
580
+ def list_php_versions(self, slug):
581
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/php/", headers=self._headers(), timeout=15)
582
+ return self._handle(resp)
583
+
584
+ def switch_php(self, slug, version):
585
+ resp = httpx.post(
586
+ f"{self.api_base}/sites/{slug}/php/switch/",
587
+ headers=self._headers(), json={"version": version}, timeout=self.timeout,
588
+ )
589
+ return self._handle(resp)
590
+
591
+ # ── Cache (authenticated) ──
592
+
593
+ def get_cache_status(self, slug):
594
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/cache/", headers=self._headers(), timeout=15)
595
+ return self._handle(resp)
596
+
597
+ def flush_cache(self, slug):
598
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/cache/flush/", headers=self._headers(), json={}, timeout=self.timeout)
599
+ return self._handle(resp)
600
+
601
+ def toggle_cache(self, slug, enable):
602
+ resp = httpx.post(
603
+ f"{self.api_base}/sites/{slug}/cache/toggle/",
604
+ headers=self._headers(), json={"enable": enable}, timeout=self.timeout,
605
+ )
606
+ return self._handle(resp)
607
+
608
+ # ── Database (authenticated) ──
609
+
610
+ def wpdb_info(self, slug):
611
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/database/info/", headers=self._headers(), timeout=self.timeout)
612
+ return self._handle(resp)
613
+
614
+ def wpdb_optimize(self, slug):
615
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/database/optimize/", headers=self._headers(), json={}, timeout=60)
616
+ return self._handle(resp)
617
+
618
+ def wpdb_search_replace(self, slug, old, new, dry_run=True):
619
+ resp = httpx.post(
620
+ f"{self.api_base}/sites/{slug}/database/search-replace/",
621
+ headers=self._headers(), json={"old": old, "new": new, "dry_run": dry_run}, timeout=120,
622
+ )
623
+ return self._handle(resp)
624
+
625
+ def list_databases(self, slug):
626
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/database/databases/", headers=self._headers(), timeout=15)
627
+ return self._handle(resp)
628
+
629
+ def list_tables(self, slug, database):
630
+ resp = httpx.get(
631
+ f"{self.api_base}/sites/{slug}/database/tables/",
632
+ headers=self._headers(), params={"database": database}, timeout=15,
633
+ )
634
+ return self._handle(resp)
635
+
636
+ def execute_query(self, slug, database, query):
637
+ resp = httpx.post(
638
+ f"{self.api_base}/sites/{slug}/database/query/",
639
+ headers=self._headers(), json={"database": database, "query": query}, timeout=self.timeout,
640
+ )
641
+ return self._handle(resp)
642
+
643
+ # ── System (authenticated) ──
644
+
645
+ def get_stack_info(self, slug):
646
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/system/stack/", headers=self._headers(), timeout=15)
647
+ return self._handle(resp)
648
+
649
+ def get_resource_snapshot(self, slug):
650
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/system/resources/", headers=self._headers(), timeout=15)
651
+ return self._handle(resp)
652
+
653
+ # ── FTP Accounts (authenticated) ──
654
+
655
+ def list_ftp_accounts(self, slug):
656
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/ftp/", headers=self._headers(), timeout=15)
657
+ return self._handle(resp)
658
+
659
+ def create_ftp_account(self, slug, username, password, home_dir="/var/www/html"):
660
+ resp = httpx.post(
661
+ f"{self.api_base}/sites/{slug}/ftp/create/",
662
+ headers=self._headers(), json={"username": username, "password": password, "home_dir": home_dir},
663
+ timeout=self.timeout,
664
+ )
665
+ return self._handle(resp)
666
+
667
+ def remove_ftp_account(self, slug, username):
668
+ resp = httpx.post(
669
+ f"{self.api_base}/sites/{slug}/ftp/remove/",
670
+ headers=self._headers(), json={"username": username}, timeout=15,
671
+ )
672
+ return self._handle(resp)
673
+
674
+ def ftp_set_password(self, slug, username, password):
675
+ resp = httpx.post(
676
+ f"{self.api_base}/sites/{slug}/ftp/password/",
677
+ headers=self._headers(), json={"username": username, "password": password}, timeout=15,
678
+ )
679
+ return self._handle(resp)
680
+
681
+ # ── Alert Rules (authenticated) ──
682
+
683
+ def list_alert_rules(self, slug):
684
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/alerts/", headers=self._headers(), timeout=15)
685
+ return self._handle(resp)
686
+
687
+ def create_alert_rule(self, slug, metric, threshold, operator="gt", severity="warning",
688
+ cooldown_minutes=30, notify_email=True, notify_webhook=""):
689
+ body = {
690
+ "metric": metric, "threshold": threshold, "operator": operator,
691
+ "severity": severity, "cooldown_minutes": cooldown_minutes,
692
+ "notify_email": notify_email, "notify_webhook": notify_webhook,
693
+ }
694
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/alerts/create/", headers=self._headers(), json=body, timeout=15)
695
+ return self._handle(resp)
696
+
697
+ def delete_alert_rule(self, slug, rule_id):
698
+ resp = httpx.post(f"{self.api_base}/sites/{slug}/alerts/{rule_id}/delete/", headers=self._headers(), json={}, timeout=15)
699
+ return self._handle(resp)
700
+
701
+ # ── Security Scanning (authenticated) ──
702
+
703
+ def malware_scan(self, slug, path=""):
704
+ body = {"path": path} if path else {}
705
+ resp = httpx.post(
706
+ f"{self.api_base}/sites/{slug}/security/scan/",
707
+ headers=self._headers(), json=body, timeout=320,
708
+ )
709
+ return self._handle(resp)
710
+
711
+ # ── Cloudflare CDN (authenticated) ──
712
+
713
+ def cf_proxy_status(self, slug):
714
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/cloudflare/proxy/", headers=self._headers(), timeout=self.timeout)
715
+ return self._handle(resp)
716
+
717
+ def cf_set_proxy(self, slug, proxied):
718
+ resp = httpx.post(
719
+ f"{self.api_base}/sites/{slug}/cloudflare/proxy/set/",
720
+ headers=self._headers(), json={"proxied": proxied}, timeout=self.timeout,
721
+ )
722
+ return self._handle(resp)
723
+
724
+ def cf_cache_purge(self, slug, urls=None):
725
+ body = {"urls": urls} if urls else {}
726
+ resp = httpx.post(
727
+ f"{self.api_base}/sites/{slug}/cloudflare/cache/purge/",
728
+ headers=self._headers(), json=body, timeout=self.timeout,
729
+ )
730
+ return self._handle(resp)
731
+
732
+ def cf_analytics(self, slug, minutes=1440):
733
+ resp = httpx.get(
734
+ f"{self.api_base}/sites/{slug}/cloudflare/analytics/",
735
+ headers=self._headers(), params={"minutes": minutes}, timeout=self.timeout,
736
+ )
737
+ return self._handle(resp)
738
+
739
+ # ── Firewall (authenticated) ──
740
+
741
+ def list_firewall_rules(self, slug):
742
+ resp = httpx.get(f"{self.api_base}/sites/{slug}/firewall/", headers=self._headers(), timeout=15)
743
+ return self._handle(resp)
744
+
745
+ def add_firewall_rule(self, slug, ip, action="deny"):
746
+ resp = httpx.post(
747
+ f"{self.api_base}/sites/{slug}/firewall/add/",
748
+ headers=self._headers(), json={"ip": ip, "action": action}, timeout=15,
749
+ )
750
+ return self._handle(resp)
751
+
752
+ def remove_firewall_rule(self, slug, ip):
753
+ resp = httpx.post(
754
+ f"{self.api_base}/sites/{slug}/firewall/remove/",
755
+ headers=self._headers(), json={"ip": ip}, timeout=15,
756
+ )
757
+ return self._handle(resp)
758
+
759
+ # ── Key Claim (challenge-response) ──
760
+
761
+ def request_api_key(self, site_slug):
762
+ """Request a claim token for challenge-response key provisioning."""
763
+ resp = httpx.post(
764
+ f"{self.api_base}/keys/claim/request/",
765
+ headers={"Content-Type": "application/json"},
766
+ json={"site_slug": site_slug},
767
+ timeout=15,
768
+ )
769
+ return self._handle(resp)
770
+
771
+ def claim_api_key(self, claim_token):
772
+ """Claim an API key using a claim token from the container."""
773
+ resp = httpx.post(
774
+ f"{self.api_base}/keys/claim/",
775
+ headers={"Content-Type": "application/json"},
776
+ json={"claim_token": claim_token},
777
+ timeout=15,
778
+ )
779
+ return self._handle(resp)
@@ -0,0 +1,7 @@
1
+ """Default configuration for the BorealHost SDK."""
2
+
3
+ import os
4
+
5
+ DEFAULT_BASE_URL = os.environ.get("BOREALHOST_BASE_URL", "https://borealhost.ai")
6
+ DEFAULT_API_KEY = os.environ.get("BOREALHOST_API_KEY", "")
7
+ DEFAULT_TIMEOUT = 30
@@ -0,0 +1,19 @@
1
+ """Exceptions raised by the BorealHost SDK."""
2
+
3
+
4
+ class ApiError(Exception):
5
+ """Raised when the BorealHost API returns an error envelope.
6
+
7
+ Attributes:
8
+ code: Short error code from the API (e.g. "NOT_FOUND", "UNAUTHORIZED").
9
+ message: Human-readable error message.
10
+ status_code: HTTP status code.
11
+ request_id: API request ID for support/debugging (if present).
12
+ """
13
+
14
+ def __init__(self, code="UNKNOWN", message="Unknown error", status_code=None, request_id=None):
15
+ self.code = code
16
+ self.message = message
17
+ self.status_code = status_code
18
+ self.request_id = request_id
19
+ super().__init__(f"{code}: {message}")
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "borealhost-sdk"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the BorealHost.ai REST API — agent-native web hosting"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "BorealHost", email = "hello@borealhost.ai" },
14
+ ]
15
+ keywords = ["borealhost", "hosting", "sdk", "api"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Internet :: WWW/HTTP",
25
+ ]
26
+ dependencies = [
27
+ "httpx>=0.27.0",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://borealhost.ai"
32
+ Documentation = "https://borealhost.ai/api/v1/docs/"
33
+ Repository = "https://github.com/alainsvrd/platform"
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["borealhost_sdk"]
37
+
38
+ [tool.hatch.build]
39
+ include = ["borealhost_sdk/**"]