10 Commits
0.6.0 ... 0.6.2

Author SHA1 Message Date
f1e03b31a1 add 0.6.2 2026-01-18 13:16:42 +00:00
4e12d596d6 0.6.2 2026-01-18 13:12:47 +00:00
fa97f89afb 0.6.2 2026-01-18 13:12:21 +00:00
0718bee185 0.6.2 2026-01-18 13:11:39 +00:00
1a53107450 0.6.2 2026-01-18 13:10:59 +00:00
ab82cc6fd3 0.6.2 2026-01-18 13:10:16 +00:00
8e51f144e1 0.6.2 2026-01-18 13:09:37 +00:00
f292e22301 Fix restore version 2026-01-18 09:08:25 +00:00
2eb194c001 Add 0.6 1 2026-01-18 09:07:40 +00:00
f4e367987a 0.6.1 2026-01-18 09:07:03 +00:00
7 changed files with 453 additions and 18 deletions

View File

@@ -11,6 +11,19 @@ Sections:
--- ---
## [0.6.2] - 2026-01-18
### Added
- Selectable install/update version per repository (install older releases/tags to downgrade when needed).
- New API endpoint to list available versions for a repository: `GET /api/bcs/versions?repo_id=...`.
## [0.6.1] - 2026-01-18
### Fixed
- Restored integrations now correctly report the restored version instead of the latest installed version.
- Update availability is correctly recalculated after restoring a backup, allowing updates to be applied again.
- Improved restore compatibility with backups created before version metadata was introduced.
## [0.6.0] - 2026-01-18 ## [0.6.0] - 2026-01-18
### Added ### Added

View File

@@ -35,6 +35,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
StaticAssetsView, StaticAssetsView,
BCSApiView, BCSApiView,
BCSReadmeView, BCSReadmeView,
BCSVersionsView,
BCSCustomRepoView, BCSCustomRepoView,
BCSInstallView, BCSInstallView,
BCSUpdateView, BCSUpdateView,
@@ -47,6 +48,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
hass.http.register_view(StaticAssetsView()) hass.http.register_view(StaticAssetsView())
hass.http.register_view(BCSApiView(core)) hass.http.register_view(BCSApiView(core))
hass.http.register_view(BCSReadmeView(core)) hass.http.register_view(BCSReadmeView(core))
hass.http.register_view(BCSVersionsView(core))
hass.http.register_view(BCSCustomRepoView(core)) hass.http.register_view(BCSCustomRepoView(core))
hass.http.register_view(BCSInstallView(core)) hass.http.register_view(BCSInstallView(core))
hass.http.register_view(BCSUpdateView(core)) hass.http.register_view(BCSUpdateView(core))
@@ -60,7 +62,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:
frontend_url_path="bahmcloud-store", frontend_url_path="bahmcloud-store",
webcomponent_name="bahmcloud-store-panel", webcomponent_name="bahmcloud-store-panel",
# IMPORTANT: bump v to avoid caching old JS # IMPORTANT: bump v to avoid caching old JS
module_url="/api/bahmcloud_store_static/panel.js?v=103", module_url="/api/bahmcloud_store_static/panel.js?v=105",
sidebar_title="Bahmcloud Store", sidebar_title="Bahmcloud Store",
sidebar_icon="mdi:store", sidebar_icon="mdi:store",
require_admin=True, require_admin=True,

View File

