4 Commits
0.7.5 ... 0.7.6

Author SHA1 Message Date
313fcf94e7 Bump version to 0.7.6 2026-03-23 17:20:29 +01:00
d976ab56e3 Fix blueprint uninstall and restore messaging 2026-03-23 17:14:22 +01:00
80c1c2966f Restore blueprint backup restore support 2026-03-23 17:04:08 +01:00
de3fbf1a12 Fix blueprint update backups 2026-03-23 16:58:38 +01:00
8 changed files with 292 additions and 23 deletions

5
.idea/changes.md generated
View File

@@ -19,6 +19,11 @@
- 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/`.
- Bumped the integration version from `0.7.4` to `0.7.5` and added the `0.7.5` release entry to `CHANGELOG.md` for blueprint support and the documentation refresh.
- Fixed blueprint update backups: blueprint file updates now create content backups before overwrite and can roll back copied blueprint files if installation fails.
- Kept the no-restart behavior for blueprints, because blueprint deployment does not normally require a Home Assistant restart.
- Restored blueprint backup/restore availability in the UI and backend: the restore button is visible again for blueprint installs, blueprint backups can be listed, and blueprint content can now be restored from backup without forcing a restart.
- Fixed category-specific uninstall and restore messaging in the active panel: blueprints now reference `/config/blueprints` and explicitly state that no restart is required, while integrations keep the `/config/custom_components` restart warning.
- Updated the public documentation set (`README.md`, `README_DEVELOPER.md`, `README_FULL.md`, and `CHANGELOG.md`) for the completed blueprint backup, restore, uninstall, and restart-behavior work, and bumped the integration version to `0.7.6` for the next release.
### 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.

View File

@@ -11,6 +11,20 @@ Sections:
---
## 0.7.6 - 2026-03-23
### Added
- Blueprint backup listing and restore support in the repository detail view.
### Changed
- Install, update, uninstall, and restore feedback now reflects whether a restart is actually required for the selected content type.
- Blueprint restore dialogs and action messages now reference `/config/blueprints` instead of integration-only paths.
### Fixed
- Blueprint updates now create usable content backups before overwriting files.
- Blueprint backups can be restored again from the active store UI after the previous restore-button regression.
- Blueprint uninstall confirmation text now matches the real uninstall behavior and no longer incorrectly warns about `/config/custom_components` or mandatory restarts.
## 0.7.5 - 2026-03-23
### Added

View File

@@ -27,6 +27,7 @@ This README is for end users.
- Pin repositories so important items stay easy to find
- Use native Home Assistant update entities for installed integrations
- Create and restore backups for integration installs and updates
- Create and restore blueprint content backups during blueprint updates and restore actions
---
@@ -74,7 +75,7 @@ Installed to:
- Expected repository content: `blueprints/...`
- Install target: `/config/blueprints/...`
- Supports install and uninstall through the store
- Supports install, update, uninstall, backup, and restore through the store
- No restart is normally required for blueprint deployment
---
@@ -83,4 +84,3 @@ Installed to:
- Developer documentation: `README_DEVELOPER.md`
- Full user guide: `README_FULL.md`

View File

@@ -86,7 +86,8 @@ Category currently matters operationally:
- Expected source layout: `blueprints/...`
- Install target: `/config/blueprints/...`
- Initial support is focused on direct install and uninstall
- Supports install, update, uninstall, content backup, and content restore
- Restores and uninstalls are path-based and operate on the recorded installed blueprint files
- Category-aware groundwork is in place so future content types can use their own install strategies
## HTTP API
@@ -117,7 +118,7 @@ Base path: `/api/bcs`
- README rendering
- Release notes rendering
- Version selection
- Backup restore UI for integrations
- Backup restore UI for integrations and blueprints
## Contributing to the Official Store Index
@@ -156,4 +157,3 @@ The long-term architecture should remain category-aware:
- category -> UI affordances
This is especially important before Templates and Lovelace support are added, because those should stay compatible with established HACS expectations where possible.

View File

@@ -58,7 +58,7 @@ That makes update checks more reliable when a repository uses tags or releases t
- Source layout: `blueprints/...`
- Install target: `/config/blueprints/...`
- Supports install and uninstall through the store
- Supports install, update, uninstall, backup, and restore through the store
- Intended for blueprint repositories without integration-specific folder structures
---
@@ -150,7 +150,8 @@ Blueprint installs normally do not require a Home Assistant restart.
### Blueprints
- Blueprint repositories can also be reinstalled from another selected version.
- Blueprint repositories can also be updated or reinstalled from another selected version.
- Blueprint updates create content backups before overwriting files.
- The current blueprint path handling is focused on direct deployment to the blueprints folder.
---
@@ -167,6 +168,7 @@ Blueprint installs normally do not require a Home Assistant restart.
- Removes the installed blueprint files recorded by BCS
- Cleans up empty directories below `/config/blueprints` when possible
- Does not require a restart under normal conditions
---
@@ -198,6 +200,7 @@ Notes:
## Backups and Restore
Integration installs and updates create backups before overwriting existing files.
Blueprint updates also create content backups before overwriting deployed blueprint files.
Backup path:
@@ -211,7 +214,16 @@ Restore flow:
4. Confirm restore
5. Restart Home Assistant if prompted
Restore is currently intended for integrations.
Restore is available for integrations and blueprints.
Blueprint restore flow:
1. Open the blueprint repository detail
2. Click **Restore**
3. Select a backup
4. Confirm restore
Blueprint restores overwrite the recorded installed files under `/config/blueprints/...` and normally do not require a restart.
---
@@ -273,7 +285,7 @@ Opening a repository detail view can force immediate enrichment for that reposit
Restart usually matters only for integration changes.
- Integration install/update/uninstall/restore: restart expected
- Blueprint install/uninstall: restart usually not needed
- Blueprint install/update/uninstall/restore: restart usually not needed
BCS uses a Home Assistant repair flow to surface restart requirements for integration changes.

View File

@@ -1335,6 +1335,11 @@ class BCSCore:
await self.hass.async_add_executor_job(_mkdir)
@staticmethod
def _backup_repo_key(repo_id: str) -> str:
safe = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in str(repo_id or "").strip())
return safe or "repo"
def _build_backup_meta(self, repo_id: str, domain: str) -> dict[str, object]:
"""Build metadata for backup folders so restores can recover the stored version."""
inst = self.get_installed(repo_id) or {}
@@ -1391,6 +1396,68 @@ class BCSCore:
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
return backup_path
async def _backup_paths(
self,
repo_id: str,
relative_paths: list[str],
*,
meta: dict[str, object] | None = None,
) -> Path | None:
"""Backup arbitrary files under the Home Assistant config root."""
clean_paths = [str(p).strip().replace("\\", "/") for p in (relative_paths or []) if str(p).strip()]
if not clean_paths:
return None
cfg_root = Path(self.hass.config.path(""))
existing: list[str] = []
for rel in clean_paths:
target = cfg_root / rel
if target.exists():
existing.append(rel)
if not existing:
return None
await self._ensure_backup_root()
ts = time.strftime("%Y%m%d_%H%M%S")
repo_root = self._backup_root / "_content" / self._backup_repo_key(repo_id)
backup_path = repo_root / ts
def _do_backup() -> None:
repo_root.mkdir(parents=True, exist_ok=True)
if backup_path.exists():
shutil.rmtree(backup_path, ignore_errors=True)
backup_path.mkdir(parents=True, exist_ok=True)
for rel in existing:
src = cfg_root / rel
dest = backup_path / rel
dest.parent.mkdir(parents=True, exist_ok=True)
if src.is_file():
shutil.copy2(src, dest)
elif src.is_dir():
shutil.copytree(src, dest, dirs_exist_ok=True)
if meta:
try:
meta_path = backup_path / BACKUP_META_FILENAME
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
pass
try:
backups = [p for p in repo_root.iterdir() if p.is_dir()]
backups.sort(key=lambda p: p.name, reverse=True)
for old in backups[self._backup_keep_per_domain :]:
shutil.rmtree(old, ignore_errors=True)
except Exception:
pass
await self.hass.async_add_executor_job(_do_backup)
_LOGGER.info("BCS content backup created: repo_id=%s path=%s files=%s", repo_id, backup_path, len(existing))
return backup_path
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
"""Restore a domain folder from a backup."""
dest_root = Path(self.hass.config.path("custom_components"))
@@ -1428,6 +1495,37 @@ class BCSCore:
await self.hass.async_add_executor_job(_restore)
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
async def _restore_paths_from_backup(self, backup_path: Path, *, remove_targets: list[str] | None = None) -> None:
"""Restore arbitrary backed up files under the Home Assistant config root."""
cfg_root = Path(self.hass.config.path(""))
remove_list = [str(p).strip().replace("\\", "/") for p in (remove_targets or []) if str(p).strip()]
def _restore() -> None:
if not backup_path.exists() or not backup_path.is_dir():
return
for rel in remove_list:
target = cfg_root / rel
if target.exists():
if target.is_dir():
shutil.rmtree(target, ignore_errors=True)
elif target.is_file():
target.unlink(missing_ok=True)
for src in backup_path.rglob("*"):
if not src.is_file():
continue
if src.name == BACKUP_META_FILENAME:
continue
rel = src.relative_to(backup_path)
dest = cfg_root / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dest)
await self.hass.async_add_executor_job(_restore)
_LOGGER.info("BCS content rollback applied: from=%s", backup_path)
async def list_repo_backups(self, repo_id: str) -> list[dict[str, Any]]:
"""List available backup sets for an installed repository.
@@ -1438,6 +1536,30 @@ class BCSCore:
domains of the repository.
"""
inst = self.get_installed(repo_id) or {}
install_type = str(inst.get("install_type") or "integration").strip() or "integration"
if install_type == "blueprint":
repo_root = self._backup_root / "_content" / self._backup_repo_key(repo_id)
def _list_content() -> list[str]:
if not repo_root.exists() or not repo_root.is_dir():
return []
ids = [p.name for p in repo_root.iterdir() if p.is_dir()]
ids.sort(reverse=True)
return ids
ids = await self.hass.async_add_executor_job(_list_content)
items: list[dict[str, Any]] = []
for bid in ids[: self._backup_keep_per_domain]:
label = self._format_backup_id(bid)
meta = await self._read_content_backup_meta(repo_id, bid)
ver = None
if isinstance(meta, dict):
ver = meta.get("installed_version") or meta.get("ref")
if ver:
label = f"{label} ({ver})"
items.append({"id": bid, "label": label, "complete": True, "domains": [], "installed_version": str(ver) if ver else None})
return items
domains = inst.get("domains") or []
if not isinstance(domains, list) or not domains:
return []
@@ -1489,6 +1611,53 @@ class BCSCore:
if not inst:
raise BCSInstallError("Repository is not installed")
install_type = str(getattr(inst, "install_type", "integration") or "integration").strip() or "integration"
if install_type == "blueprint":
backup_path = self._backup_root / "_content" / self._backup_repo_key(repo_id) / backup_id
if not backup_path.exists() or not backup_path.is_dir():
raise BCSInstallError("Selected backup is not available")
installed_paths = [str(p).strip() for p in (getattr(inst, "installed_paths", None) or []) if str(p).strip()]
async with self._install_lock:
_LOGGER.info("BCS restore started: repo_id=%s backup_id=%s type=blueprint", repo_id, backup_id)
try:
await self._backup_paths(repo_id, installed_paths)
except Exception:
_LOGGER.debug("BCS pre-restore content backup failed for repo_id=%s", repo_id, exc_info=True)
await self._restore_paths_from_backup(backup_path, remove_targets=installed_paths)
restored_meta = await self._read_content_backup_meta(repo_id, backup_id)
restored_version: str | None = None
if isinstance(restored_meta, dict):
rv = restored_meta.get("installed_version") or restored_meta.get("ref")
if rv is not None and str(rv).strip():
restored_version = str(rv).strip()
if not restored_version:
restored_version = f"restored:{backup_id}"
repo = self.get_repo(repo_id)
repo_url = getattr(repo, "url", None) or ""
await self.storage.set_installed_repo(
repo_id=repo_id,
url=repo_url,
domains=[],
installed_version=restored_version,
installed_manifest_version=None,
ref=restored_version,
install_type="blueprint",
installed_paths=installed_paths,
)
await self._refresh_installed_cache()
self.signal_updated()
_LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s type=blueprint", repo_id, backup_id)
return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": [], "installed_paths": installed_paths, "restored_version": restored_version, "restart_required": False}
domains = inst.get("domains") or []
if not isinstance(domains, list) or not domains:
raise BCSInstallError("No installed domains found")
@@ -1585,6 +1754,17 @@ class BCSCore:
except Exception:
return None
async def _read_content_backup_meta(self, repo_id: str, backup_id: str) -> dict[str, Any] | None:
try:
p = self._backup_root / "_content" / self._backup_repo_key(repo_id) / backup_id / BACKUP_META_FILENAME
if not p.exists():
return None
txt = await self.hass.async_add_executor_job(p.read_text, 'utf-8')
data = json.loads(txt)
return data if isinstance(data, dict) else None
except Exception:
return None
async def _read_backup_manifest_version(self, domain: str, backup_id: str) -> str | None:
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
@@ -1806,6 +1986,7 @@ class BCSCore:
installed_domains: list[str] = []
installed_paths: list[str] = []
backups: dict[str, Path] = {}
content_backup: Path | None = None
install_type = self._repo_install_type(repo)
inst_before = self.get_installed(repo_id) or {}
@@ -1832,8 +2013,22 @@ class BCSCore:
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"))
planned_paths: list[str] = []
for src in blueprints_root.rglob("*"):
if not src.is_file():
continue
rel = src.relative_to(blueprints_root)
planned_paths.append(str(Path("blueprints") / rel).replace("\\", "/"))
if not planned_paths:
raise BCSInstallError("No blueprint files found under blueprints/")
m = dict(backup_meta)
m["install_type"] = "blueprint"
m["installed_paths"] = planned_paths
content_backup = await self._backup_paths(repo_id, planned_paths, meta=m)
def _copy_blueprints() -> list[str]:
copied: list[str] = []
@@ -1849,8 +2044,6 @@ class BCSCore:
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:
@@ -1934,6 +2127,12 @@ class BCSCore:
except Exception:
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
if content_backup is not None:
try:
await self._restore_paths_from_backup(content_backup, remove_targets=installed_paths)
except Exception:
_LOGGER.debug("BCS content rollback failed for repo_id=%s", repo_id, exc_info=True)
# Remove newly created domains if the install did not complete.
for domain in created_new:
try:

View File

@@ -1,7 +1,7 @@
{
"domain": "bahmcloud_store",
"name": "Bahmcloud Store",
"version": "0.7.5",
"version": "0.7.6",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"config_flow": true,
"platforms": ["update"],

View File

@@ -206,7 +206,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._error = this._safeText(resp?.message) || "Install failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Installation finished. Restart required.";
this._lastActionMsg = resp?.restart_required
? "Installation finished. Restart required."
: "Installation finished. No restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
@@ -238,7 +240,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._error = this._safeText(resp?.message) || "Update failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Update finished. Restart required.";
this._lastActionMsg = resp?.restart_required
? "Update finished. Restart required."
: "Update finished. No restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
@@ -253,7 +257,11 @@ class BahmcloudStorePanel extends HTMLElement {
if (!repoId) return;
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) return;
const ok = window.confirm("Really uninstall this repository? This will remove its files from /config/custom_components and requires a restart.");
const details = this._getInstallTypeDetails(repoId);
const uninstallText = details.restartRequired
? `Really uninstall this repository? This will remove its files from ${details.targetPath} and requires a restart.`
: `Really uninstall this repository? This will remove its files from ${details.targetPath}. No restart is required.`;
const ok = window.confirm(uninstallText);
if (!ok) return;
this._uninstallingRepoId = repoId;
@@ -267,7 +275,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._error = this._safeText(resp?.message) || "Uninstall failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Uninstall finished. Restart required.";
this._lastActionMsg = resp?.restart_required
? "Uninstall finished. Restart required."
: "Uninstall finished. No restart required.";
}
} catch (e) {
this._error = e?.message ? String(e.message) : String(e);
@@ -337,7 +347,11 @@ class BahmcloudStorePanel extends HTMLElement {
return;
}
const ok = window.confirm("Restore selected backup? This will overwrite the installed files under /config/custom_components and requires a restart.");
const details = this._getInstallTypeDetails(this._restoreRepoId);
const restoreText = details.restartRequired
? `Restore selected backup? This will overwrite the installed files under ${details.targetPath} and requires a restart.`
: `Restore selected backup? This will overwrite the installed files under ${details.targetPath}. No restart is required.`;
const ok = window.confirm(restoreText);
if (!ok) return;
this._restoring = true;
@@ -350,7 +364,9 @@ class BahmcloudStorePanel extends HTMLElement {
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
} else {
this._restartRequired = !!resp.restart_required;
this._lastActionMsg = "Restore finished. Restart required.";
this._lastActionMsg = resp?.restart_required
? "Restore finished. Restart required."
: "Restore finished. No restart required.";
this._closeRestore();
}
} catch (e) {
@@ -981,6 +997,27 @@ class BahmcloudStorePanel extends HTMLElement {
return !!id && Array.isArray(this._favoriteRepoIds) && this._favoriteRepoIds.includes(id);
}
_getRepoById(repoId) {
const id = this._safeId(repoId);
if (!id || !Array.isArray(this._data?.repos)) return null;
return this._data.repos.find((r) => this._safeId(r?.id) === id) || null;
}
_getInstallTypeDetails(repoOrId) {
const repo = typeof repoOrId === "string" ? this._getRepoById(repoOrId) : repoOrId;
const installType = this._safeText(repo?.install_type) || "integration";
if (installType === "blueprint") {
return { installType, targetPath: "/config/blueprints", restartRequired: false };
}
if (installType === "template") {
return { installType, targetPath: "/config", restartRequired: false };
}
if (installType === "lovelace") {
return { installType, targetPath: "/config/www", restartRequired: false };
}
return { installType: "integration", targetPath: "/config/custom_components", restartRequired: true };
}
async _toggleFavorite(repoId) {
if (!this._hass || !repoId) return;
@@ -1379,9 +1416,7 @@ 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 = installType === "integration"
? `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`
: ``;
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
const restartHint = this._restartRequired
@@ -1628,12 +1663,16 @@ class BahmcloudStorePanel extends HTMLElement {
})
.join("");
const restoreDetails = this._getInstallTypeDetails(this._restoreRepoId);
const actionHint = restoreDetails.restartRequired
? `Select a backup to restore. This will overwrite files under ${restoreDetails.targetPath} and requires a restart.`
: `Select a backup to restore. This will overwrite files under ${restoreDetails.targetPath}. No restart is required.`;
const msg = this._restoreLoading
? "Loading backups…"
: this._restoreError
? this._safeText(this._restoreError)
: opts.length
? "Select a backup to restore. This will overwrite files under /config/custom_components and requires a restart."
? actionHint
: "No backups found.";
return `