6 Commits
Author SHA1 Message Date
Edouard Durand e123dc9279 RigShare 1.2.3 2026-09-27 00:43:44 +00:00
Edouard Durand a75443d8bb Files tab refreshes when workflows change
The server tells everyone who can open the folder when a workflow is saved,
moved or deleted, or folders are created, renamed, moved, deleted or have
their access changed; the Files tab reloads the folder on screen.
2026-09-27 00:42:03 +00:00
Edouard Durand 6e2324a7c3 Back to the login screen when the session has ended
The ComfyUI page is served with no-store and carries a tiny script, run
before ComfyUI's own code, that goes to the login page when a request is
answered 401 with X-RigShare-Login: required (now sent by the login gate).
2026-09-27 00:39:09 +00:00
Edouard Durand 137051a135 Fix publish workflow YAML (a literal line break in printf) and line endings 2026-09-26 08:36:55 +00:00
Edouard Durand 78415ebf25 Registry changelogs as plain text
The registry shows a version's changelog as one paragraph, without Markdown
or line breaks: a shared script turns the CHANGELOG.md section into
'Added: ...; ... Fixed: ...' for both the publish and the update workflows.
2026-09-26 08:34:19 +00:00
Edouard Durand c94b90133a Workflow to fill in registry changelogs of published versions 2026-09-26 08:30:17 +00:00
9 changed files with 265 additions and 14 deletions
+74
View File
@@ -0,0 +1,74 @@
"""Print a version's CHANGELOG.md section as plain text for the Comfy Registry.
The registry shows a version's changelog as one paragraph: line breaks and
Markdown are not rendered. So headings become "Added:", "Fixed:"..., list
items are joined with "; ", and Markdown markup is dropped.
python .github/scripts/registry_changelog.py 1.2.2
python .github/scripts/registry_changelog.py --all # JSON {version: text}
"""
import json
import re
import sys
def sections(path="CHANGELOG.md"):
out, current = {}, None
for line in open(path, encoding="utf-8").read().splitlines():
m = re.match(r"^## (\d+\.\d+\.\d+)\b", line)
if m:
current = m.group(1)
out[current] = []
elif line.startswith("## "):
current = None
elif current:
out[current].append(line)
return out
def plain(text):
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) # links: keep the text
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
text = re.sub(r"(?<!\w)\*([^*]+)\*(?!\w)", r"\1", text)
return text.replace("`", "").strip()
def registry_text(lines):
parts, heading, items, intro = [], None, [], []
def flush():
if items:
body = "; ".join(i.rstrip(".") for i in items) + "."
parts.append(f"{heading}: {body}" if heading else body)
for raw in lines:
line = raw.strip()
if not line:
continue
if line.startswith("### "):
flush()
heading, items = plain(line[4:]), []
elif line.startswith("- "):
items.append(plain(line[2:]))
elif items:
items[-1] += " " + plain(line) # a wrapped list item
else:
intro.append(plain(line))
flush()
return " ".join(intro + parts).strip()
def main():
sys.stdout.reconfigure(encoding="utf-8")
all_sections = sections()
if sys.argv[1:] == ["--all"]:
print(json.dumps({v: registry_text(lines) for v, lines in all_sections.items()}, ensure_ascii=False))
elif len(sys.argv) == 2:
print(registry_text(all_sections.get(sys.argv[1], [])))
else:
sys.exit(__doc__)
if __name__ == "__main__":
main()
+5 -10
View File
@@ -15,15 +15,11 @@ jobs:
- name: Check out code
uses: actions/checkout@v7
- name: Changelog for this version
# comfy-cli sends COMFY_NODE_CHANGELOG as the version's changelog
# (the registry's Updates section): the matching CHANGELOG.md section.
# comfy-cli sends COMFY_NODE_CHANGELOG as the version's changelog (the
# registry's Updates section), as plain text: the registry shows one paragraph.
run: |
version=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1)
notes=$(awk -v v="$version" '
$0 ~ "^## " v "( |$)" { on = 1; next }
on && /^## / { exit }
on { print }
' CHANGELOG.md | sed -e '/./,$!d')
notes=$(python3 .github/scripts/registry_changelog.py "$version")
if [ -z "$notes" ]; then
echo "::warning::No CHANGELOG.md section for $version"
exit 0
@@ -31,11 +27,10 @@ jobs:
delimiter="EOF_$(date +%s%N)"
{
echo "COMFY_NODE_CHANGELOG<<$delimiter"
printf '%s\n' "$notes"
echo "$notes"
echo "$delimiter"
} >> "$GITHUB_ENV"
echo "Changelog for $version:"
printf '%s\n' "$notes"
echo "Changelog for $version: $notes"
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@main
with:
+57
View File
@@ -0,0 +1,57 @@
name: Update registry changelogs
# Fills in the Comfy Registry changelog of versions already published, from the
# matching CHANGELOG.md sections. Run it by hand (Actions -> Run workflow).
on:
workflow_dispatch:
inputs:
versions:
description: "Versions to update, space separated (empty: every version in CHANGELOG.md)"
required: false
default: ""
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Update changelogs
env:
# The registry's edit endpoint takes a registry account key; the publishing
# token is tried when no REGISTRY_UPDATE_TOKEN secret is set.
TOKEN: ${{ secrets.REGISTRY_UPDATE_TOKEN || secrets.REGISTRY_ACCESS_TOKEN }}
VERSIONS: ${{ inputs.versions }}
run: |
python3 - <<'PY'
import json, os, re, sys, urllib.error, urllib.request
API = "https://api.comfy.org"
publisher = re.search(r'^PublisherId\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
node = re.search(r'^name\s*=\s*"([^"]+)"', open("pyproject.toml").read(), re.M).group(1)
import subprocess
# Plain text: the registry shows the changelog as one paragraph.
notes = json.loads(subprocess.check_output(
[sys.executable, ".github/scripts/registry_changelog.py", "--all"], text=True, encoding="utf-8"))
with urllib.request.urlopen(f"{API}/nodes/{node}/versions") as r:
published = json.load(r)
ids = {v["version"]: v["id"] for v in published}
wanted = os.environ.get("VERSIONS", "").split() or sorted(notes)
failed = False
for version in wanted:
if version not in ids or not notes.get(version):
print(f"skip {version}: {'not published' if version not in ids else 'no CHANGELOG section'}")
continue
req = urllib.request.Request(
f"{API}/publishers/{publisher}/nodes/{node}/versions/{ids[version]}",
data=json.dumps({"changelog": notes[version]}).encode(), method="PUT",
headers={"Content-Type": "application/json", "Authorization": f"Bearer {os.environ['TOKEN']}"})
try:
with urllib.request.urlopen(req) as r:
print(f"updated {version}: HTTP {r.status}")
except urllib.error.HTTPError as e:
failed = True
print(f"::error::{version}: HTTP {e.code} {e.read()[:300]!r}")
sys.exit(1 if failed else 0)
PY
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 1.2.3 (2026-09-27)
### Fixed
- The Files tab didn't show a workflow you had just saved into the folder on screen until you left the folder and came back. It now refreshes whenever workflows are saved, moved, renamed or deleted, or folders change, including by other people (each person is only told about folders they can open)
- When the session had ended (logged out elsewhere, expired, cookie cleared), a ComfyUI page that was already open showed "API key missing" errors instead of going back to the login screen. It now returns to the login screen as soon as a request says login is required, even if ComfyUI had not finished loading
## 1.2.2 (2026-09-26)
A Queue tab showing who launched what, and chat clean-up for admins.
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-rigshare"
description = "Turn one ComfyUI server into a shared team workspace. Sign-in with accounts, permissions (edit, queue, Manager, admin) and personal API keys, enforced by the server. A private folder for each person plus shared folders with access lists, with Save asking where. Live co-editing of every shared workflow, with cursors, follow mode and conflict-free merges, in the graph or App view. Per-workflow history with a snapshot on every save, team chat with its whole history, and a CPU, RAM, GPU and queue monitor."
version = "1.2.2"
version = "1.2.3"
license = { file = "LICENSE" }
dependencies = []
+10
View File
@@ -13,6 +13,7 @@ import asyncio
import hashlib
import json
import logging
import posixpath
import secrets
import time
from collections import OrderedDict
@@ -365,6 +366,14 @@ class Hub:
return True
return self.workspace.role(rel, client.kind, key, client.perms) is not None
async def files_changed(self, *paths):
"""Workflow files changed in these folders (userdata paths): tell whoever can
open one of them, so their Files tab reloads."""
folders = {posixpath.dirname(p) if p.endswith(".json") else p for p in paths if p}
for client in list(self.clients.values()):
if client.kind and any(self.path_access(f, client) for f in folders):
await self.send(client, {"type": "files_changed"})
def who_sees(self, path):
"""Connected clients that may open ``path``; call before moving it."""
return [c for c in self.clients.values() if c.kind and self.path_access(path, c)]
@@ -382,6 +391,7 @@ class Hub:
key = (renamed or {}).get(client.key, client.key) if client.kind == "user" else client.key
notes.append((client, self.path_access(new, client, key=key)))
self.rekey_rooms("file:" + old, "file:" + new)
await self.files_changed(posixpath.dirname(old), posixpath.dirname(new), new)
for client, follows in notes:
await self.send(client, {"type": "path_moved", "from": old, "to": new} if follows
else {"type": "path_closed", "from": old})
+52 -2
View File
@@ -7,6 +7,7 @@ import ipaddress
import json
import logging
import os
import posixpath
import time
from urllib.parse import unquote
from collections import defaultdict
@@ -17,6 +18,21 @@ from .workspace import Workspace, norm
log = logging.getLogger("ComfyUI-RigShare")
# Runs in the ComfyUI page before anything else: any request answered "login
# required" (session ended, cookie cleared) sends the browser to the login page.
LOGIN_WATCH = """<script id="rigshare-login-watch">(() => {
const fetch0 = window.fetch;
let leaving = false;
window.fetch = async (...args) => {
const res = await fetch0(...args);
if (res.status === 401 && !leaving && res.headers.get("X-RigShare-Login") === "required") {
leaving = true;
location.href = "/rigshare/login?next=" + encodeURIComponent(location.pathname + location.search);
}
return res;
};
})();</script>"""
# (method, path) prefixes that need a permission when api_protection is on.
PROTECTED = [
("POST", "/prompt", "queue"),
@@ -402,7 +418,10 @@ def setup(server, store, hub):
async def ws_mkdir(request):
body = await request.json()
err, path = ws_call(lambda: ws().make_dir(body.get("parent", ""), body.get("name"), *who(request)))
return err or web.json_response({"path": path})
if err:
return err
await hub.files_changed("workflows/" + posixpath.dirname(path))
return web.json_response({"path": path})
@routes.post("/rigshare/api/workspace/rename-folder")
async def ws_rename_dir(request):
@@ -434,6 +453,7 @@ def setup(server, store, hub):
if err:
return err
await hub.drop_rooms("file:workflows/" + path, display(request))
await hub.files_changed("workflows/" + posixpath.dirname(path))
return web.json_response({"ok": True})
@routes.get("/rigshare/api/workspace/folder")
@@ -458,6 +478,7 @@ def setup(server, store, hub):
except ValueError as e:
return error(str(e))
log.info(f"[RigShare] {key} created folder {folder}")
await hub.files_changed("workflows/shared")
return web.json_response({"path": folder})
@routes.patch("/rigshare/api/workspace/folders")
@@ -486,6 +507,7 @@ def setup(server, store, hub):
except ValueError as e:
return error(str(e))
await hub.refresh_rooms_access()
await hub.files_changed("workflows/shared") # the folder's access changed who sees it
return web.json_response({"ok": True, "path": folder})
@routes.delete("/rigshare/api/workspace/folders")
@@ -617,6 +639,25 @@ def setup(server, store, hub):
action = {"GET": "get", "HEAD": "get", "POST": "save", "DELETE": "delete"}.get(request.method)
return (action, src, None) if action else None
def watch_login(response):
try:
file_path = getattr(response, "_path", None)
if file_path:
with open(file_path, "r", encoding="utf-8") as f:
page = f.read()
elif isinstance(response, web.Response) and response.body and "html" in (response.content_type or ""):
page = response.body.decode("utf-8")
else:
return response
if "<head>" not in page or "rigshare-login-watch" in page:
return response
page = page.replace("<head>", "<head>" + LOGIN_WATCH, 1)
return web.Response(text=page, content_type="text/html",
headers={"Cache-Control": "no-store, must-revalidate"})
except Exception as e:
log.debug(f"[RigShare] could not add the login watch: {e}")
return response
def api_path(request):
return request.path[4:] if request.path.startswith("/api/") else request.path
@@ -729,8 +770,10 @@ def setup(server, store, hub):
path in ("/", "/index.html") or "text/html" in request.headers.get("Accept", ""))
if wants_page:
raise web.HTTPFound(f"/rigshare/login?next={path}")
# The header lets the ComfyUI page (see LOGIN_WATCH) go back to the login screen.
return web.json_response({"error": "RigShare: login required. API clients: send "
"'Authorization: Bearer <api key>'."}, status=401)
"'Authorization: Bearer <api key>'."}, status=401,
headers={"X-RigShare-Login": "required"})
userdata = parse_userdata(request)
if userdata and not trusted(request.remote or ""):
refusal = check_userdata(request, *userdata)
@@ -771,6 +814,7 @@ def setup(server, store, hub):
# Every save of a workflow keeps a snapshot, named after when it was saved.
if userdata and userdata[0] == "save" and getattr(response, "status", 0) == 200:
snapshot_saved_file(request, userdata[1], await request.read())
await hub.files_changed(userdata[1])
# Workflow listings only show what this person may open.
if userdata and userdata[0] == "list" and not trusted(request.remote or "") and hub.workspace:
response = filter_listing(request, response, userdata[1])
@@ -779,6 +823,7 @@ def setup(server, store, hub):
await hub.path_moved(userdata[1], userdata[2])
if userdata and userdata[0] == "delete" and getattr(response, "status", 0) in (200, 204):
await hub.drop_rooms("file:" + userdata[1], display(request))
await hub.files_changed(userdata[1])
# Hide API templates: rewrite the template index ComfyUI serves.
if (store.config.get("hide_api_templates") and request.method == "GET"
and path.startswith("/templates/index") and path.endswith(".json")):
@@ -793,6 +838,11 @@ def setup(server, store, hub):
response = web.json_response(filter_templates(index), headers={"Cache-Control": "no-store"})
except Exception as e:
log.debug(f"[RigShare] could not filter templates: {e}")
# The ComfyUI page: never cached (an ended session must reach the redirect
# above), and watched, so a session that ends while it is open goes back
# to the login screen instead of showing API errors.
if request.method == "GET" and request.path in ("/", "/index.html") and store.config.get("require_login"):
response = watch_login(response)
# Sliding session: renew the cookie JWT when it is getting old.
cookie = request.cookies.get(COOKIE)
if cookie and isinstance(response, web.Response) and not response.prepared:
+51
View File
@@ -495,3 +495,54 @@ def test_queue_shows_who_launched_and_guards_cancelling(run):
assert not rig.ps.prompt_queue.pending
assert (await h.post(rig.url("/api/queue"), json={"clear": True}, headers=admin)).status == 200
run(scenario())
def test_ended_session_goes_back_to_login(run, tmp_path):
index = tmp_path / "index.html"
index.write_text("<!doctype html><html><head><title>ComfyUI</title></head><body></body></html>")
async def scenario():
async def page(request):
from aiohttp import web
return web.FileResponse(str(index))
async with rig_server(setup=lambda app: app.router.add_get("/", page)) as rig:
admin = await rig.setup_admin()
h = rig.http
r = await h.get(rig.url("/"), headers=admin)
text = await r.text()
assert r.status == 200 and "rigshare-login-watch" in text and text.index("rigshare-login-watch") < text.index("<title>")
assert "no-store" in r.headers.get("Cache-Control", ""), "never served from the browser cache"
# Session gone: API calls say so in a header the page watches for...
await h.post(rig.url("/rigshare/api/logout"), headers=admin)
r = await h.get(rig.url("/api/prompt"), headers={"Authorization": "Bearer nope"})
assert r.status == 401 and r.headers.get("X-RigShare-Login") == "required"
# ...and the page itself redirects to the login screen.
r = await h.get(rig.url("/"), headers={"Accept": "text/html"}, allow_redirects=False)
assert r.status == 302 and r.headers["Location"].startswith("/rigshare/login")
run(scenario())
def test_file_changes_reach_who_can_see_them(run):
async def scenario():
async with rig_server() as rig:
await rig.setup_admin()
alice = await rig.add_user("alice", edit=True)
await rig.add_user("bob", edit=True)
a, b = await rig.ws("alice"), await rig.ws("bob")
h = rig.http
async def changed(sock):
return any(m["type"] == "files_changed" for m in await sock.drain())
await h.post(rig.url(f"/api/userdata/{enc('workflows/users/alice/mine.json')}"), data=b'{"nodes": []}', headers=alice)
assert await changed(a) and not await changed(b), "a private save only tells its owner"
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}"), data=b'{"nodes": []}', headers=alice)
assert await changed(a) and await changed(b)
await h.post(rig.url("/rigshare/api/workspace/mkdir"), json={"parent": "@shared", "name": "Crew"}, headers=alice)
assert await changed(b), "new shared folder"
await h.post(rig.url(f"/api/userdata/{enc('workflows/shared/team.json')}/move/{enc('workflows/shared/Crew/team.json')}"), headers=rig.headers["boss"])
assert await changed(b), "moves"
await a.close(); await b.close()
run(scenario())
+8 -1
View File
@@ -35,7 +35,14 @@ export class FilesBrowser {
h("div", { class: "rs-fb-tools" }, this.search),
this.list);
// The sidebar browser lives as long as the page: keep its live dots current.
if (mode === "browse") client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
if (mode === "browse") {
client.on("presence", () => { if (this.listing && this.el.isConnected) this.renderList(); });
// Someone (maybe you, from the Save dialog) saved, moved or deleted a workflow: reload.
client.on("files_changed", () => {
clearTimeout(this.changedTimer);
this.changedTimer = setTimeout(() => { if (this.el.isConnected && !this.accessFor) this.reload(); }, 250);
});
}
this.setStart(start);
}