Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e123dc9279 | ||
|
|
a75443d8bb | ||
|
|
6e2324a7c3 | ||
|
|
137051a135 | ||
|
|
78415ebf25 | ||
|
|
c94b90133a |
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user