ha-deploy-kit 0.1.0__py3-none-any.whl

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,63 @@
1
+ """Optional hooks to serve a Lovelace card from a custom component.
2
+
3
+ Each hook probes the installed Home Assistant for the supported API and uses it,
4
+ rather than hard-coding a single call or swallowing unexpected errors blindly.
5
+ Both the legacy and current HA signatures are handled:
6
+
7
+ - static paths: legacy ``http.register_static_path`` or current
8
+ ``http.async_register_static_paths`` with ``StaticPathConfig``;
9
+ - extra module URL: legacy ``frontend.add_extra_module_url`` or current
10
+ ``frontend.add_extra_js_url`` (``es5=False`` registers an ES module).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING
16
+
17
+ if TYPE_CHECKING:
18
+ from homeassistant.core import HomeAssistant
19
+
20
+
21
+ def register_card_serving(
22
+ hass: HomeAssistant,
23
+ domain: str,
24
+ lovelace_dir: str,
25
+ card_url: str,
26
+ ) -> None:
27
+ """Serve ``lovelace_dir`` at ``card_url`` and load it as a frontend module."""
28
+ _register_static_path(hass, lovelace_dir, card_url)
29
+ _register_extra_module_url(hass, card_url)
30
+
31
+
32
+ def _register_static_path(
33
+ hass: HomeAssistant, lovelace_dir: str, card_url: str
34
+ ) -> None:
35
+ http = getattr(hass, "http", None)
36
+ if http is None:
37
+ return
38
+
39
+ register_static = getattr(http, "register_static_path", None)
40
+ if callable(register_static):
41
+ register_static(card_url, lovelace_dir, False)
42
+ return
43
+
44
+ async_register = getattr(http, "async_register_static_paths", None)
45
+ if callable(async_register):
46
+ from homeassistant.components.http import StaticPathConfig
47
+
48
+ hass.async_create_task(
49
+ async_register([StaticPathConfig(card_url, lovelace_dir, False)])
50
+ )
51
+
52
+
53
+ def _register_extra_module_url(hass: HomeAssistant, card_url: str) -> None:
54
+ from homeassistant.components import frontend
55
+
56
+ add_module = getattr(frontend, "add_extra_module_url", None)
57
+ if callable(add_module):
58
+ add_module(hass, card_url)
59
+ return
60
+
61
+ add_js = getattr(frontend, "add_extra_js_url", None)
62
+ if callable(add_js):
63
+ add_js(hass, card_url)
@@ -0,0 +1,50 @@
1
+ """Local inventory: pure, side-effect-free hashing of a component tree.
2
+
3
+ Keys are POSIX paths relative to ``component_dir`` (one namespace per
4
+ deployment root), so an unchanged tree compares as a true no-op.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+
12
+ from ha_deploy_kit.settings import DeployError, DeploySettings
13
+
14
+
15
+ def sha256_file(path: Path) -> str:
16
+ """Return the hex SHA-256 of a file, read in bounded chunks."""
17
+ digest = hashlib.sha256()
18
+ with path.open("rb") as handle:
19
+ for chunk in iter(lambda: handle.read(65536), b""):
20
+ digest.update(chunk)
21
+ return digest.hexdigest()
22
+
23
+
24
+ def local_inventory(settings: DeploySettings) -> dict[str, str]:
25
+ """Map component-relative POSIX paths to SHA-256.
26
+
27
+ Skips files whose suffix is in ``settings.skip_suffixes`` and any path with
28
+ a part in ``settings.skip_dirs``. Raises ``DeployError`` when the component
29
+ dir is missing or the resulting inventory is empty.
30
+ """
31
+ component_dir = settings.component_dir
32
+ if not component_dir.is_dir():
33
+ raise DeployError(
34
+ f"component directory for domain '{settings.domain}' not found"
35
+ )
36
+
37
+ inventory: dict[str, str] = {}
38
+ for path in sorted(component_dir.rglob("*")):
39
+ if not path.is_file():
40
+ continue
41
+ rel = path.relative_to(component_dir)
42
+ if any(part in settings.skip_dirs for part in rel.parts):
43
+ continue
44
+ if path.suffix in settings.skip_suffixes:
45
+ continue
46
+ inventory[rel.as_posix()] = sha256_file(path)
47
+
48
+ if not inventory:
49
+ raise DeployError(f"no deployable files found for domain '{settings.domain}'")
50
+ return inventory
@@ -0,0 +1,6 @@
1
+ # Seed template for a consumer's custom_components/<domain>/services.yaml.
2
+ # Replace {service_name} with your configured service name (default below).
3
+ {service_name}:
4
+ name: Raise restart issue
5
+ description: Raise the restart-required repair issue for this integration.
6
+ fields: {}
@@ -0,0 +1,15 @@
1
+ {
2
+ "issues": {
3
+ "restart_required": {
4
+ "title": "Restart required to finish updating",
5
+ "fix_flow": {
6
+ "step": {
7
+ "confirm_restart": {
8
+ "title": "Restart Home Assistant",
9
+ "description": "Restart Home Assistant now to load the newly deployed code?"
10
+ }
11
+ }
12
+ }
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "services": {
3
+ "raise_restart_issue": {
4
+ "name": "Raise restart issue",
5
+ "description": "Raise the restart-required repair issue for this integration."
6
+ }
7
+ },
8
+ "issues": {
9
+ "restart_required": {
10
+ "title": "Restart required to finish updating",
11
+ "description": "Newly deployed code is on disk. Restart Home Assistant to load it."
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,150 @@
1
+ """Sanitized HTTP notification client.
2
+
3
+ Posts to the HA service that raises the restart Repairs issue and classifies the
4
+ outcome into a fixed, secret-free vocabulary. Never prints tokens, raw bodies, or
5
+ secret-bearing exception text; never weakens TLS verification.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ssl
11
+
12
+ from ha_deploy_kit.settings import DeploySettings
13
+
14
+
15
+ def _verifying_ssl_context() -> ssl.SSLContext:
16
+ """Return a strictly-verifying TLS context backed by a real CA store.
17
+
18
+ Python's default context has no usable CA store on some platforms (macOS /
19
+ python.org / fresh venvs), which misreports valid certs as ``tls-error``.
20
+ Prefer the OS trust store (``truststore``, matching ``curl``), then
21
+ ``certifi``, then the stdlib default. Every branch keeps verification strict
22
+ (``CERT_REQUIRED`` + ``check_hostname``); none ever disables it.
23
+ """
24
+ try:
25
+ import truststore
26
+
27
+ return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
28
+ except ImportError:
29
+ pass
30
+ try:
31
+ import certifi
32
+
33
+ return ssl.create_default_context(cafile=certifi.where())
34
+ except ImportError:
35
+ pass
36
+ return ssl.create_default_context()
37
+
38
+
39
+ def raise_restart_issue(
40
+ base_url: str, token: str | None, settings: DeploySettings
41
+ ) -> str:
42
+ """POST ``{}`` to the restart service; return a sanitized status string.
43
+
44
+ One of: ``no-token``, ``raised``, ``no-service``, ``http-<code>``,
45
+ ``tls-error``, ``connection-error``, ``invalid-config``, ``protocol-error``.
46
+ """
47
+ import http.client
48
+ import urllib.error
49
+ import urllib.request
50
+
51
+ if not base_url or not token:
52
+ return "no-token"
53
+
54
+ context = _verifying_ssl_context()
55
+
56
+ url = f"{base_url}/api/services/{settings.domain}/{settings.service_name}"
57
+ request = urllib.request.Request(
58
+ url,
59
+ data=b"{}",
60
+ headers={
61
+ "Authorization": f"Bearer {token}",
62
+ "Content-Type": "application/json",
63
+ },
64
+ method="POST",
65
+ )
66
+
67
+ try:
68
+ with urllib.request.urlopen(
69
+ request, timeout=settings.http_timeout, context=context
70
+ ) as response:
71
+ response.read()
72
+ return "raised"
73
+ except urllib.error.HTTPError as err:
74
+ code = err.code
75
+ try:
76
+ err.read()
77
+ except Exception: # noqa: BLE001 - draining; outcome does not matter
78
+ pass
79
+ finally:
80
+ err.close()
81
+ if code in (400, 404) and _restart_service_missing(
82
+ base_url, token, settings, context
83
+ ):
84
+ return "no-service"
85
+ return f"http-{code}"
86
+ except ssl.SSLCertVerificationError:
87
+ return "tls-error"
88
+ except urllib.error.URLError as err:
89
+ if isinstance(err.reason, ssl.SSLError):
90
+ return "tls-error"
91
+ return "connection-error"
92
+ except (ValueError, http.client.InvalidURL):
93
+ return "invalid-config"
94
+ except http.client.HTTPException:
95
+ return "protocol-error"
96
+ except OSError:
97
+ return "connection-error"
98
+
99
+
100
+ def _restart_service_missing(
101
+ base_url: str, token: str, settings: DeploySettings, context: ssl.SSLContext
102
+ ) -> bool:
103
+ """Prove the service is absent via a bounded ``/api/services`` probe.
104
+
105
+ Returns ``True`` only when the response is a well-formed list and no item
106
+ declares ``settings.service_name`` under ``settings.domain``. Any malformed,
107
+ failed, or ambiguous probe returns ``False`` (keep the original HTTP error).
108
+ """
109
+ import http.client
110
+ import json
111
+ import urllib.error
112
+ import urllib.request
113
+
114
+ url = f"{base_url}/api/services"
115
+ request = urllib.request.Request(
116
+ url,
117
+ headers={
118
+ "Authorization": f"Bearer {token}",
119
+ "Accept": "application/json",
120
+ },
121
+ method="GET",
122
+ )
123
+
124
+ try:
125
+ with urllib.request.urlopen(
126
+ request, timeout=settings.http_timeout, context=context
127
+ ) as response:
128
+ raw = response.read()
129
+ data = json.loads(raw)
130
+ except (
131
+ urllib.error.HTTPError,
132
+ http.client.HTTPException,
133
+ urllib.error.URLError,
134
+ OSError,
135
+ ValueError,
136
+ ):
137
+ return False
138
+
139
+ if not isinstance(data, list):
140
+ return False
141
+ for item in data:
142
+ if not isinstance(item, dict):
143
+ return False
144
+ domain = item.get("domain")
145
+ services = item.get("services")
146
+ if not isinstance(domain, str) or not isinstance(services, dict):
147
+ return False
148
+ if domain == settings.domain and settings.service_name in services:
149
+ return False
150
+ return True
@@ -0,0 +1,56 @@
1
+ """Frozen settings types for ha_deploy_kit.
2
+
3
+ All per-consumer state travels in a frozen ``DeploySettings`` passed by
4
+ argument. No module globals, no ``os.environ`` mutation, and no imports of
5
+ Home Assistant: one process can deploy several consumers without cross-talk.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ class DeployError(Exception):
15
+ """Raised for any recoverable deployment error (sanitized messages)."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class FrontendAsset:
20
+ """An optional frontend card served separately from the backend tree.
21
+
22
+ ``source_rel`` is POSIX, relative to the component dir (e.g.
23
+ ``"lovelace/<name>-card.js"``); ``published_remote`` is the separately-served
24
+ copy relative to the remote HA config dir (e.g.
25
+ ``"www/<domain>/<name>-card.js"``).
26
+ """
27
+
28
+ source_rel: str
29
+ published_remote: str
30
+ serve_url: str | None = None
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DeploySettings:
35
+ """All per-consumer deployment state. Paths derive from ``source_root``."""
36
+
37
+ domain: str
38
+ source_root: Path
39
+ remote_config: str = "/config"
40
+ service_name: str = "raise_restart_issue"
41
+ issue_id: str = "restart_required"
42
+ translation_key: str = "restart_required"
43
+ learn_more_url: str | None = None
44
+ frontend: FrontendAsset | None = None
45
+ ssh_timeout: int = 120
46
+ http_timeout: int = 30
47
+ skip_dirs: frozenset[str] = frozenset({"__pycache__"})
48
+ skip_suffixes: tuple[str, ...] = (".pyc",)
49
+
50
+ @property
51
+ def component_dir(self) -> Path:
52
+ return self.source_root / "custom_components" / self.domain
53
+
54
+ @property
55
+ def remote_component(self) -> str:
56
+ return f"{self.remote_config.rstrip('/')}/custom_components/{self.domain}"
@@ -0,0 +1,117 @@
1
+ """SSH command transport and safe tar streaming.
2
+
3
+ The transport never interpolates untrusted values into a shell string, never
4
+ issues ``ha core restart``, and validates every archive member before a single
5
+ byte is written.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import posixpath
11
+ import subprocess
12
+ import tarfile
13
+ from io import BytesIO
14
+ from typing import Protocol
15
+
16
+ from ha_deploy_kit.settings import DeployError, DeploySettings
17
+
18
+
19
+ def _shquote(value: str) -> str:
20
+ """Single-quote a value for safe embedding in a POSIX shell command."""
21
+ return "'" + value.replace("'", "'\\''") + "'"
22
+
23
+
24
+ class SSHTransport(Protocol):
25
+ def __call__(self, command: str, stdin: bytes | None = None) -> bytes: ...
26
+
27
+
28
+ class SubprocessSSH:
29
+ """Run remote commands over ``ssh`` via ``subprocess`` (no shell on our side)."""
30
+
31
+ def __init__(self, target: str, timeout: int = 120) -> None:
32
+ self.target = target
33
+ self.timeout = timeout
34
+
35
+ def __call__(self, command: str, stdin: bytes | None = None) -> bytes:
36
+ try:
37
+ result = subprocess.run(
38
+ ["ssh", self.target, command],
39
+ input=stdin,
40
+ capture_output=True,
41
+ timeout=self.timeout,
42
+ )
43
+ except (subprocess.TimeoutExpired, OSError) as exc:
44
+ raise DeployError(f"SSH to {self.target} failed") from exc
45
+ if result.returncode != 0:
46
+ raise DeployError(
47
+ f"SSH to {self.target} failed with exit code {result.returncode}"
48
+ )
49
+ return result.stdout
50
+
51
+
52
+ def validate_members(settings: DeploySettings, rel_paths: list[str]) -> None:
53
+ """Reject unsafe archive members before any transfer.
54
+
55
+ Rejects absolute paths, ``..`` traversal, NUL/newline injection, and any
56
+ source path that is or traverses a symlink escaping ``component_dir``.
57
+ Messages are sanitized and never echo the raw (attacker-controlled) path.
58
+ """
59
+ base = settings.component_dir.resolve()
60
+ for index, rel in enumerate(rel_paths):
61
+ if "\x00" in rel or "\n" in rel or "\r" in rel:
62
+ raise DeployError(f"archive member #{index} contains an illegal character")
63
+ if rel.startswith("/") or posixpath.isabs(rel):
64
+ raise DeployError(f"archive member #{index} is an absolute path")
65
+ if ".." in rel.split("/"):
66
+ raise DeployError(f"archive member #{index} traverses outside the tree")
67
+ resolved = (settings.component_dir / rel).resolve()
68
+ try:
69
+ resolved.relative_to(base)
70
+ except ValueError as exc:
71
+ raise DeployError(
72
+ f"archive member #{index} escapes the component directory"
73
+ ) from exc
74
+
75
+
76
+ def build_tar(settings: DeploySettings, rel_paths: list[str]) -> bytes:
77
+ """Build an uncompressed tar of the given members (owner-normalized)."""
78
+ validate_members(settings, rel_paths)
79
+
80
+ def _normalize(info: tarfile.TarInfo) -> tarfile.TarInfo:
81
+ info.uid = 0
82
+ info.gid = 0
83
+ info.uname = ""
84
+ info.gname = ""
85
+ return info
86
+
87
+ buffer = BytesIO()
88
+ with tarfile.open(fileobj=buffer, mode="w") as tar:
89
+ for rel in rel_paths:
90
+ tar.add(
91
+ settings.component_dir / rel,
92
+ arcname=rel,
93
+ recursive=False,
94
+ filter=_normalize,
95
+ )
96
+ return buffer.getvalue()
97
+
98
+
99
+ def deploy_backend(
100
+ ssh: SSHTransport, settings: DeploySettings, rel_paths: list[str]
101
+ ) -> None:
102
+ """Create the remote dirs and stream the tar. No atomicity, no rollback."""
103
+ remote = settings.remote_component
104
+ dirs = [remote, f"{remote}/translations"]
105
+ if settings.frontend is not None:
106
+ published = (
107
+ settings.remote_config.rstrip("/")
108
+ + "/"
109
+ + settings.frontend.published_remote
110
+ )
111
+ dirs.append(posixpath.dirname(published))
112
+
113
+ mkdir = "mkdir -p " + " ".join(_shquote(d) for d in dirs)
114
+ ssh(mkdir)
115
+
116
+ blob = build_tar(settings, rel_paths)
117
+ ssh(f"tar -xf - -C {_shquote(remote)}", stdin=blob)
@@ -0,0 +1,199 @@
1
+ Metadata-Version: 2.5
2
+ Name: ha-deploy-kit
3
+ Version: 0.1.0
4
+ Summary: HA-free SSH deploy / compare / notify / optional-frontend toolkit for Home Assistant custom components
5
+ Project-URL: Homepage, https://github.com/latrekc-meta/ha-deploy-kit
6
+ Project-URL: Repository, https://github.com/latrekc-meta/ha-deploy-kit
7
+ Project-URL: Issues, https://github.com/latrekc-meta/ha-deploy-kit/issues
8
+ Author: latrekc-meta
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 latrekc-meta
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: custom-component,deploy,home-assistant,homeassistant,lovelace,ssh
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Operating System :: OS Independent
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Topic :: Home Automation
41
+ Classifier: Topic :: Software Development :: Build Tools
42
+ Requires-Python: >=3.10
43
+ Requires-Dist: certifi
44
+ Requires-Dist: truststore>=0.9
45
+ Provides-Extra: ha
46
+ Requires-Dist: homeassistant; extra == 'ha'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # ha-deploy-kit
50
+
51
+ A small, reusable library for deploying a Home Assistant custom integration's
52
+ code to a running HA host over SSH, and (optionally) wiring the HA-side restart
53
+ notification. The core is Home-Assistant-free and standard-library only, so a
54
+ deploy script can import it without pulling in Home Assistant; the HA adapter is
55
+ a separate, explicitly-imported subpackage.
56
+
57
+ One process can deploy several integrations without cross-talk: all per-consumer
58
+ state lives in a frozen `DeploySettings` passed by argument — no module globals
59
+ and no `os.environ` mutation.
60
+
61
+ ## What it does
62
+
63
+ - Builds a content-hash inventory of your `custom_components/<domain>` tree and
64
+ compares it to the remote tree, deploying only the changed files.
65
+ - Leaves remote-only files untouched (never deletes on the host).
66
+ - Streams files over SSH with an uncompressed, path-validated tar — no backups,
67
+ no lock files, no rollback, no auto-restart.
68
+ - Optionally publishes a separately-served Lovelace card.
69
+ - Optionally raises a Home Assistant *Repairs* issue (via an HA service) telling
70
+ the user a restart is needed. The engine never restarts HA itself; restart is
71
+ opt-in and happens only when a user confirms a Repairs fix flow.
72
+
73
+ ## Install
74
+
75
+ Published on PyPI. Pin an exact version.
76
+
77
+ ```sh
78
+ pip install "ha-deploy-kit==0.1.0"
79
+ ```
80
+
81
+ The HA adapter needs Home Assistant, which is an optional extra:
82
+
83
+ ```sh
84
+ pip install "ha-deploy-kit[ha]==0.1.0"
85
+ ```
86
+
87
+ - **Core** requires Python **3.10+** and only the standard library (plus a CA
88
+ trust store: `certifi`/`truststore`).
89
+ - **The `ha` adapter** requires the Python version Home Assistant itself
90
+ requires; install it in the HA runtime, not in your deploy tooling.
91
+ - **Home Assistant integrations** must pin a versioned PyPI requirement
92
+ (`"ha-deploy-kit==0.1.0"` in `manifest.json`), **never** a `git+…` VCS URL — HA
93
+ installs requirements in its own credential-less environment and treats URL
94
+ requirements as never-satisfied, retrying every startup.
95
+
96
+ See [docs/install.md](docs/install.md) for `pyproject.toml`, HA `manifest.json`,
97
+ and air-gapped host instructions, and [docs/releasing.md](docs/releasing.md) for
98
+ how releases are published.
99
+
100
+ ## Public API
101
+
102
+ | Symbol | Import | Purpose |
103
+ | --- | --- | --- |
104
+ | `DeploySettings` | `ha_deploy_kit` | Frozen per-consumer settings (domain, source root, remote config, optional frontend, timeouts). |
105
+ | `FrontendAsset` | `ha_deploy_kit` | Optional served-card mapping (`source_rel`, `published_remote`). |
106
+ | `DeployError` | `ha_deploy_kit` | Raised for recoverable, sanitized deployment errors. |
107
+ | `run(settings, argv, ssh=None, environ=None)` | `ha_deploy_kit.cli` | Orchestrate end to end: compare, report, publish the changed card, deploy the changed backend, notify. Honors a `REMOTE_CONFIG` env override. |
108
+ | `raise_restart_issue(base_url, token, settings)` | `ha_deploy_kit.notify` | POST to the HA restart service; returns a sanitized status string. |
109
+ | `frontend_run(settings, plan, ssh, *, check)` | `ha_deploy_kit.frontend` | Deploy the card into the tree and/or publish the served copy. |
110
+ | `ha.async_register_restart_service(hass, domain, ...)` | `ha_deploy_kit.ha` | Idempotently register the restart service (empty schema). Pass `fixable=True` for a one-click Repairs card (pair with the fix flow). |
111
+ | `ha.async_raise_restart_issue(hass, domain, ...)` | `ha_deploy_kit.ha` | Raise the deduplicated, non-persistent restart Repairs issue. |
112
+ | `ha.async_clear_restart_issue(hass, domain, ...)` | `ha_deploy_kit.ha` | Clear the restart issue. |
113
+ | `ha.async_create_restart_fix_flow(hass, issue_id, ...)` | `ha_deploy_kit.ha` | Optional opt-in confirm-and-restart flow (restart only on submit). |
114
+ | `ha.register_card_serving(hass, domain, lovelace_dir, card_url)` | `ha_deploy_kit.ha` | Serve the card and register it as a frontend module. |
115
+
116
+ ## CLI
117
+
118
+ The package installs a `ha-deploy` console script. It deploys by default; pass
119
+ `--check` (or its alias `--dry-run`) to preview without writing anything.
120
+
121
+ ```sh
122
+ ha-deploy # deploy changed backend files, then notify
123
+ ha-deploy --check # compare and report only; no writes, no notify
124
+ ha-deploy --root /path/to/your/repo
125
+ ```
126
+
127
+ The source root is resolved from `--root`, then `HA_DEPLOY_SOURCE_ROOT`, then
128
+ the current directory. The domain is read from `HA_DEPLOY_DOMAIN`, or inferred
129
+ when the root has exactly one `custom_components/<domain>` subdirectory.
130
+
131
+ ### One-liner for your own deploy script
132
+
133
+ `run` is fully frontend-aware, so a consumer's `deploy.py` is just settings + a
134
+ call — no pipeline to copy. It publishes a changed card and deploys a changed
135
+ backend; a frontend-only change publishes the card but never notifies a restart.
136
+
137
+ ```python
138
+ import sys
139
+ from pathlib import Path
140
+
141
+ from ha_deploy_kit import DeploySettings, FrontendAsset
142
+ from ha_deploy_kit.cli import run
143
+
144
+ settings = DeploySettings(
145
+ domain="your_domain",
146
+ source_root=Path(__file__).resolve().parent,
147
+ frontend=FrontendAsset( # omit entirely for backend-only
148
+ source_rel="lovelace/your-card.js",
149
+ published_remote="www/your_domain/your-card.js",
150
+ ),
151
+ )
152
+ raise SystemExit(run(settings, sys.argv[1:]))
153
+ ```
154
+
155
+ `TARGET`/`HA_BASE_URL`/`HA_TOKEN` and an optional `REMOTE_CONFIG` override come
156
+ from `.env` or the process environment; `run` applies `REMOTE_CONFIG` for you.
157
+
158
+ See [docs/configuration.md](docs/configuration.md) for settings and `.env`, and
159
+ [docs/adopting.md](docs/adopting.md) for a step-by-step adoption guide.
160
+
161
+ ## Design notes
162
+
163
+ This library deliberately differs from typical hand-rolled HA deploy scripts.
164
+ Each choice below is intentional:
165
+
166
+ - **No positional target and no `--restart` / `--no-restart` / `--card-only`
167
+ flags.** The target comes from the environment; the engine never restarts HA,
168
+ so there is no restart flag to pass.
169
+ - **The engine never executes a restart.** It can only raise a Repairs issue;
170
+ the actual restart is opt-in behind a user-confirmed fix flow.
171
+ - **A frontend-only change never notifies a backend restart.** Backend and
172
+ frontend are separate concerns.
173
+ - **Structured remote inventory.** The remote probe returns an explicit
174
+ four-field result (hashes, stale files, a missing-tree flag, published hash)
175
+ rather than guessing from text.
176
+ - **Environment precedence is explicit.** Process environment overrides `.env`;
177
+ the library never mutates `os.environ`.
178
+ - **Path validation.** Tar members are rejected if absolute, containing `..`,
179
+ NUL, or newlines, or escaping the component directory.
180
+ - **No backups, lock files, rollback, or auto-restart**, and tokens, raw bodies,
181
+ and secret-bearing errors are never printed. TLS and SSH verification are
182
+ never weakened.
183
+ - **Real CA trust store for the notification.** The HTTPS call to Home Assistant
184
+ verifies against the OS trust store via `truststore` (matching `curl`), falling
185
+ back to `certifi` then the stdlib default — all strictly verifying
186
+ (`CERT_REQUIRED` + hostname check). This avoids misreporting a valid cert as
187
+ `tls-error` on platforms whose default context has no usable CA store (macOS,
188
+ python.org, fresh venvs); `tls-error` is reserved for genuine failures.
189
+
190
+ ## Releases
191
+
192
+ Releases are automated: every merge to `main` runs semantic-release, which picks
193
+ the next version from the [Conventional Commit] messages (`fix:` → patch,
194
+ `feat:` → minor, `feat!:`/`BREAKING CHANGE:` → major; `docs:`/`chore:`/`test:`
195
+ publish nothing) and publishes to PyPI via Trusted Publishing. Write PR
196
+ titles/commits in that form. See [docs/releasing.md](docs/releasing.md) for the
197
+ one-time setup and the manual fallback.
198
+
199
+ [Conventional Commit]: https://www.conventionalcommits.org/