Files
malkuthro-ComfyUI-Koolook/web/sidebar/installer.js
malkuthroandClaude Opus 4.7 a6a31c8086 feat(sidebar): Tools row + starter preset replaces curated_defaults (#72)
* fix(sidebar): merge preset HEAD route into GET handler

PR #68 shipped koolook_routes.py with @routes.get + @routes.head on the
same path. aiohttp's add_get auto-registers HEAD by default, so the
explicit @routes.head collided with the auto-synthesized HEAD and
crashed ComfyUI startup with "Added route will never be executed,
method HEAD is already registered".

Trying allow_head=False on the GET to opt out triggered a second crash:
ComfyUI's server.py mirrors every PromptServer route into a parallel
/api/... table via api_routes.route(method, path)(handler, **kwargs),
and RouteTableDef.route's inner closure rejects unknown kwargs with
TypeError. So no kwarg-based opt-out is viable.

Drop the dedicated HEAD handler. Aiohttp's auto-HEAD-from-GET delivers
HEAD probes to get_preset; we branch on request.method to skip the
file read for HEAD — preserving the cheap-existence optimization the
client's presetExists relies on for multi-MB JSON over Dropbox / iCloud
/ NFS-mounted libraries. CHANGELOG [Unreleased] updated to match.

This bug never reached users — koolook_routes.py was added in #68
(post-v0.2.0) and was never published to Comfy Registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sidebar): Tools row + starter preset replaces curated_defaults

Two coordinated UX changes for fresh-install distribution + admin
ergonomics — designed together and shipped as one unit.

1. New "Tools" row sits between the Snapshot row and the search field.
   Moves the Export and "Install missing for picks" buttons up out of
   the Nodes row (admin/advanced operations don't belong in the
   everyday + Add toolbar). Adds a new "Drop placeholders onto canvas"
   button — the security_level=normal escape hatch for installing
   missing packs: instantiates one placeholder per missing pack on a
   fresh canvas tab so ComfyUI/Manager's standard "Install Missing
   Custom Nodes" detection takes over via Manager's UI flow (which
   uses different gates than our programmatic /customnode/install/
   git_url call). Nodes row keeps only the everyday + button.

