diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c610fc8..fe1d332 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,7 @@ jobs:
run: |
node tests/js/test_ai_pipeline_preview.mjs
node tests/js/test_draft_guard.mjs
+ node tests/js/test_update_check.mjs
security-gate:
name: Security Gate
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 395a03d..87f0a0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,14 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
+## [0.4.1] - 2026-06-18
+
### Added
+- **Sidebar update notice.** The Kforge Labs sidebar now checks the installed
+ pack version against GitHub's latest release and shows a discreet
+ footer-slot "Update Available" notice only when a newer release exists. The
+ notice links to the release notes and stays hidden when the check fails or
+ the install is already current, so offline ComfyUI sessions remain quiet.
- **Easy Image Batch — review hardening.** `keyframe_batch` is kept as a
**deprecated alias** of `keyframes_insert` (pre-rename workflows keep loading;
an explicit `keyframes_insert` wins if both are wired; removal deferred to a
diff --git a/docs/designs/README.md b/docs/designs/README.md
index 2f28725..fd8a045 100644
--- a/docs/designs/README.md
+++ b/docs/designs/README.md
@@ -7,6 +7,7 @@ before they land in the runtime sidebar.
|---|---|
| [`sidebar-icon-proposals.html`](sidebar-icon-proposals.html) | Side-by-side icon direction mockup for the Kforge Labs sidebar action rows |
| [`sidebar-icon-proposals.png`](sidebar-icon-proposals.png) | Rendered preview of the current icon proposal state |
+| [`update-banner.html`](update-banner.html) | Footer banner layout proposal for a discreet "Update Available" release-notes link |
| [`snapshot-dialogs-redesign.md`](snapshot-dialogs-redesign.md) | Scope sketch + Mermaid flow for consolidating Settings cog into Save / Load dialogs (+ autosave-newest fix) |
| [`snapshot-dialogs.html`](snapshot-dialogs.html) | Side-by-side mockups: current vs. proposed Snapshot toolbar, Save dialog, Load dialog, and autosave YES/NO modal |
diff --git a/docs/designs/update-banner.html b/docs/designs/update-banner.html
new file mode 100644
index 0000000..3609399
--- /dev/null
+++ b/docs/designs/update-banner.html
@@ -0,0 +1,276 @@
+
+
+
+
+
+ Update Available Banner - Sidebar Mockup
+
+
+
+
+ Sidebar Update Available Banner
+ Chosen quiet footer treatment for a future release-check feature. It points users to release notes without interrupting ComfyUI work or stealing space from the main sidebar lists. The notice uses the normal user-facing empty footer slot; the dev build line is omitted because normal users will not see it.
+
+
+ Footer Slot Text
+ Discreet release notice at the bottom of the sidebar, using the footer slot instead of adding another row.
+
+ Lowest visual weight. This is the closest match if we want the notice to live where the hidden dev footer would otherwise be.
+
+
+
+
diff --git a/koolook_routes.py b/koolook_routes.py
index c1b1b20..dd5f54f 100644
--- a/koolook_routes.py
+++ b/koolook_routes.py
@@ -90,6 +90,26 @@ _DIRNAME_RE = re.compile(r"^[A-Za-z0-9 _.()\-]+$")
# filter; can be removed in a future major if we choose to migrate.
_HIDDEN_LIST_PREFIXES = ("_autosave_",)
_WEB_DIR = Path(__file__).resolve().parent / "web"
+_REPO_ROOT = Path(__file__).resolve().parent
+_GITHUB_RELEASES_URL = "https://github.com/malkuthro/ComfyUI-Koolook/releases"
+_GITHUB_LATEST_RELEASE_API_URL = (
+ "https://api.github.com/repos/malkuthro/ComfyUI-Koolook/releases/latest"
+)
+
+
+def _read_pack_version() -> str:
+ """Read the installed Koolook pack version from ``pyproject.toml``.
+
+ Kept local and dependency-free so the ComfyUI runtime does not need package
+ metadata installed. Returns ``"unknown"`` if the file is unavailable or
+ malformed; the frontend treats that as "do not show update notice".
+ """
+ try:
+ text = (_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ except OSError:
+ return "unknown"
+ match = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)["\']', text)
+ return match.group(1).strip() if match else "unknown"
def _latest_autosave_mtime(autosave_dir: Path, named_mtime: float) -> float | None:
@@ -564,6 +584,16 @@ def register_routes(routes, setup_registry_factory=None, setup_runner_factory=No
async def setup_runner_simulator_js(_request):
return _web_asset_response("setup_runner_simulator.js", "application/javascript")
+ @routes.get("/koolook/api/version")
+ async def get_koolook_version(_request):
+ return web.json_response(
+ {
+ "version": _read_pack_version(),
+ "releasesUrl": _GITHUB_RELEASES_URL,
+ "latestReleaseApiUrl": _GITHUB_LATEST_RELEASE_API_URL,
+ }
+ )
+
@routes.get("/koolook/api/setups")
async def list_published_setups(_request):
registry: PublishedSetupRegistry = setup_registry_factory()
diff --git a/pyproject.toml b/pyproject.toml
index 94787cc..b6d1366 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
[project]
name = "koolook"
description = "This repository provides custom nodes for ComfyUI tailored for VFX and AI image/video generation workflows"
-version = "0.3.7"
+version = "0.4.1"
license = {file = "LICENSE"}
# classifiers = [
# # For OS-independent nodes (works on all operating systems)
diff --git a/tests/js/test_update_check.mjs b/tests/js/test_update_check.mjs
new file mode 100644
index 0000000..1f9f50e
--- /dev/null
+++ b/tests/js/test_update_check.mjs
@@ -0,0 +1,89 @@
+// Behavior tests for the sidebar update-available check.
+
+import assert from "node:assert/strict";
+import path from "node:path";
+
+const repoRoot = path.resolve(import.meta.dirname, "../..");
+const updateCheck = await import(
+ `file:///${path.join(repoRoot, "web", "sidebar", "update_check.js").replaceAll("\\", "/")}`
+);
+
+function jsonResponse(body, ok = true) {
+ return {
+ ok,
+ async json() {
+ return body;
+ },
+ };
+}
+
+assert.equal(updateCheck.compareSemver("0.4.1", "0.4.0"), 1);
+assert.equal(updateCheck.compareSemver("v0.4.0", "0.4.0"), 0);
+assert.equal(updateCheck.compareSemver("0.3.9", "0.4.0"), -1);
+assert.equal(updateCheck.compareSemver("0.4.0", "0.4.0-rc.1"), 1);
+assert.equal(updateCheck.compareSemver("not-a-version", "0.4.0"), 0);
+
+{
+ const calls = [];
+ const update = await updateCheck.checkForUpdate(async (url) => {
+ calls.push(String(url));
+ if (calls.length === 1) {
+ assert.ok(calls[0].startsWith("/koolook/api/version?_="));
+ return jsonResponse({
+ version: "0.4.0",
+ latestReleaseApiUrl: "https://api.example.test/latest",
+ });
+ }
+ assert.equal(url, "https://api.example.test/latest");
+ return jsonResponse({
+ tag_name: "v0.4.1",
+ html_url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1",
+ });
+ });
+ assert.deepEqual(update, {
+ current: "0.4.0",
+ latest: "0.4.1",
+ url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1",
+ });
+}
+
+{
+ let calls = 0;
+ const update = await updateCheck.checkForUpdate(async () => {
+ calls += 1;
+ if (calls === 1) return jsonResponse({ version: "0.4.0" });
+ return jsonResponse({
+ tag_name: "v0.4.1",
+ html_url: "https://github.example.test/releases/v0.4.1",
+ });
+ });
+ assert.deepEqual(update, {
+ current: "0.4.0",
+ latest: "0.4.1",
+ url: "https://github.com/malkuthro/ComfyUI-Koolook/releases/tag/v0.4.1",
+ });
+}
+
+{
+ const update = await updateCheck.checkForUpdate(async () => jsonResponse({ version: "0.4.0" }));
+ assert.equal(update, null);
+}
+
+{
+ let calls = 0;
+ const update = await updateCheck.checkForUpdate(async () => {
+ calls += 1;
+ if (calls === 1) return jsonResponse({ version: "0.4.0" });
+ return jsonResponse({ tag_name: "v0.4.0", html_url: "https://example.test/current" });
+ });
+ assert.equal(update, null);
+}
+
+{
+ const update = await updateCheck.checkForUpdate(async () => {
+ throw new Error("offline");
+ });
+ assert.equal(update, null);
+}
+
+console.log("test_update_check.mjs: all scenarios passed");
diff --git a/tests/server/test_published_setup_api.py b/tests/server/test_published_setup_api.py
index 6ea7f7c..315356e 100644
--- a/tests/server/test_published_setup_api.py
+++ b/tests/server/test_published_setup_api.py
@@ -53,6 +53,21 @@ def test_setup_runner_simulator_routes_serve_html_and_js() -> None:
asyncio.run(exercise())
+def test_version_route_exposes_installed_version_and_release_urls() -> None:
+ async def exercise() -> None:
+ app = _app_with_registry(PublishedSetupRegistry(StaticSetupStorage([])))
+
+ response = await _handle(app, "GET", "/koolook/api/version")
+
+ assert response.status == 200
+ body = _json_body(response)
+ assert body["version"] == "0.4.1"
+ assert body["releasesUrl"] == "https://github.com/malkuthro/ComfyUI-Koolook/releases"
+ assert body["latestReleaseApiUrl"].endswith("/malkuthro/ComfyUI-Koolook/releases/latest")
+
+ asyncio.run(exercise())
+
+
def test_catalog_list_omits_invalid_setups() -> None:
async def exercise() -> None:
invalid = deepcopy(_valid_setup())
diff --git a/tests/sidebar/test_update_footer.py b/tests/sidebar/test_update_footer.py
new file mode 100644
index 0000000..52fe37a
--- /dev/null
+++ b/tests/sidebar/test_update_footer.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parents[2]
+TREE_JS = REPO_ROOT / "web" / "sidebar" / "tree.js"
+
+
+def test_compare_panels_disable_update_footer() -> None:
+ text = TREE_JS.read_text(encoding="utf-8")
+
+ assert "showUpdateFooter: false" in text
+ assert "if (showUpdateFooter) {" in text
diff --git a/web/sidebar/constants.js b/web/sidebar/constants.js
index 6d10fe7..627d7f7 100644
--- a/web/sidebar/constants.js
+++ b/web/sidebar/constants.js
@@ -320,7 +320,11 @@ export function ensureStyle() {
.koolook-preview-widget-name { text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-widget-value { text-align: right; font-size: 11px; opacity: 0.85; font-family: monospace; text-overflow: ellipsis; white-space: nowrap; }
.koolook-preview-desc { margin: 10px 9px 0; padding: 7px 9px; background: rgba(0,0,0,0.18); border: 1px solid rgba(255,255,255,0.05); border-radius: 6px; font-style: italic; font-weight: 500; font-size: 11px; line-height: 1.45; word-break: break-word; opacity: 0.86; max-height: 120px; overflow: hidden; }
-.koolook-build-tag { flex-shrink: 0; padding: 6px 10px 14px; font-size: 10px; opacity: 0.5; text-align: center; letter-spacing: 0.04em; font-family: monospace; color: var(--input-text, inherit); line-height: 1.45; }
+.koolook-update-footer { flex-shrink: 0; display: none; justify-content: center; gap: 6px; padding: 4px 10px 12px; color: var(--input-text, inherit); opacity: 0.62; font-size: 11px; line-height: 1.45; text-align: center; }
+.koolook-update-footer-visible { display: flex; }
+.koolook-update-footer a { color: #b9d8ff; font-weight: 700; text-decoration: none; opacity: 1; }
+.koolook-update-footer a:hover { color: #ffffff; }
+.koolook-build-tag { flex-shrink: 0; padding: 0 10px 12px; font-size: 10px; opacity: 0.5; text-align: center; letter-spacing: 0.04em; font-family: monospace; color: var(--input-text, inherit); line-height: 1.45; }
.koolook-build-sha { font-size: 13px; letter-spacing: 0.06em; }
.koolook-build-scope { display: block; font-size: 12px; margin-top: 3px; letter-spacing: 0.02em; font-family: var(--font-family, sans-serif); font-style: italic; }
/* Folder-browse picker (issue #137, mockup section 6). Navigate-into
diff --git a/web/sidebar/tree.js b/web/sidebar/tree.js
index fd454ea..62b496b 100644
--- a/web/sidebar/tree.js
+++ b/web/sidebar/tree.js
@@ -74,6 +74,7 @@ import {
clearWorkflowsRenderSource,
} from "./workflows_store.js";
import { diffPicks, diffWorkflows, getWorkflowEntryFromStore } from "./snapshot_diff.js";
+import { checkForUpdate, renderUpdateFooter } from "./update_check.js";
import {
serializeFullCanvas,
serializeSelection,
@@ -2544,6 +2545,7 @@ export function renderSidebar(container) {
signal,
readOnly: !liveIsActive,
pullIn: liveIsActive ? null : pullIn,
+ showUpdateFooter: false,
});
renderPanel(rightCol.host, {
compare: true,
@@ -2553,6 +2555,7 @@ export function renderSidebar(container) {
compareName: snapName,
readOnly: true,
pullIn: liveIsActive ? pullIn : null,
+ showUpdateFooter: false,
});
// Diff/tint/filter follow the SOURCE (the read-only side you copy FROM):
// "new" = in the source, not the target — the copy candidates — so the
@@ -3105,7 +3108,7 @@ function applyCompareTint(panelEl, targetPicks, targetStore, sourcePicks, source
}
export function renderPanel(container, options = {}) {
- const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "", readOnly = compare, pullIn = null } = options;
+ const { compare = false, snapshot = null, onToggleCompare = null, signal = null, compareName = "", readOnly = compare, pullIn = null, showUpdateFooter = true } = options;
// Compare mode (#181): when `compare` is set, every tree (re-)render in
// this panel reads from the loaded `snapshot` via the read-only store
// overrides, and the live-change listeners at the bottom are skipped so
@@ -3828,6 +3831,13 @@ export function renderPanel(container, options = {}) {
withSource(() => renderTree({ treeEl: tree, query: "" }));
+ const updateFooter = document.createElement("div");
+ updateFooter.className = "koolook-update-footer";
+ container.appendChild(updateFooter);
+ if (showUpdateFooter) {
+ checkForUpdate().then((update) => renderUpdateFooter(updateFooter, update));
+ }
+
// ---- Build tag (dev-sync verification) ----
// `scripts/sync_to_dev.py` writes `web/_dev_build.json` post-sync so the
// maintainer can eyeball whether the live ComfyUI is running the latest
diff --git a/web/sidebar/update_check.js b/web/sidebar/update_check.js
new file mode 100644
index 0000000..d54a379
--- /dev/null
+++ b/web/sidebar/update_check.js
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: GPL-3.0-only
+
+import { noStoreUrl } from "./constants.js";
+
+const LOCAL_VERSION_ROUTE = "/koolook/api/version";
+const FALLBACK_LATEST_RELEASE_API =
+ "https://api.github.com/repos/malkuthro/ComfyUI-Koolook/releases/latest";
+const RELEASES_URL = "https://github.com/malkuthro/ComfyUI-Koolook/releases";
+
+function releaseNotesUrl(version, rawUrl) {
+ const expected = `${RELEASES_URL}/tag/v${version}`;
+ if (typeof rawUrl !== "string") return expected;
+ try {
+ const parsed = new URL(rawUrl);
+ if (
+ parsed.protocol === "https:" &&
+ parsed.hostname === "github.com" &&
+ parsed.pathname === `/malkuthro/ComfyUI-Koolook/releases/tag/v${version}`
+ ) {
+ return parsed.href;
+ }
+ } catch (_err) {
+ // Fall through to the canonical release URL.
+ }
+ return expected;
+}
+
+function parseVersion(value) {
+ if (typeof value !== "string") return null;
+ const match = value.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/);
+ if (!match) return null;
+ return {
+ parts: match.slice(1, 4).map((part) => Number(part)),
+ prerelease: match[4] || "",
+ };
+}
+
+export function parseSemver(value) {
+ const parsed = parseVersion(value);
+ return parsed ? parsed.parts : null;
+}
+
+export function compareSemver(a, b) {
+ const av = parseVersion(a);
+ const bv = parseVersion(b);
+ if (!av || !bv) return 0;
+ for (let i = 0; i < 3; i += 1) {
+ if (av.parts[i] > bv.parts[i]) return 1;
+ if (av.parts[i] < bv.parts[i]) return -1;
+ }
+ if (av.prerelease && !bv.prerelease) return -1;
+ if (!av.prerelease && bv.prerelease) return 1;
+ return 0;
+}
+
+function normalizeLatestRelease(payload) {
+ if (!payload || typeof payload !== "object") return null;
+ const version = typeof payload.tag_name === "string"
+ ? payload.tag_name.replace(/^v/i, "")
+ : "";
+ if (!parseSemver(version)) return null;
+ return {
+ version,
+ url: releaseNotesUrl(version, payload.html_url),
+ };
+}
+
+export async function checkForUpdate(fetchImpl = fetch) {
+ try {
+ const localResponse = await fetchImpl(noStoreUrl(LOCAL_VERSION_ROUTE), {
+ cache: "no-store",
+ });
+ if (!localResponse || !localResponse.ok) return null;
+ const local = await localResponse.json();
+ const current = typeof local.version === "string" ? local.version : "";
+ if (!parseSemver(current)) return null;
+
+ const latestUrl = typeof local.latestReleaseApiUrl === "string" && local.latestReleaseApiUrl
+ ? local.latestReleaseApiUrl
+ : FALLBACK_LATEST_RELEASE_API;
+ const latestResponse = await fetchImpl(latestUrl, { cache: "no-store" });
+ if (!latestResponse || !latestResponse.ok) return null;
+ const latest = normalizeLatestRelease(await latestResponse.json());
+ if (!latest || compareSemver(latest.version, current) <= 0) return null;
+
+ return {
+ current,
+ latest: latest.version,
+ url: latest.url,
+ };
+ } catch (_err) {
+ return null;
+ }
+}
+
+export function renderUpdateFooter(footerEl, update) {
+ footerEl.textContent = "";
+ footerEl.classList.remove("koolook-update-footer-visible");
+ if (!update) return;
+
+ const label = document.createElement("span");
+ label.textContent = "Update Available";
+ const link = document.createElement("a");
+ link.href = update.url;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.textContent = "Release notes";
+ link.title = `Kforge Labs ${update.latest} release notes`;
+ footerEl.append(label, link);
+ footerEl.classList.add("koolook-update-footer-visible");
+}