@@ -20,7 +20,7 @@ from homeassistant.helpers import issue_registry as ir
from homeassistant.util import yaml as ha_yaml from homeassistant.util import yaml as ha_yaml
from .storage import BCSStorage, CustomRepo from .storage import BCSStorage, CustomRepo
from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown from .providers import fetch_repo_info, detect_provider, RepoInfo, fetch_readme_markdown, fetch_repo_versions
from .metadata import fetch_repo_metadata, RepoMetadata from .metadata import fetch_repo_metadata, RepoMetadata
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -30,6 +30,8 @@ DOMAIN = "bahmcloud_store"
SIGNAL_UPDATED = f"{DOMAIN}_updated" SIGNAL_UPDATED = f"{DOMAIN}_updated"
RESTART_REQUIRED_ISSUE_ID = "restart_required" RESTART_REQUIRED_ISSUE_ID = "restart_required"
BACKUP_META_FILENAME = ".bcs_backup_meta.json"
class BCSError(Exception): class BCSError(Exception):
"""BCS core error.""" """BCS core error."""
@@ -413,6 +415,23 @@ class BCSCore:
default_branch=repo.default_branch, default_branch=repo.default_branch,
) )
async def list_repo_versions(self, repo_id: str, *, limit: int = 20) -> list[dict[str, str]]:
"""List installable versions/refs for a repo.
This is used by the UI to allow selecting an older tag/release.
"""
repo = self.get_repo(repo_id)
if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}")
return await fetch_repo_versions(
self.hass,
repo.url,
provider=repo.provider,
default_branch=repo.default_branch,
limit=limit,
)
def _pick_ref_for_install(self, repo: RepoItem) -> str: def _pick_ref_for_install(self, repo: RepoItem) -> str:
if repo.latest_version and str(repo.latest_version).strip(): if repo.latest_version and str(repo.latest_version).strip():
return str(repo.latest_version).strip() return str(repo.latest_version).strip()
@@ -493,7 +512,19 @@ class BCSCore:
await self.hass.async_add_executor_job(_mkdir) await self.hass.async_add_executor_job(_mkdir)
async def _backup_domain(self, domain: str) -> Path | None: 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 {}
return {
"repo_id": repo_id,
"domain": domain,
"installed_version": inst.get("installed_version") or inst.get("ref"),
"installed_manifest_version": inst.get("installed_manifest_version"),
"ref": inst.get("ref") or inst.get("installed_version"),
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
}
async def _backup_domain(self, domain: str, *, meta: dict[str, object] | None = None) -> Path | None:
"""Backup an existing domain folder. """Backup an existing domain folder.
Returns the created backup path, or None if the domain folder does not exist. Returns the created backup path, or None if the domain folder does not exist.
@@ -515,6 +546,13 @@ class BCSCore:
if backup_path.exists(): if backup_path.exists():
shutil.rmtree(backup_path, ignore_errors=True) shutil.rmtree(backup_path, ignore_errors=True)
shutil.copytree(target, backup_path, dirs_exist_ok=True) shutil.copytree(target, backup_path, dirs_exist_ok=True)
# Store backup metadata (kept inside backup folder; removed from target after restore).
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
# Retention: keep only the newest N backups per domain. # Retention: keep only the newest N backups per domain.
try: try:
@@ -541,6 +579,28 @@ class BCSCore:
if target.exists(): if target.exists():
shutil.rmtree(target, ignore_errors=True) shutil.rmtree(target, ignore_errors=True)
shutil.copytree(backup_path, target, dirs_exist_ok=True) shutil.copytree(backup_path, target, dirs_exist_ok=True)
try:
meta_file = target / BACKUP_META_FILENAME
if meta_file.exists():
meta_file.unlink(missing_ok=True)
except Exception:
pass
# Do not leave backup metadata inside the restored integration folder.
try:
meta_p = target / BACKUP_META_FILENAME
if meta_p.exists():
meta_p.unlink()
except Exception:
pass
# Do not leave backup metadata inside the live integration folder.
try:
m = target / BACKUP_META_FILENAME
if m.exists():
m.unlink()
except Exception:
pass
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)
@@ -579,7 +639,13 @@ class BCSCore:
for bid, present in id_map.items(): for bid, present in id_map.items():
complete = present == all_domains complete = present == all_domains
label = self._format_backup_id(bid) label = self._format_backup_id(bid)
items.append({"id": bid, "label": label, "complete": complete, "domains": sorted(present)}) meta = await self._read_backup_meta(dom_list[0], 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": complete, "domains": sorted(present), "installed_version": str(ver) if ver else None})
# Sort newest first by id (lexicographic works for timestamp format). # Sort newest first by id (lexicographic works for timestamp format).
items.sort(key=lambda x: str(x.get("id") or ""), reverse=True) items.sort(key=lambda x: str(x.get("id") or ""), reverse=True)
@@ -631,12 +697,87 @@ class BCSCore:
for d in dom_list: for d in dom_list:
await self._restore_domain_from_backup(d, self._backup_root / d / backup_id) await self._restore_domain_from_backup(d, self._backup_root / d / backup_id)
# Update stored installed version to the restored one (so UI shows the restored state).
#
# Backups created before 0.6.1 may not have metadata. For those legacy backups we fall back to:
# 1) version from the backup's manifest.json (best-effort), else
# 2) a synthetic marker (restored:<backup_id>) so the UI reflects a restored state and updates
# remain available.
restored_meta = await self._read_backup_meta(dom_list[0], backup_id)
restored_version: str | None = None
restored_manifest_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()
mv = restored_meta.get("installed_manifest_version")
if mv is not None and str(mv).strip():
restored_manifest_version = str(mv).strip()
# Legacy backups (no meta): try to read manifest.json version from the backup folder.
if not restored_manifest_version:
restored_manifest_version = await self._read_backup_manifest_version(dom_list[0], backup_id)
# Use manifest version as a fallback display value if we don't have the exact installed ref.
if not restored_version and restored_manifest_version:
restored_version = restored_manifest_version
# Last resort: ensure the installed version changes so the UI does not keep showing the newest version.
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=dom_list,
installed_version=restored_version,
installed_manifest_version=restored_manifest_version,
ref=restored_version,
)
await self._refresh_installed_cache() await self._refresh_installed_cache()
self._mark_restart_required() self._mark_restart_required()
self.signal_updated() self.signal_updated()
_LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s", repo_id, backup_id) _LOGGER.info("BCS restore complete: repo_id=%s backup_id=%s", repo_id, backup_id)
return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": dom_list, "restart_required": True} return {"ok": True, "repo_id": repo_id, "backup_id": backup_id, "domains": dom_list, "restored_version": restored_version, "restart_required": True}
async def _read_backup_meta(self, domain: str, backup_id: str) -> dict[str, Any] | None:
"""Read backup metadata for a domain backup.
Metadata is stored inside the backup folder and will be removed from the
live folder after restore.
"""
try:
p = self._backup_root / domain / 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)."""
def _read() -> str | None:
try:
p = self._backup_root / domain / backup_id / 'manifest.json'
if not p.exists():
return None
data = json.loads(p.read_text(encoding='utf-8'))
v = data.get('version')
return str(v) if v else None
except Exception:
return None
return await self.hass.async_add_executor_job(_read)
async def _list_domain_backup_ids(self, domain: str) -> list[str]: async def _list_domain_backup_ids(self, domain: str) -> list[str]:
"""List backup ids for a domain (newest->oldest).""" """List backup ids for a domain (newest->oldest)."""
@@ -789,19 +930,28 @@ class BCSCore:
self.signal_updated() 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)}
async def install_repo(self, repo_id: str) -> dict[str, Any]: async def install_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
repo = self.get_repo(repo_id) repo = self.get_repo(repo_id)
if not repo: if not repo:
raise BCSInstallError(f"repo_id not found: {repo_id}") raise BCSInstallError(f"repo_id not found: {repo_id}")
async with self._install_lock: async with self._install_lock:
ref = self._pick_ref_for_install(repo) requested = (version or "").strip()
ref = requested if requested else self._pick_ref_for_install(repo)
zip_url = self._build_zip_url(repo.url, ref) zip_url = self._build_zip_url(repo.url, ref)
_LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url) _LOGGER.info("BCS install started: repo_id=%s ref=%s zip_url=%s", repo_id, ref, zip_url)
installed_domains: list[str] = [] installed_domains: list[str] = []
backups: dict[str, Path] = {} backups: dict[str, Path] = {}
inst_before = self.get_installed(repo_id) or {}
backup_meta = {
"repo_id": repo_id,
"installed_version": inst_before.get("installed_version") or inst_before.get("ref"),
"installed_manifest_version": inst_before.get("installed_manifest_version"),
"ref": inst_before.get("ref") or inst_before.get("installed_version"),
}
created_new: set[str] = set() created_new: set[str] = set()
try: try:
@@ -832,7 +982,9 @@ class BCSCore:
# Backup only if we are going to overwrite an existing domain. # Backup only if we are going to overwrite an existing domain.
if target.exists() and target.is_dir(): if target.exists() and target.is_dir():
bkp = await self._backup_domain(domain) m = dict(backup_meta)
m["domain"] = domain
bkp = await self._backup_domain(domain, meta=m)
if bkp: if bkp:
backups[domain] = bkp backups[domain] = bkp
else: else:
@@ -905,9 +1057,9 @@ class BCSCore:
raise raise
raise BCSInstallError(str(e)) from e raise BCSInstallError(str(e)) from e
async def update_repo(self, repo_id: str) -> dict[str, Any]: async def update_repo(self, repo_id: str, *, version: str | None = None) -> dict[str, Any]:
_LOGGER.info("BCS update started: repo_id=%s", repo_id) _LOGGER.info("BCS update started: repo_id=%s", repo_id)
return await self.install_repo(repo_id) return await self.install_repo(repo_id, version=version)
async def request_restart(self) -> None: async def request_restart(self) -> None:
await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False) await self.hass.services.async_call("homeassistant", "restart", {}, blocking=False)

View File

@@ -1,7 +1,7 @@
{ {
"domain": "bahmcloud_store", "domain": "bahmcloud_store",
"name": "Bahmcloud Store", "name": "Bahmcloud Store",
"version": "0.6.0", "version": "0.6.2",
"documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store", "documentation": "https://git.bahmcloud.de/bahmcloud/bahmcloud_store",
"platforms": ["update"], "platforms": ["update"],
"requirements": [], "requirements": [],

View File

@@ -45,6 +45,11 @@ class BahmcloudStorePanel extends HTMLElement {
this._restoreSelected = ""; this._restoreSelected = "";
this._restoring = false; this._restoring = false;
this._restoreError = null; this._restoreError = null;
// Phase C1: selectable install version
this._versionsCache = {}; // repo_id -> [{ref,label,source}, ...]
this._versionsLoadingRepoId = null;
this._selectedVersionByRepoId = {}; // repo_id -> ref ("" means latest)
} }
set hass(hass) { set hass(hass) {
@@ -114,7 +119,13 @@ class BahmcloudStorePanel extends HTMLElement {
this._update(); this._update();
try { try {
const resp = await this._hass.callApi("post", `bcs/install?repo_id=${encodeURIComponent(repoId)}`, {}); const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&version=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"post",
`bcs/install?repo_id=${encodeURIComponent(repoId)}${qv}`,
{},
);
if (!resp?.ok) { if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Install failed."; this._error = this._safeText(resp?.message) || "Install failed.";
} else { } else {
@@ -140,7 +151,13 @@ class BahmcloudStorePanel extends HTMLElement {
this._update(); this._update();
try { try {
const resp = await this._hass.callApi("post", `bcs/update?repo_id=${encodeURIComponent(repoId)}`, {}); const sel = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
const qv = sel ? `&version=${encodeURIComponent(sel)}` : "";
const resp = await this._hass.callApi(
"post",
`bcs/update?repo_id=${encodeURIComponent(repoId)}${qv}`,
{},
);
if (!resp?.ok) { if (!resp?.ok) {
this._error = this._safeText(resp?.message) || "Update failed."; this._error = this._safeText(resp?.message) || "Update failed.";
} else { } else {
@@ -366,8 +383,41 @@ class BahmcloudStorePanel extends HTMLElement {
this._readmeExpanded = false; this._readmeExpanded = false;
this._readmeCanToggle = false; this._readmeCanToggle = false;
// Versions dropdown
if (!(repoId in this._selectedVersionByRepoId)) {
this._selectedVersionByRepoId[repoId] = ""; // default = latest
}
this._update(); this._update();
this._loadReadme(repoId); this._loadReadme(repoId);
this._loadVersions(repoId);
}
async _loadVersions(repoId) {
if (!this._hass) return;
if (!repoId) return;
// Cache: avoid re-fetching repeatedly in the same session.
if (Array.isArray(this._versionsCache?.[repoId]) && this._versionsCache[repoId].length) {
return;
}
this._versionsLoadingRepoId = repoId;
this._update();
try {
const resp = await this._hass.callApi("get", `bcs/versions?repo_id=${encodeURIComponent(repoId)}`);
if (resp?.ok && Array.isArray(resp.versions)) {
this._versionsCache[repoId] = resp.versions;
} else {
this._versionsCache[repoId] = [];
}
} catch (e) {
this._versionsCache[repoId] = [];
} finally {
this._versionsLoadingRepoId = null;
this._update();
}
} }
async _loadReadme(repoId) { async _loadReadme(repoId) {
@@ -953,6 +1003,8 @@ class BahmcloudStorePanel extends HTMLElement {
const r = this._detailRepo; const r = this._detailRepo;
if (!r) return `<div class="card">No repository selected.</div>`; if (!r) return `<div class="card">No repository selected.</div>`;
const repoId = this._safeId(r?.id) || this._detailRepoId || "";
const name = this._safeText(r?.name) || "Unnamed repository"; const name = this._safeText(r?.name) || "Unnamed repository";
const url = this._safeText(r?.url) || ""; const url = this._safeText(r?.url) || "";
const desc = this._safeText(r?.description) || ""; const desc = this._safeText(r?.description) || "";
@@ -1001,8 +1053,6 @@ class BahmcloudStorePanel extends HTMLElement {
</div> </div>
`; `;
const repoId = this._safeId(r?.id);
const installed = this._asBoolStrict(r?.installed); const installed = this._asBoolStrict(r?.installed);
const installedVersion = this._safeText(r?.installed_version); const installedVersion = this._safeText(r?.installed_version);
const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : []; const installedDomains = Array.isArray(r?.installed_domains) ? r.installed_domains : [];
@@ -1015,6 +1065,32 @@ class BahmcloudStorePanel extends HTMLElement {
const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion); const updateAvailable = installed && !!latestVersion && (!installedVersion || latestVersion !== installedVersion);
const versions = Array.isArray(this._versionsCache?.[repoId]) ? this._versionsCache[repoId] : [];
const versionsLoading = this._versionsLoadingRepoId === repoId;
const selectedRef = this._safeText(this._selectedVersionByRepoId?.[repoId] || "").trim();
let versionOptions = `<option value="">Latest (recommended)</option>`;
if (selectedRef && !versions.some((v) => this._safeText(v?.ref) === selectedRef)) {
versionOptions += `<option value="${this._esc(selectedRef)}" selected>Selected: ${this._esc(selectedRef)}</option>`;
}
for (const v of versions) {
const ref = this._safeText(v?.ref);
if (!ref) continue;
const label = this._safeText(v?.label) || ref;
const sel = selectedRef === ref ? "selected" : "";
versionOptions += `<option value="${this._esc(ref)}" ${sel}>${this._esc(label)}</option>`;
}
const versionSelect = `
<div style="margin-top:12px;">
<div class="muted small" style="margin-bottom:6px;"><strong>Install version:</strong></div>
<select id="selVersion" ${busy ? "disabled" : ""} style="width:100%;">
${versionOptions}
</select>
${versionsLoading ? `<div class="muted small" style="margin-top:6px;">Loading versions…</div>` : ``}
</div>
`;
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>`;
@@ -1067,6 +1143,8 @@ class BahmcloudStorePanel extends HTMLElement {
<div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div> <div style="margin-top:6px;"><strong>Domains:</strong> ${installedDomains.length ? this._esc(installedDomains.join(", ")) : "-"}</div>
</div> </div>
${versionSelect}
<div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;"> <div class="row" style="margin-top:14px; gap:10px; flex-wrap:wrap;">
${installBtn} ${installBtn}
${updateBtn} ${updateBtn}
@@ -1091,6 +1169,7 @@ class BahmcloudStorePanel extends HTMLElement {
const btnRestore = root.getElementById("btnRestore"); const btnRestore = root.getElementById("btnRestore");
const btnRestart = root.getElementById("btnRestart"); const btnRestart = root.getElementById("btnRestart");
const btnReadmeToggle = root.getElementById("btnReadmeToggle"); const btnReadmeToggle = root.getElementById("btnReadmeToggle");
const selVersion = root.getElementById("selVersion");
if (btnInstall) { if (btnInstall) {
btnInstall.addEventListener("click", () => { btnInstall.addEventListener("click", () => {
@@ -1099,6 +1178,14 @@ class BahmcloudStorePanel extends HTMLElement {
}); });
} }
if (selVersion) {
selVersion.addEventListener("change", () => {
if (!this._detailRepoId) return;
const v = selVersion.value != null ? String(selVersion.value) : "";
this._selectedVersionByRepoId[this._detailRepoId] = v;
});
}
if (btnUpdate) { if (btnUpdate) {
btnUpdate.addEventListener("click", () => { btnUpdate.addEventListener("click", () => {
if (btnUpdate.disabled) return; if (btnUpdate.disabled) return;

View File

@@ -505,3 +505,159 @@ async def fetch_readme_markdown(
continue continue
return None return None
async def fetch_repo_versions(
hass: HomeAssistant,
repo_url: str,
*,
provider: str | None = None,
default_branch: str | None = None,
limit: int = 20,
) -> list[dict[str, str]]:
"""List available versions/refs for a repository.
Returns a list of dicts with keys:
- ref: the ref to install (tag/release/branch)
- label: human-friendly label
- source: release|tag|branch
Notes:
- Uses public endpoints (no tokens) for public repositories.
- We prefer releases first (if available), then tags.
"""
repo_url = (repo_url or "").strip()
if not repo_url:
return []
prov = (provider or "").strip().lower() if provider else ""
if not prov:
prov = detect_provider(repo_url)
owner, repo = _split_owner_repo(repo_url)
if not owner or not repo:
return []
session = async_get_clientsession(hass)
headers = {"User-Agent": UA}
out: list[dict[str, str]] = []
seen: set[str] = set()
def _add(ref: str | None, label: str, source: str) -> None:
r = (ref or "").strip()
if not r or r in seen:
return
seen.add(r)
out.append({"ref": r, "label": label, "source": source})
# Always offer default branch as an explicit option.
if default_branch and str(default_branch).strip():
b = str(default_branch).strip()
_add(b, f"Branch: {b}", "branch")
try:
if prov == "github":
# Releases
gh_headers = {"Accept": "application/vnd.github+json", "User-Agent": UA}
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/releases?per_page={int(limit)}",
headers=gh_headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
# Tags
data, _ = await _safe_json(
session,
f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={int(limit)}",
headers=gh_headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
if prov == "gitlab":
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
project = quote_plus(f"{owner}/{repo}")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/releases?per_page={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
data, _ = await _safe_json(
session,
f"{base}/api/v4/projects/{project}/repository/tags?per_page={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
# gitea (incl. Bahmcloud)
u = urlparse(repo_url.rstrip("/"))
base = f"{u.scheme}://{u.netloc}"
data, _ = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/releases?limit={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for r in data:
if not isinstance(r, dict):
continue
tag = r.get("tag_name")
name = r.get("name")
if tag:
lbl = str(tag)
if isinstance(name, str) and name.strip() and name.strip() != str(tag):
lbl = f"{tag}{name.strip()}"
_add(str(tag), lbl, "release")
data, _ = await _safe_json(
session,
f"{base}/api/v1/repos/{owner}/{repo}/tags?limit={int(limit)}",
headers=headers,
)
if isinstance(data, list):
for t in data:
if isinstance(t, dict) and t.get("name"):
_add(str(t["name"]), str(t["name"]), "tag")
return out
except Exception:
_LOGGER.debug("fetch_repo_versions failed for %s", repo_url, exc_info=True)
return out

View File

@@ -292,6 +292,27 @@ class BCSReadmeView(HomeAssistantView):
return web.json_response({"ok": True, "readme": md_str, "html": html}) return web.json_response({"ok": True, "readme": md_str, "html": html})
class BCSVersionsView(HomeAssistantView):
url = "/api/bcs/versions"
name = "api:bcs_versions"
requires_auth = True
def __init__(self, core: Any) -> None:
self.core: BCSCore = core
async def get(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id")
if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try:
versions = await self.core.list_repo_versions(repo_id)
return web.json_response({"ok": True, "repo_id": repo_id, "versions": versions}, status=200)
except Exception as e:
_LOGGER.exception("BCS list versions failed: %s", e)
return web.json_response({"ok": False, "message": str(e) or "List versions failed"}, status=500)
class BCSInstallView(HomeAssistantView): class BCSInstallView(HomeAssistantView):
url = "/api/bcs/install" url = "/api/bcs/install"
name = "api:bcs_install" name = "api:bcs_install"
@@ -302,11 +323,13 @@ class BCSInstallView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id") repo_id = request.query.get("repo_id")
version = request.query.get("version")
if not repo_id: if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400) return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try: try:
result = await self.core.install_repo(repo_id) v = str(version).strip() if version is not None else None
result = await self.core.install_repo(repo_id, version=v)
return web.json_response(result, status=200) return web.json_response(result, status=200)
except Exception as e: except Exception as e:
_LOGGER.exception("BCS install failed: %s", e) _LOGGER.exception("BCS install failed: %s", e)
@@ -323,11 +346,13 @@ class BCSUpdateView(HomeAssistantView):
async def post(self, request: web.Request) -> web.Response: async def post(self, request: web.Request) -> web.Response:
repo_id = request.query.get("repo_id") repo_id = request.query.get("repo_id")
version = request.query.get("version")
if not repo_id: if not repo_id:
return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400) return web.json_response({"ok": False, "message": "Missing repo_id"}, status=400)
try: try:
result = await self.core.update_repo(repo_id) v = str(version).strip() if version is not None else None
result = await self.core.update_repo(repo_id, version=v)
return web.json_response(result, status=200) return web.json_response(result, status=200)
except Exception as e: except Exception as e:
_LOGGER.exception("BCS update failed: %s", e) _LOGGER.exception("BCS update failed: %s", e)