2. curated_defaults.json retired in favor of starter_preset.json.
   Fresh installs no longer get picks seeded directly into localStorage
   on first load. Instead the bundled web/starter_preset.json (full
   snapshot format — picks + workflows + tags + archive) is copied
   into the user's snapshot library directory as starter.json. The
   user opens Snapshot -> Load -> starter to apply in one click.
   Unifies fresh-install distribution with the per-user snapshot
   library (#68); maintainer flow becomes "build state in ComfyUI ->
   click the (down-arrow) Tools-row button -> paste over
   web/starter_preset.json -> commit." Existing users with non-empty
   picks are explicitly skipped by the new seeder, so their state is
   untouched.

Removed: seedDefaultsIfNeeded, exportPicks, web/curated_defaults.json,
SEEDED_KEY, DEFAULTS_URL.

Added: seedStarterPresetIfNeeded, exportStarterPreset (snapshot.js),
STARTER_SEEDED_KEY, STARTER_URL, STARTER_PRESET_FILENAME,
discoverMissingPacks (installer.js), dropPlaceholdersForPacks
(canvas_io.js), web/starter_preset.json.

Docs: CLAUDE.md "Curated Nodes sidebar — distributed defaults" ->
"Kforge Labs sidebar — starter preset distribution".
docs/maintainers/curated-sidebar.md rewritten.
CHANGELOG.md [Unreleased] updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 17:18:15 +02:00

215 lines
9.2 KiB
JavaScript

// =============================================================================
// ComfyUI-Manager API client + pick → install resolver.
//
// Detects whether Manager is reachable, walks the user's curated picks
// against /customnode/getmappings to find which packs need installing,
// queues git-URL installs, polls the install queue for progress, and
// reboots. UI-free — modals.js drives the user-facing flow on top of the
// functions exported here.
//
// Routes referenced (Manager source pins):
// GET /manager/queue/status → reachability probe + progress poll
// GET /customnode/getmappings?mode → unified node-id ↔ git-url mapping
// POST /customnode/install/git_url → queue one repo for install (text body)
// POST /manager/queue/start → kick off the worker thread
// POST /manager/reboot → restart ComfyUI to load installed code
//
// `?mode=local` is REQUIRED on getmappings — Manager errors with KeyError if
// the query param is absent (manager_server.py: `request.rel_url.query["mode"]`).
// =============================================================================
const ENDPOINT_QUEUE_STATUS = "/manager/queue/status";
const ENDPOINT_QUEUE_START = "/manager/queue/start";
const ENDPOINT_GETMAPPINGS = "/customnode/getmappings?mode=local";
const ENDPOINT_INSTALL_GIT_URL = "/customnode/install/git_url";
const ENDPOINT_REBOOT = "/manager/reboot";
export const POLL_INTERVAL_MS = 1000;
// =============================================================================
// Detection
// =============================================================================
// Reachability probe. The status endpoint is unauthenticated and side-effect-
// free, so a 200 here is a strong signal Manager is loaded and serving routes.
// Anything else (network error, 404 — e.g. user disabled Manager) routes the
// caller into the copy-URL fallback path.
export async function detectManager() {
try {
const r = await fetch(ENDPOINT_QUEUE_STATUS, { method: "GET" });
return r.ok;
} catch (e) {
console.warn("[Koolook] Manager reachability probe failed:", e);
return false;
}
}
// =============================================================================
// Mapping load + pick resolution
// =============================================================================
// /customnode/getmappings response shape (mirrors extension-node-map.json):
// { "<git_url>": [[node_id1, node_id2, ...], { title, ... }] }
// We invert it to a node-id → git-url Map so a list of picks can be resolved
// in O(picks). First-write-wins on duplicate node IDs across repos — rare,
// but if it happens the user can override by editing picks; either repo is a
// valid install target.
export async function loadMappings() {
const r = await fetch(ENDPOINT_GETMAPPINGS);
if (!r.ok) throw new Error(`getmappings ${r.status}`);
const data = await r.json();
const urlByNodeId = new Map();
for (const [gitUrl, val] of Object.entries(data || {})) {
if (!Array.isArray(val) || !Array.isArray(val[0])) continue;
for (const id of val[0]) {
if (typeof id !== "string") continue;
if (!urlByNodeId.has(id)) urlByNodeId.set(id, gitUrl);
}
}
return { urlByNodeId };
}
// Bucket the picks by what we need to do with them. The `byUrl` Map dedupes
// repos when several picks live in the same pack — we install once per repo,
// not once per node ID.
//
// `unresolved` collects picks the mapping doesn't know about. Common causes:
// • a private/local node pack (never published to ComfyUI-Manager's index)
// • a pack so new it isn't in the cached extension-node-map.json yet
// • a renamed node that the upstream mapping hasn't caught up with
// We surface those by ID so the user can act manually instead of silently
// dropping them.
export function resolvePicksToInstall(picks, urlByNodeId) {
const registry = (typeof LiteGraph !== "undefined" && LiteGraph.registered_node_types) || {};
const alreadyInstalled = [];
const unresolved = [];
const byUrl = new Map();
for (const id of picks) {
if (registry[id]) {
alreadyInstalled.push(id);
continue;
}
const url = urlByNodeId.get(id);
if (!url) {
unresolved.push(id);
continue;
}
if (!byUrl.has(url)) byUrl.set(url, []);
byUrl.get(url).push(id);
}
return { alreadyInstalled, willInstall: { byUrl }, unresolved };
}
// =============================================================================
// UI-free discovery helper
// =============================================================================
// Wraps the detect → loadMappings → resolvePicksToInstall sequence into a
// single call so non-modal callers (e.g. the "drop placeholders onto canvas"
// button) can run discovery without paying for the Install-Missing modal.
//
// Discriminated result. `ok: true` means we have a usable bucket breakdown;
// `ok: false` means we hit a precondition failure that should surface as a
// toast or an in-place message rather than as an empty bucket list.
//
// { ok: true, result: { alreadyInstalled, willInstall: { byUrl }, unresolved } }
// { ok: false, reason: "manager-unreachable" }
// { ok: false, reason: "mapping-load-failed", error: Error }
export async function discoverMissingPacks(picks) {
const managerOk = await detectManager();
if (!managerOk) return { ok: false, reason: "manager-unreachable" };
let mappings;
try {
mappings = await loadMappings();
} catch (e) {
return { ok: false, reason: "mapping-load-failed", error: e };
}
return { ok: true, result: resolvePicksToInstall(picks, mappings.urlByNodeId) };
}
// =============================================================================
// Install queue dispatch
// =============================================================================
// One git URL → one queue entry. Body is plain text (per the route's
// `await request.text()` on the server); fetch defaults to a text/plain
// content-type for a string body, which Manager accepts.
//
// 403 is the structured "your security level forbids this" response — we map
// it to user-facing language because raw HTTP codes don't help the user act.
// Other non-2xx are surfaced with their status so the result modal can still
// show what failed.
export async function queueInstallGitUrl(gitUrl) {
try {
const r = await fetch(ENDPOINT_INSTALL_GIT_URL, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: gitUrl,
});
if (r.ok) return { ok: true };
const message = r.status === 403
? "Manager rejected the install — your security level forbids git-URL installs. Install via Manager UI, or relax the security_level setting."
: `Install request failed (HTTP ${r.status}).`;
return { ok: false, status: r.status, message };
} catch (e) {
return { ok: false, status: 0, message: `Network error: ${e.message}` };
}
}
// Start the worker thread that drains the queue. 200 = started, 201 = already
// processing (e.g. the user kicked off another install moments earlier).
// Both are success from our perspective — the queue is being worked.
export async function startQueue() {
try {
const r = await fetch(ENDPOINT_QUEUE_START, { method: "POST" });
return r.ok || r.status === 201;
} catch (e) {
console.warn("[Koolook] startQueue failed:", e);
return false;
}
}
export async function getQueueStatus() {
const r = await fetch(ENDPOINT_QUEUE_STATUS);
if (!r.ok) throw new Error(`queue/status ${r.status}`);
return await r.json();
}
// Poll status until `is_processing` flips false. `signal.aborted = true` lets
// the caller bail without canceling installs already in flight (we can't —
// Manager has no per-task cancel) — it just disengages the UI loop.
//
// Returns the final status snapshot, or null if aborted / a poll fetch threw.
export async function pollUntilDone({ intervalMs = POLL_INTERVAL_MS, onTick, signal } = {}) {
while (true) {
if (signal && signal.aborted) return null;
let status;
try {
status = await getQueueStatus();
} catch (e) {
console.warn("[Koolook] queue status poll failed:", e);
return null;
}
if (typeof onTick === "function") onTick(status);
if (!status.is_processing) return status;
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
// =============================================================================
// Restart
// =============================================================================
// Manager's reboot endpoint signals the launcher (via a sentinel file) that
// ComfyUI should restart. The HTTP request itself usually fails partway —
// the server is going down — so we treat a network error here as "request
// likely accepted" rather than a hard failure.
export async function reboot() {
try {
const r = await fetch(ENDPOINT_REBOOT, { method: "POST" });
return r.ok;
} catch (e) {
return true;
}
}