Prepare blueprint install support
This commit is contained in:
3
.idea/changes.md
generated
3
.idea/changes.md
generated
@@ -15,6 +15,9 @@
|
||||
- Added a persistent language rule that all project-facing repository content must stay in English regardless of the chat language.
|
||||
- Added persistent pinned repositories support: favorites are stored in settings, exposed by the backend, filterable and sortable in the store view, and toggleable from the detail view without forcing a full repository refresh.
|
||||
- Bumped the integration version from `0.7.3` to `0.7.4` and added the `0.7.4` release entry to `CHANGELOG.md` for the pinned-repositories feature.
|
||||
- Added the broader product roadmap to the persistent project prompt: blueprints, templates, Lovelace designs, and more category support as future store targets.
|
||||
- Started the blueprint/category-aware installer preparation: installation metadata now distinguishes install type and installed paths, repo payloads expose install targets, and the active panel shows install-target context for future non-integration categories.
|
||||
- Added initial blueprint install-path handling groundwork so the codebase is no longer fully hard-wired to `custom_components/`.
|
||||
|
||||
### Documented
|
||||
- Captured the verified project identity from the repository and README files: Bahmcloud Store is a Home Assistant custom integration intended to behave like a provider-neutral store for custom integrations, similar to HACS but broader than GitHub-only workflows.
|
||||
|
||||
11
.idea/start prompt.md
generated
11
.idea/start prompt.md
generated
@@ -6,6 +6,7 @@ Project identity:
|
||||
- This is a Home Assistant custom integration named `bahmcloud_store`.
|
||||
- The product goal is a provider-neutral store for Home Assistant custom integrations, similar in spirit to HACS, but not limited to GitHub.
|
||||
- Current real provider implementation is strongest for GitHub, GitLab, and Gitea-compatible providers. Unknown providers currently fall through the Gitea-style code paths, so do not assume every arbitrary Git provider works without verification.
|
||||
- The long-term product direction is broader than integrations only: the store should evolve into a Home Assistant content store that can manage integrations, blueprints, templates, Lovelace designs, and additional future categories from multiple git providers.
|
||||
|
||||
Working rules:
|
||||
- All project-facing work must be done in English only, regardless of the language the user speaks in chat. This applies to code comments, documentation, changelog entries, release notes, commit messages, PR text, UI text, issue text, and any other project artifacts unless the user explicitly requests a specific exception for repository content.
|
||||
@@ -102,6 +103,16 @@ Code-analysis findings that should influence future work:
|
||||
- The end-user and full READMEs contain some stale or inconsistent details compared with the current UI and code. Verify behavior in source before using README text as specification.
|
||||
- There are visible encoding/mojibake issues in some documentation and older UI assets. Preserve valid UTF-8 when editing.
|
||||
|
||||
Planned product expansion:
|
||||
1. Add support for Home Assistant blueprints and install them directly into the correct Home Assistant blueprint location from the store.
|
||||
2. Add templates and Lovelace designs to the store so they can be discovered and installed from the same UI.
|
||||
3. Add support for more categories beyond integrations and design the architecture so category-specific install targets and validation rules are explicit.
|
||||
|
||||
Implications for future architecture:
|
||||
- The current install pipeline is integration-centric because it assumes repository content under `custom_components/`.
|
||||
- Future category support should move toward category-aware install strategies instead of one universal install path.
|
||||
- Store metadata and index entries will likely need stronger category typing, install-target definitions, and validation rules per category.
|
||||
|
||||
Project constraints to respect in future edits:
|
||||
- Keep async I/O non-blocking in Home Assistant.
|
||||
- Avoid startup-heavy network work before HA is fully started.
|
||||
|
||||
@@ -90,7 +90,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
frontend_url_path="bahmcloud-store",
|
||||
webcomponent_name="bahmcloud-store-panel",
|
||||
# IMPORTANT: bump v to avoid caching old JS
|
||||
module_url="/api/bahmcloud_store_static/panel.js?v=111",
|
||||
module_url="/api/bahmcloud_store_static/panel.js?v=112",
|
||||
sidebar_title="Bahmcloud Store",
|
||||
sidebar_icon="mdi:store",
|
||||
require_admin=True,
|
||||
|
||||
@@ -1172,13 +1172,17 @@ class BCSCore:
|
||||
"latest_version": r.latest_version,
|
||||
"latest_version_source": r.latest_version_source,
|
||||
"category": r.meta_category,
|
||||
"category_key": self._repo_install_type(r),
|
||||
"install_target": self._repo_install_target(r),
|
||||
"meta_author": r.meta_author,
|
||||
"meta_maintainer": r.meta_maintainer,
|
||||
"meta_source": r.meta_source,
|
||||
"installed": installed,
|
||||
"install_type": str(inst.get("install_type") if isinstance(inst, dict) else self._repo_install_type(r)),
|
||||
"installed_version": installed_version,
|
||||
"installed_manifest_version": installed_manifest_version,
|
||||
"installed_domains": installed_domains,
|
||||
"installed_paths": list(inst.get("installed_paths") or []) if isinstance(inst, dict) else [],
|
||||
"favorite": self.is_favorite_repo(r.id),
|
||||
}
|
||||
)
|
||||
@@ -1220,6 +1224,32 @@ class BCSCore:
|
||||
return str(repo.default_branch).strip()
|
||||
return "main"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_category_key(category: str | None) -> str:
|
||||
raw = str(category or "").strip().lower()
|
||||
if raw in ("integration", "integrations"):
|
||||
return "integration"
|
||||
if raw in ("blueprint", "blueprints"):
|
||||
return "blueprint"
|
||||
if raw in ("template", "templates"):
|
||||
return "template"
|
||||
if raw in ("lovelace", "dashboard", "dashboards", "lovelace design", "lovelace designs"):
|
||||
return "lovelace"
|
||||
return "integration"
|
||||
|
||||
def _repo_install_type(self, repo: RepoItem | None) -> str:
|
||||
return self._normalize_category_key(getattr(repo, "meta_category", None))
|
||||
|
||||
def _repo_install_target(self, repo: RepoItem | None) -> str:
|
||||
install_type = self._repo_install_type(repo)
|
||||
if install_type == "blueprint":
|
||||
return "/config/blueprints"
|
||||
if install_type == "template":
|
||||
return "/config"
|
||||
if install_type == "lovelace":
|
||||
return "/config"
|
||||
return "/config/custom_components"
|
||||
|
||||
def _build_zip_url(self, repo_url: str, ref: str) -> str:
|
||||
ref = (ref or "").strip()
|
||||
if not ref:
|
||||
@@ -1286,6 +1316,18 @@ class BCSCore:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _find_blueprints_root(extract_root: Path) -> Path | None:
|
||||
direct = extract_root / "blueprints"
|
||||
if direct.exists() and direct.is_dir():
|
||||
return direct
|
||||
|
||||
for child in extract_root.iterdir():
|
||||
candidate = child / "blueprints"
|
||||
if candidate.exists() and candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
async def _ensure_backup_root(self) -> None:
|
||||
"""Create backup root directory if needed."""
|
||||
def _mkdir() -> None:
|
||||
@@ -1640,15 +1682,25 @@ class BCSCore:
|
||||
|
||||
for it in items:
|
||||
domains = [str(d) for d in (it.domains or []) if str(d).strip()]
|
||||
install_type = str(getattr(it, "install_type", "integration") or "integration").strip() or "integration"
|
||||
installed_paths = [str(p) for p in (getattr(it, "installed_paths", None) or []) if str(p).strip()]
|
||||
|
||||
# A repo is considered "present" if at least one of its domains
|
||||
# exists and contains a manifest.json.
|
||||
present = False
|
||||
if install_type == "integration":
|
||||
for d in domains:
|
||||
p = cc_root / d
|
||||
if p.is_dir() and (p / "manifest.json").exists():
|
||||
present = True
|
||||
break
|
||||
else:
|
||||
cfg_root = Path(self.hass.config.path(""))
|
||||
for rel in installed_paths:
|
||||
p = cfg_root / rel
|
||||
if p.exists():
|
||||
present = True
|
||||
break
|
||||
|
||||
if not present:
|
||||
to_remove.append(it.repo_id)
|
||||
@@ -1657,6 +1709,8 @@ class BCSCore:
|
||||
cache[it.repo_id] = {
|
||||
"installed": True,
|
||||
"domains": domains,
|
||||
"install_type": install_type,
|
||||
"installed_paths": installed_paths,
|
||||
"installed_version": it.installed_version,
|
||||
"installed_manifest_version": it.installed_manifest_version,
|
||||
"ref": it.ref,
|
||||
@@ -1692,6 +1746,24 @@ class BCSCore:
|
||||
if path.exists() and path.is_dir():
|
||||
shutil.rmtree(path, ignore_errors=True)
|
||||
|
||||
def _remove_file(path: Path) -> None:
|
||||
if path.exists() and path.is_file():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
def _prune_empty_parents(path: Path, stop_at: Path) -> None:
|
||||
cur = path.parent
|
||||
while cur != stop_at and str(cur).startswith(str(stop_at)):
|
||||
try:
|
||||
if any(cur.iterdir()):
|
||||
break
|
||||
cur.rmdir()
|
||||
except Exception:
|
||||
break
|
||||
cur = cur.parent
|
||||
|
||||
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
|
||||
|
||||
if install_type == "integration":
|
||||
for domain in inst.domains:
|
||||
d = str(domain).strip()
|
||||
if not d:
|
||||
@@ -1699,17 +1771,25 @@ class BCSCore:
|
||||
target = cc_root / d
|
||||
await self.hass.async_add_executor_job(_remove_dir, target)
|
||||
removed.append(d)
|
||||
elif install_type == "blueprint":
|
||||
cfg_root = Path(self.hass.config.path(""))
|
||||
blueprints_root = Path(self.hass.config.path("blueprints"))
|
||||
for rel in [str(p).strip() for p in (inst.installed_paths or []) if str(p).strip()]:
|
||||
target = cfg_root / rel
|
||||
await self.hass.async_add_executor_job(_remove_file, target)
|
||||
await self.hass.async_add_executor_job(_prune_empty_parents, target, blueprints_root)
|
||||
removed.append(rel)
|
||||
|
||||
await self.storage.remove_installed_repo(repo_id)
|
||||
await self._refresh_installed_cache()
|
||||
|
||||
# Show restart required in Settings.
|
||||
if removed:
|
||||
if removed and install_type == "integration":
|
||||
self._mark_restart_required()
|
||||
|
||||
_LOGGER.info("BCS uninstall complete: repo_id=%s removed_domains=%s", repo_id, removed)
|
||||
self.signal_updated()
|
||||
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed)}
|
||||
return {"ok": True, "repo_id": repo_id, "removed": removed, "restart_required": bool(removed) if install_type == "integration" else False}
|
||||
|
||||
async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
|
||||
repo = self.get_repo(repo_id)
|
||||
@@ -1724,7 +1804,9 @@ class BCSCore:
|
||||
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
|
||||
|
||||
installed_domains: list[str] = []
|
||||
installed_paths: list[str] = []
|
||||
backups: dict[str, Path] = {}
|
||||
install_type = self._repo_install_type(repo)
|
||||
|
||||
inst_before = self.get_installed(repo_id) or {}
|
||||
backup_meta = {
|
||||
@@ -1745,6 +1827,31 @@ class BCSCore:
|
||||
await self._download_zip(zip_url, zip_path)
|
||||
await self._extract_zip(zip_path, extract_dir)
|
||||
|
||||
if install_type == "blueprint":
|
||||
blueprints_root = self._find_blueprints_root(extract_dir)
|
||||
if not blueprints_root:
|
||||
raise BCSInstallError("blueprints folder not found in repository ZIP")
|
||||
|
||||
config_root = Path(self.hass.config.path(""))
|
||||
target_root = Path(self.hass.config.path("blueprints"))
|
||||
|
||||
def _copy_blueprints() -> list[str]:
|
||||
copied: list[str] = []
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
for src in blueprints_root.rglob("*"):
|
||||
if not src.is_file():
|
||||
continue
|
||||
rel = src.relative_to(blueprints_root)
|
||||
dest = target_root / rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
copied.append(str(Path("blueprints") / rel).replace("\\", "/"))
|
||||
return copied
|
||||
|
||||
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
|
||||
if not installed_paths:
|
||||
raise BCSInstallError("No blueprint files found under blueprints/")
|
||||
else:
|
||||
cc_root = self._find_custom_components_root(extract_dir)
|
||||
if not cc_root:
|
||||
raise BCSInstallError("custom_components folder not found in repository ZIP")
|
||||
@@ -1777,25 +1884,28 @@ class BCSCore:
|
||||
if not installed_domains:
|
||||
raise BCSInstallError("No integrations found under custom_components/ (missing manifest.json)")
|
||||
|
||||
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0])
|
||||
installed_manifest_version = await self._read_installed_manifest_version(installed_domains[0]) if installed_domains else None
|
||||
installed_version = ref
|
||||
|
||||
await self.storage.set_installed_repo(
|
||||
repo_id=repo_id,
|
||||
url=repo.url,
|
||||
domains=installed_domains,
|
||||
install_type=install_type,
|
||||
installed_paths=installed_paths,
|
||||
installed_version=installed_version,
|
||||
installed_manifest_version=installed_manifest_version,
|
||||
ref=ref,
|
||||
)
|
||||
await self._refresh_installed_cache()
|
||||
|
||||
if install_type == "integration":
|
||||
self._mark_restart_required()
|
||||
|
||||
_LOGGER.info(
|
||||
"BCS install complete: repo_id=%s domains=%s installed_ref=%s manifest_version=%s",
|
||||
repo_id,
|
||||
installed_domains,
|
||||
installed_domains if installed_domains else installed_paths,
|
||||
installed_version,
|
||||
installed_manifest_version,
|
||||
)
|
||||
@@ -1804,9 +1914,11 @@ class BCSCore:
|
||||
"ok": True,
|
||||
"repo_id": repo_id,
|
||||
"domains": installed_domains,
|
||||
"installed_paths": installed_paths,
|
||||
"install_type": install_type,
|
||||
"installed_version": installed_version,
|
||||
"installed_manifest_version": installed_manifest_version,
|
||||
"restart_required": True,
|
||||
"restart_required": True if install_type == "integration" else False,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1270,6 +1270,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
||||
this._safeText(r?.latest_version) ? `Latest: ${this._safeText(r?.latest_version)}` : "Latest: -",
|
||||
this._safeText(r?.provider) ? `Provider: ${this._safeText(r?.provider)}` : null,
|
||||
this._safeText(r?.category) ? `Category: ${this._safeText(r?.category)}` : null,
|
||||
this._safeText(r?.install_target) ? `Target: ${this._safeText(r?.install_target)}` : null,
|
||||
this._safeText(r?.meta_author) ? `Author: ${this._safeText(r?.meta_author)}` : null,
|
||||
this._safeText(r?.meta_maintainer) ? `Maintainer: ${this._safeText(r?.meta_maintainer)}` : null,
|
||||
this._safeText(r?.meta_source) ? `Meta: ${this._safeText(r?.meta_source)}` : null,
|
||||
@@ -1312,8 +1313,10 @@ class BahmcloudStorePanel extends HTMLElement {
|
||||
const installed = this._asBoolStrict(r?.installed);
|
||||
const installedVersion = this._safeText(r?.installed_version);
|
||||
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
|
||||
const installedPaths = Array.isArray(r?.installed_paths) ? r.installed_paths : [];
|
||||
const latestVersion = this._safeText(r?.latest_version);
|
||||
const favorite = this._asBoolStrict(r?.favorite) || this._isFavoriteRepo(repoId);
|
||||
const installType = this._safeText(r?.install_type) || "integration";
|
||||
|
||||
const busyInstall = this._installingRepoId === repoId;
|
||||
const busyUpdate = this._updatingRepoId === repoId;
|
||||
@@ -1376,7 +1379,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
||||
const installBtn = `<button class="primary" id="btnInstall" ${installed || busy ? "disabled" : ""}>${busyInstall ? "Installing…" : installed ? "Installed" : "Install"}</button>`;
|
||||
const updateBtn = `<button class="primary" id="btnUpdate" ${!updateAvailable || busy ? "disabled" : ""}>${busyUpdate ? "Updating…" : updateAvailable ? "Update" : "Up to date"}</button>`;
|
||||
const uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
|
||||
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
|
||||
const restoreBtn = installType === "integration"
|
||||
? `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`
|
||||
: ``;
|
||||
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
|
||||
|
||||
const restartHint = this._restartRequired
|
||||
@@ -1424,6 +1429,7 @@ class BahmcloudStorePanel extends HTMLElement {
|
||||
<div><strong>Installed version:</strong> ${this._esc(installedVersion || "-")}</div>
|
||||
<div><strong>Latest version:</strong> ${this._esc(latestVersion || "-")}</div>
|
||||
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
|
||||
<div style="margin-top:6px;"><strong>Installed paths:</strong> ${installedPaths.length ? this._esc(installedPaths.join(", ")) : "-"}</div>
|
||||
</div>
|
||||
|
||||
${versionSelect}
|
||||
|
||||
@@ -25,6 +25,8 @@ class InstalledRepo:
|
||||
url: str
|
||||
domains: list[str]
|
||||
installed_at: int
|
||||
install_type: str = "integration"
|
||||
installed_paths: list[str] | None = None
|
||||
installed_version: str | None = None # BCS "installed ref" (tag/release/branch)
|
||||
installed_manifest_version: str | None = None # informational only
|
||||
ref: str | None = None # kept for backward compatibility / diagnostics
|
||||
@@ -197,6 +199,11 @@ class BCSStorage:
|
||||
if not isinstance(domains, list):
|
||||
domains = []
|
||||
domains = [str(d) for d in domains if str(d).strip()]
|
||||
installed_paths = entry.get("installed_paths") or []
|
||||
if not isinstance(installed_paths, list):
|
||||
installed_paths = []
|
||||
installed_paths = [str(p) for p in installed_paths if str(p).strip()]
|
||||
install_type = str(entry.get("install_type") or "integration").strip() or "integration"
|
||||
|
||||
installed_version = entry.get("installed_version")
|
||||
ref = entry.get("ref")
|
||||
@@ -213,6 +220,8 @@ class BCSStorage:
|
||||
url=str(entry.get("url") or ""),
|
||||
domains=domains,
|
||||
installed_at=int(entry.get("installed_at") or 0),
|
||||
install_type=install_type,
|
||||
installed_paths=installed_paths,
|
||||
installed_version=str(installed_version) if installed_version else None,
|
||||
installed_manifest_version=str(installed_manifest_version) if installed_manifest_version else None,
|
||||
ref=str(ref) if ref else None,
|
||||
@@ -238,9 +247,11 @@ class BCSStorage:
|
||||
repo_id: str,
|
||||
url: str,
|
||||
domains: list[str],
|
||||
installed_version: str | None,
|
||||
installed_version: str | None = None,
|
||||
installed_manifest_version: str | None = None,
|
||||
ref: str | None,
|
||||
ref: str | None = None,
|
||||
install_type: str = "integration",
|
||||
installed_paths: list[str] | None = None,
|
||||
) -> None:
|
||||
data = await self._load()
|
||||
installed = data.get("installed_repos", {})
|
||||
@@ -252,6 +263,8 @@ class BCSStorage:
|
||||
"repo_id": str(repo_id),
|
||||
"url": str(url),
|
||||
"domains": [str(d) for d in (domains or []) if str(d).strip()],
|
||||
"install_type": str(install_type or "integration"),
|
||||
"installed_paths": [str(p) for p in (installed_paths or []) if str(p).strip()],
|
||||
"installed_at": int(time.time()),
|
||||
# IMPORTANT: this is what BCS uses as "installed version" (ref/tag/branch)
|
||||
"installed_version": installed_version,
|
||||
|
||||
@@ -540,6 +540,9 @@ class BCSRepoDetailView(HomeAssistantView):
|
||||
domains = inst.get("domains") or []
|
||||
if not isinstance(domains, list):
|
||||
domains = []
|
||||
installed_paths = inst.get("installed_paths") or []
|
||||
if not isinstance(installed_paths, list):
|
||||
installed_paths = []
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
@@ -556,13 +559,17 @@ class BCSRepoDetailView(HomeAssistantView):
|
||||
"latest_version": repo.latest_version,
|
||||
"latest_version_source": repo.latest_version_source,
|
||||
"category": repo.meta_category,
|
||||
"category_key": self.core._repo_install_type(repo),
|
||||
"install_target": self.core._repo_install_target(repo),
|
||||
"meta_author": repo.meta_author,
|
||||
"meta_maintainer": repo.meta_maintainer,
|
||||
"meta_source": repo.meta_source,
|
||||
"installed": installed,
|
||||
"install_type": inst.get("install_type") or self.core._repo_install_type(repo),
|
||||
"installed_version": inst.get("installed_version"),
|
||||
"installed_manifest_version": inst.get("installed_manifest_version"),
|
||||
"installed_domains": domains,
|
||||
"installed_paths": installed_paths,
|
||||
"favorite": self.core.is_favorite_repo(repo.id),
|
||||
}
|
||||
}, status=200)
|
||||
|
||||
Reference in New Issue
Block a user