Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 313fcf94e7 | |||
| d976ab56e3 | |||
| 80c1c2966f | |||
| de3fbf1a12 |
5
.idea/changes.md
generated
5
.idea/changes.md
generated
@@ -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.
|
- 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/`.
|
- 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.
|
- 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
|
### 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.
|
- 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.
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -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
|
## 0.7.5 - 2026-03-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ This README is for end users.
|
|||||||
- Pin repositories so important items stay easy to find
|
- Pin repositories so important items stay easy to find
|
||||||
- Use native Home Assistant update entities for installed integrations
|
- Use native Home Assistant update entities for installed integrations
|
||||||
- Create and restore backups for integration installs and updates
|
- 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/...`
|
- Expected repository content: `blueprints/...`
|
||||||
- Install target: `/config/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
|
- No restart is normally required for blueprint deployment
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -83,4 +84,3 @@ Installed to:
|
|||||||
|
|
||||||
- Developer documentation: `README_DEVELOPER.md`
|
- Developer documentation: `README_DEVELOPER.md`
|
||||||
- Full user guide: `README_FULL.md`
|
- Full user guide: `README_FULL.md`
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ Category currently matters operationally:
|
|||||||
|
|
||||||
- Expected source layout: `blueprints/...`
|
- Expected source layout: `blueprints/...`
|
||||||
- Install target: `/config/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
|
- Category-aware groundwork is in place so future content types can use their own install strategies
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
@@ -117,7 +118,7 @@ Base path: `/api/bcs`
|
|||||||
- README rendering
|
- README rendering
|
||||||
- Release notes rendering
|
- Release notes rendering
|
||||||
- Version selection
|
- Version selection
|
||||||
- Backup restore UI for integrations
|
- Backup restore UI for integrations and blueprints
|
||||||
|
|
||||||
## Contributing to the Official Store Index
|
## Contributing to the Official Store Index
|
||||||
|
|
||||||
@@ -156,4 +157,3 @@ The long-term architecture should remain category-aware:
|
|||||||
- category -> UI affordances
|
- 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.
|
This is especially important before Templates and Lovelace support are added, because those should stay compatible with established HACS expectations where possible.
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ That makes update checks more reliable when a repository uses tags or releases t
|
|||||||
|
|
||||||
- Source layout: `blueprints/...`
|
- Source layout: `blueprints/...`
|
||||||
- Install target: `/config/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
|
- Intended for blueprint repositories without integration-specific folder structures
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -150,7 +150,8 @@ Blueprint installs normally do not require a Home Assistant restart.
|
|||||||
|
|
||||||
### Blueprints
|
### 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.
|
- 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
|
- Removes the installed blueprint files recorded by BCS
|
||||||
- Cleans up empty directories below `/config/blueprints` when possible
|
- 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
|
## Backups and Restore
|
||||||
|
|
||||||
Integration installs and updates create backups before overwriting existing files.
|
Integration installs and updates create backups before overwriting existing files.
|
||||||
|
Blueprint updates also create content backups before overwriting deployed blueprint files.
|
||||||
|
|
||||||
Backup path:
|
Backup path:
|
||||||
|
|
||||||
@@ -211,7 +214,16 @@ Restore flow:
|
|||||||
4. Confirm restore
|
4. Confirm restore
|
||||||
5. Restart Home Assistant if prompted
|
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.
|
Restart usually matters only for integration changes.
|
||||||
|
|
||||||
- Integration install/update/uninstall/restore: restart expected
|
- 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.
|
BCS uses a Home Assistant repair flow to surface restart requirements for integration changes.
|
||||||
|
|
||||||
|
|||||||
@@ -1335,6 +1335,11 @@ class BCSCore:
|
|||||||
|
|
||||||
await self.hass.async_add_executor_job(_mkdir)
|
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]:
|
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."""
|
"""Build metadata for backup folders so restores can recover the stored version."""
|
||||||
inst = self.get_installed(repo_id) or {}
|
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)
|
_LOGGER.info("BCS backup created: domain=%s path=%s", domain, backup_path)
|
||||||
return 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:
|
async def _restore_domain_from_backup(self, domain: str, backup_path: Path) -> None:
|
||||||
"""Restore a domain folder from a backup."""
|
"""Restore a domain folder from a backup."""
|
||||||
dest_root = Path(self.hass.config.path("custom_components"))
|
dest_root = Path(self.hass.config.path("custom_components"))
|
||||||
@@ -1428,6 +1495,37 @@ class BCSCore:
|
|||||||
await self.hass.async_add_executor_job(_restore)
|
await self.hass.async_add_executor_job(_restore)
|
||||||
_LOGGER.info("BCS rollback applied: domain=%s from=%s", domain, backup_path)
|
_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]]:
|
async def list_repo_backups(self, repo_id: str) -> list[dict[str, Any]]:
|
||||||
"""List available backup sets for an installed repository.
|
"""List available backup sets for an installed repository.
|
||||||
|
|
||||||
@@ -1438,6 +1536,30 @@ class BCSCore:
|
|||||||
domains of the repository.
|
domains of the repository.
|
||||||
"""
|
"""
|
||||||
inst = self.get_installed(repo_id) or {}
|
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 []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list) or not domains:
|
if not isinstance(domains, list) or not domains:
|
||||||
return []
|
return []
|
||||||
@@ -1489,6 +1611,53 @@ class BCSCore:
|
|||||||
if not inst:
|
if not inst:
|
||||||
raise BCSInstallError("Repository is not installed")
|
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 []
|
domains = inst.get("domains") or []
|
||||||
if not isinstance(domains, list) or not domains:
|
if not isinstance(domains, list) or not domains:
|
||||||
raise BCSInstallError("No installed domains found")
|
raise BCSInstallError("No installed domains found")
|
||||||
@@ -1585,6 +1754,17 @@ class BCSCore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return None
|
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:
|
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)."""
|
"""Best-effort: read manifest.json version from a legacy backup (no metadata)."""
|
||||||
@@ -1806,6 +1986,7 @@ class BCSCore:
|
|||||||
installed_domains: list[str] = []
|
installed_domains: list[str] = []
|
||||||
installed_paths: list[str] = []
|
installed_paths: list[str] = []
|
||||||
backups: dict[str, Path] = {}
|
backups: dict[str, Path] = {}
|
||||||
|
content_backup: Path | None = None
|
||||||
install_type = self._repo_install_type(repo)
|
install_type = self._repo_install_type(repo)
|
||||||
|
|
||||||
inst_before = self.get_installed(repo_id) or {}
|
inst_before = self.get_installed(repo_id) or {}
|
||||||
@@ -1832,8 +2013,22 @@ class BCSCore:
|
|||||||
if not blueprints_root:
|
if not blueprints_root:
|
||||||
raise BCSInstallError("blueprints folder not found in repository ZIP")
|
raise BCSInstallError("blueprints folder not found in repository ZIP")
|
||||||
|
|
||||||
config_root = Path(self.hass.config.path(""))
|
|
||||||
target_root = Path(self.hass.config.path("blueprints"))
|
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]:
|
def _copy_blueprints() -> list[str]:
|
||||||
copied: list[str] = []
|
copied: list[str] = []
|
||||||
@@ -1849,8 +2044,6 @@ class BCSCore:
|
|||||||
return copied
|
return copied
|
||||||
|
|
||||||
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
|
installed_paths = await self.hass.async_add_executor_job(_copy_blueprints)
|
||||||
if not installed_paths:
|
|
||||||
raise BCSInstallError("No blueprint files found under blueprints/")
|
|
||||||
else:
|
else:
|
||||||
cc_root = self._find_custom_components_root(extract_dir)
|
cc_root = self._find_custom_components_root(extract_dir)
|
||||||
if not cc_root:
|
if not cc_root:
|
||||||
@@ -1934,6 +2127,12 @@ class BCSCore:
|
|||||||
except Exception:
|
except Exception:
|
||||||
_LOGGER.debug("BCS rollback failed for domain=%s", domain, exc_info=True)
|
_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.
|
# Remove newly created domains if the install did not complete.
|
||||||
for domain in created_new:
|
for domain in created_new:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "bahmcloud_store",
|
"domain": "bahmcloud_store",
|
||||||
"name": "Bahmcloud Store",
|
"name": "Bahmcloud Store",
|
||||||
"version": "0.7.5",
|
"version": "0.7.6",
|
||||||
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"platforms": ["update"],
|
"platforms": ["update"],
|
||||||
|
|||||||
@@ -206,7 +206,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Install failed.";
|
this._error = this._safeText(resp?.message) || "Install failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
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) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(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.";
|
this._error = this._safeText(resp?.message) || "Update failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
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) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -253,7 +257,11 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
if (!repoId) return;
|
if (!repoId) return;
|
||||||
if (this._installingRepoId || this._updatingRepoId || this._uninstallingRepoId) 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;
|
if (!ok) return;
|
||||||
|
|
||||||
this._uninstallingRepoId = repoId;
|
this._uninstallingRepoId = repoId;
|
||||||
@@ -267,7 +275,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._error = this._safeText(resp?.message) || "Uninstall failed.";
|
this._error = this._safeText(resp?.message) || "Uninstall failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
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) {
|
} catch (e) {
|
||||||
this._error = e?.message ? String(e.message) : String(e);
|
this._error = e?.message ? String(e.message) : String(e);
|
||||||
@@ -337,7 +347,11 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
return;
|
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;
|
if (!ok) return;
|
||||||
|
|
||||||
this._restoring = true;
|
this._restoring = true;
|
||||||
@@ -350,7 +364,9 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
|
this._restoreError = this._safeText(resp?.message) || "Restore failed.";
|
||||||
} else {
|
} else {
|
||||||
this._restartRequired = !!resp.restart_required;
|
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();
|
this._closeRestore();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -981,6 +997,27 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
return !!id && Array.isArray(this._favoriteRepoIds) && this._favoriteRepoIds.includes(id);
|
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) {
|
async _toggleFavorite(repoId) {
|
||||||
if (!this._hass || !repoId) return;
|
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 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 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 uninstallBtn = `<button class="primary" id="btnUninstall" ${!installed || busy ? "disabled" : ""}>${busyUninstall ? "Uninstalling…" : "Uninstall"}</button>`;
|
||||||
const restoreBtn = installType === "integration"
|
const restoreBtn = `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`;
|
||||||
? `<button class="primary" id="btnRestore" ${!installed || busy ? "disabled" : ""}>Restore</button>`
|
|
||||||
: ``;
|
|
||||||
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
|
const favoriteBtn = `<button id="btnFavorite">${favorite ? "Unpin" : "Pin"}</button>`;
|
||||||
|
|
||||||
const restartHint = this._restartRequired
|
const restartHint = this._restartRequired
|
||||||
@@ -1628,12 +1663,16 @@ class BahmcloudStorePanel extends HTMLElement {
|
|||||||
})
|
})
|
||||||
.join("");
|
.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
|
const msg = this._restoreLoading
|
||||||
? "Loading backups…"
|
? "Loading backups…"
|
||||||
: this._restoreError
|
: this._restoreError
|
||||||
? this._safeText(this._restoreError)
|
? this._safeText(this._restoreError)
|
||||||
: opts.length
|
: opts.length
|
||||||
? "Select a backup to restore. This will overwrite files under /config/custom_components and requires a restart."
|
? actionHint
|
||||||
: "No backups found.";
|
: "No backups found.";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
|
|||||||
Reference in New Issue
Block a user