diff --git a/docs/nodes/creatingNodePack.md b/docs/nodes/creatingNodePack.md new file mode 100644 index 00000000000..7dc7c8824ab --- /dev/null +++ b/docs/nodes/creatingNodePack.md @@ -0,0 +1,154 @@ +# Creating a Node Pack for the Custom Node Manager + +This guide explains how to structure your Git repository so it can be installed via InvokeAI's Custom Node Manager. + +## Repository Structure + +Your repository **is** the node pack. When a user installs it, the entire repo is cloned into the `nodes` directory. + +### Minimum Required Structure + +``` +my-node-pack/ +├── __init__.py # Required: imports your node classes +├── my_node.py # Your node implementation(s) +└── README.md # Recommended: describe your nodes +``` + +The `__init__.py` at the root is **mandatory**. Without it, the pack will not be loaded. + +### Recommended Structure + +``` +my-node-pack/ +├── __init__.py # Imports all node classes +├── requirements.txt # Python dependencies (user-installed) +├── README.md # Description, usage, examples +├── node_one.py # Node implementation +├── node_two.py # Node implementation +├── utils.py # Shared utilities +└── workflows/ # Optional: workflow files + ├── example_workflow.json + └── advanced_workflow.json +``` + +## The `__init__.py` File + +This file must import all invocation classes you want to register. Only classes imported here will be available in InvokeAI. + +```python +from .node_one import MyFirstInvocation +from .node_two import MySecondInvocation +``` + +If you have nodes in subdirectories: + +```python +from .nodes.image_tools import CropInvocation, ResizeInvocation +from .nodes.text_tools import ConcatInvocation +``` + +## Dependencies (`requirements.txt` or `pyproject.toml`) + +If your nodes require additional Python packages, list them in a `requirements.txt` (or `pyproject.toml`) at the repository root: + +``` +numpy>=1.24 +opencv-python>=4.8 +``` + +The Custom Node Manager **does not** install these dependencies automatically — auto-installing into the running InvokeAI environment risks pulling in incompatible versions and breaking the application. After install, the UI shows the user a toast telling them that manual installation is required, and your README should document the exact install command (e.g. `pip install -r requirements.txt` from inside an activated InvokeAI environment). + +**Important:** Avoid pinning versions too tightly. InvokeAI has its own dependencies, and version conflicts can cause issues. Use minimum version constraints (`>=`) where possible. + +## Including Workflows + +If your repository contains workflow `.json` files, they will be **automatically imported** into the user's workflow library during installation. + +### Workflow Detection + +The installer recursively scans your repository for `.json` files. A file is recognized as a workflow if it contains both `nodes` and `edges` keys at the top level. + +### Tagging + +Imported workflows are automatically tagged with `node-pack:` so users can filter for them in the workflow library. When the node pack is uninstalled, these workflows are also removed. + +### Workflow Format + +Workflows should follow the standard InvokeAI workflow format: + +```json +{ + "name": "My Example Workflow", + "author": "Your Name", + "description": "Demonstrates how to use MyFirstInvocation", + "version": "1.0.0", + "contact": "", + "tags": "example, my-node-pack", + "notes": "", + "meta": { + "version": "3.0.0", + "category": "user" + }, + "exposedFields": [], + "nodes": [...], + "edges": [...] +} +``` + +**Tip:** The easiest way to create a workflow file is to build the workflow in InvokeAI's workflow editor, then export it via **Save As** and copy the `.json` file into your repository. + +## Node Implementation + +Each node is a Python class decorated with `@invocation()`. Here's a minimal example: + +```python +from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.invocation_api import BaseInvocationOutput, invocation_output + + +@invocation_output("my_output") +class MyOutput(BaseInvocationOutput): + result: str = OutputField(description="The result") + + +@invocation( + "my_node", + title="My Node", + tags=["example", "custom"], + category="custom", + version="1.0.0", +) +class MyInvocation(BaseInvocation): + """Does something useful.""" + + input_text: str = InputField(default="", description="Input text") + + def invoke(self, context) -> MyOutput: + return MyOutput(result=f"Processed: {self.input_text}") +``` + +For full details on the invocation API, see the [Invocation API documentation](invocation-api.md). + +## Best Practices + +- **Use a descriptive repository name** — it becomes the pack name shown in the UI +- **Include a README.md** with description, screenshots, and usage instructions +- **Version your nodes** using semver in the `@invocation()` decorator +- **Don't include large binary files** in your repository (models, weights, etc.) +- **Test your nodes** by placing the repo in the `nodes` directory before publishing +- **Include example workflows** so users can get started quickly +- **Tag your GitHub repository** with `invokeai-node` for discoverability +- **Avoid name collisions** — choose unique invocation type strings (e.g. `my_pack_resize` instead of just `resize`) + +## Testing Your Pack + +Before publishing, verify your pack works with the Custom Node Manager: + +1. Create a Git repository with your node pack +2. Push it to GitHub (or any Git host) +3. In InvokeAI, go to the Nodes tab and install it via the Git URL +4. Verify your nodes appear in the workflow editor +5. Verify any included workflows are imported +6. Test uninstalling — nodes and workflows should be removed diff --git a/docs/nodes/customNodeManager.md b/docs/nodes/customNodeManager.md new file mode 100644 index 00000000000..76a4b804a95 --- /dev/null +++ b/docs/nodes/customNodeManager.md @@ -0,0 +1,78 @@ +# Custom Node Manager + +The Custom Node Manager allows you to install, manage, and remove community node packs directly from the InvokeAI UI — no manual file copying required. + +## Accessing the Node Manager + +Click the **Nodes** tab (circuit icon) in the left sidebar, between Models and Queue. + +## Installing a Node Pack + +1. Navigate to the **Nodes** tab +2. On the right panel, select the **Git Repository URL** tab +3. Paste the Git URL of the node pack (e.g. `https://github.com/user/my-node-pack.git`) +4. Click **Install** + +The installer will: + +- Clone the repository into your `nodes` directory +- Load the nodes immediately — no restart needed +- Import any workflow `.json` files found in the repository into your workflow library (tagged with `node-pack:` for easy filtering) + +The install progress and results are shown in the **Install Log** at the bottom of the panel. + +### Installing Python Dependencies + +The installer does **not** automatically run `pip install` for `requirements.txt` or `pyproject.toml`. Auto-installing dependencies into the running InvokeAI environment can pull in incompatible package versions and break the application. + +If a node pack ships a `requirements.txt` or `pyproject.toml`, you'll see a warning toast after installation. Install the dependencies yourself by following the instructions in the node pack's documentation (typically `pip install -r requirements.txt` from inside an activated InvokeAI environment, but check the pack's README first). After installing, click the **Reload** button so the new dependencies take effect. + +### Security Warning + +Custom nodes execute arbitrary Python code on your system. **Only install node packs from authors you trust.** Malicious nodes could harm your system or compromise your data. + +## Managing Installed Nodes + +The left panel shows all installed node packs with: + +- **Pack name** +- **Number of nodes** provided +- **Individual node types** as badges +- **File path** on disk + +### Reloading Nodes + +Click the **Reload** button to re-scan the nodes directory. This picks up any node packs that were manually added to the directory without using the installer. + +### Uninstalling a Node Pack + +Click the **Uninstall** button on any node pack. This will: + +- Remove the node pack directory +- Unregister the nodes from the system immediately +- Remove any workflows that were imported from the pack +- Update the workflow editor so the nodes are no longer available + +No restart is required. + +## Scan Folder Tab + +The **Scan Folder** tab shows the location of your nodes directory. Node packs placed there manually (e.g. via `git clone`) are automatically detected at startup. Use the **Reload** button to detect newly added packs without restarting. + +## Troubleshooting + +### Node pack fails to install + +- Verify the Git URL is correct and accessible +- Check that the repository contains an `__init__.py` file at the top level +- Review the Install Log for error details + +### Nodes don't appear after install + +- Click the **Reload** button +- Check that the node pack's `__init__.py` imports its node classes +- Check the server console for error messages + +### Workflows show errors after uninstalling + +If you have user-created workflows that reference nodes from an uninstalled pack, those workflows will show errors for the missing node types. Reinstall the pack or remove the affected nodes from the workflow. diff --git a/invokeai/app/api/routers/custom_nodes.py b/invokeai/app/api/routers/custom_nodes.py new file mode 100644 index 00000000000..3ee8c0ec99c --- /dev/null +++ b/invokeai/app/api/routers/custom_nodes.py @@ -0,0 +1,504 @@ +"""FastAPI routes for custom node management.""" + +import json +import shutil +import subprocess +import sys +import traceback +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from typing import Optional + +from fastapi import Body +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.auth_dependencies import AdminUserOrDefault +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.invocations.baseinvocation import InvocationRegistry +from invokeai.app.services.config.config_default import get_config +from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator +from invokeai.backend.util.logging import InvokeAILogger + +custom_nodes_router = APIRouter(prefix="/v2/custom_nodes", tags=["custom_nodes"]) + +logger = InvokeAILogger.get_logger() + +# Name of the manifest file written inside a pack directory to track which workflows +# were imported by that pack. Used on uninstall to delete only pack-imported workflows +# — deleting by tag alone is unsafe because users can edit tags on their own workflows. +PACK_MANIFEST_FILENAME = ".invokeai_pack_manifest.json" + + +class NodePackInfo(BaseModel): + """Information about an installed node pack.""" + + name: str = Field(description="The name of the node pack.") + path: str = Field(description="The path to the node pack directory.") + node_count: int = Field(description="The number of nodes in the pack.") + node_types: list[str] = Field(description="The invocation types provided by this node pack.") + + +class NodePackListResponse(BaseModel): + """Response for listing installed node packs.""" + + node_packs: list[NodePackInfo] = Field(description="List of installed node packs.") + custom_nodes_path: str = Field(description="The configured custom nodes directory path.") + + +class InstallNodePackRequest(BaseModel): + """Request to install a node pack from a git URL.""" + + source: str = Field(description="Git URL of the node pack to install.") + + +class InstallNodePackResponse(BaseModel): + """Response after installing a node pack.""" + + name: str = Field(description="The name of the installed node pack.") + success: bool = Field(description="Whether the installation was successful.") + message: str = Field(description="Status message.") + workflows_imported: int = Field(default=0, description="Number of workflows imported from the pack.") + requires_dependencies: bool = Field( + default=False, + description="Whether the pack ships a dependency manifest (requirements.txt or pyproject.toml) " + "that the user must install manually following the pack's documentation.", + ) + dependency_file: Optional[str] = Field( + default=None, + description="Name of the detected dependency manifest file, if any.", + ) + + +class UninstallNodePackResponse(BaseModel): + """Response after uninstalling a node pack.""" + + name: str = Field(description="The name of the uninstalled node pack.") + success: bool = Field(description="Whether the uninstall was successful.") + message: str = Field(description="Status message.") + + +def _get_custom_nodes_path() -> Path: + """Returns the configured custom nodes directory path.""" + config = get_config() + return config.custom_nodes_path + + +def _get_installed_packs() -> list[NodePackInfo]: + """Scans the custom nodes directory and returns info about installed packs.""" + custom_nodes_path = _get_custom_nodes_path() + + if not custom_nodes_path.exists(): + return [] + + packs: list[NodePackInfo] = [] + + # Get all node types grouped by node_pack + node_types_by_pack: dict[str, list[str]] = {} + for inv_class in InvocationRegistry._invocation_classes: + node_pack = inv_class.UIConfig.node_pack + inv_type = inv_class.get_type() + if node_pack not in node_types_by_pack: + node_types_by_pack[node_pack] = [] + node_types_by_pack[node_pack].append(inv_type) + + for d in sorted(custom_nodes_path.iterdir()): + if not d.is_dir(): + continue + if d.name.startswith("_") or d.name.startswith("."): + continue + init = d / "__init__.py" + if not init.exists(): + continue + + pack_name = d.name + node_types = node_types_by_pack.get(pack_name, []) + + packs.append( + NodePackInfo( + name=pack_name, + path=str(d), + node_count=len(node_types), + node_types=node_types, + ) + ) + + return packs + + +@custom_nodes_router.get( + "/", + operation_id="list_custom_node_packs", + response_model=NodePackListResponse, +) +async def list_custom_node_packs(current_admin: AdminUserOrDefault) -> NodePackListResponse: + """Lists all installed custom node packs. + + Admin-only: the response includes absolute filesystem paths, and non-admins have no + legitimate use for pack management data (install/uninstall/reload are also admin-only). + """ + packs = _get_installed_packs() + return NodePackListResponse(node_packs=packs, custom_nodes_path=str(_get_custom_nodes_path())) + + +@custom_nodes_router.post( + "/install", + operation_id="install_custom_node_pack", + response_model=InstallNodePackResponse, +) +async def install_custom_node_pack( + current_admin: AdminUserOrDefault, + request: InstallNodePackRequest = Body(description="The source URL to install from."), +) -> InstallNodePackResponse: + """Installs a custom node pack from a git URL by cloning it into the nodes directory.""" + custom_nodes_path = _get_custom_nodes_path() + custom_nodes_path.mkdir(parents=True, exist_ok=True) + + source = request.source.strip() + + # Extract pack name from URL + pack_name = source.rstrip("/").split("/")[-1] + if pack_name.endswith(".git"): + pack_name = pack_name[:-4] + + target_dir = custom_nodes_path / pack_name + + if target_dir.exists(): + return InstallNodePackResponse( + name=pack_name, + success=False, + message=f"Node pack '{pack_name}' already exists. Uninstall it first to reinstall.", + ) + + try: + # Clone the repository + result = subprocess.run( + ["git", "clone", source, str(target_dir)], + capture_output=True, + text=True, + timeout=120, + ) + + if result.returncode != 0: + # Clean up on failure + if target_dir.exists(): + shutil.rmtree(target_dir) + return InstallNodePackResponse( + name=pack_name, + success=False, + message=f"Git clone failed: {result.stderr.strip()}", + ) + + # Detect dependency manifests but do NOT install them automatically. + # The user is responsible for installing dependencies per the pack's documentation, + # since arbitrary pip installs can break the InvokeAI environment. + dependency_file: Optional[str] = None + for candidate in ("requirements.txt", "pyproject.toml"): + if (target_dir / candidate).exists(): + dependency_file = candidate + logger.info(f"Node pack '{pack_name}' ships a {candidate}; user must install dependencies manually.") + break + + # Check for __init__.py + init_file = target_dir / "__init__.py" + if not init_file.exists(): + shutil.rmtree(target_dir) + return InstallNodePackResponse( + name=pack_name, + success=False, + message=f"Node pack '{pack_name}' does not contain an __init__.py file.", + ) + + # Load the node pack at runtime + _load_node_pack(pack_name, target_dir) + + # Import any workflows found in the pack, owned by the installing admin and shared with all users + imported_workflow_ids = _import_workflows_from_pack(target_dir, pack_name, owner_user_id=current_admin.user_id) + _write_pack_manifest(target_dir, imported_workflow_ids) + workflows_imported = len(imported_workflow_ids) + workflow_msg = f" Imported {workflows_imported} workflow(s)." if workflows_imported > 0 else "" + dependency_msg = ( + f" This pack includes a {dependency_file} — install its dependencies manually following the pack's documentation." + if dependency_file + else "" + ) + + return InstallNodePackResponse( + name=pack_name, + success=True, + message=f"Successfully installed node pack '{pack_name}'.{workflow_msg}{dependency_msg}", + workflows_imported=workflows_imported, + requires_dependencies=dependency_file is not None, + dependency_file=dependency_file, + ) + + except subprocess.TimeoutExpired: + if target_dir.exists(): + shutil.rmtree(target_dir) + return InstallNodePackResponse( + name=pack_name, + success=False, + message="Installation timed out.", + ) + except Exception: + if target_dir.exists(): + shutil.rmtree(target_dir) + error = traceback.format_exc() + logger.error(f"Failed to install node pack {pack_name}: {error}") + return InstallNodePackResponse( + name=pack_name, + success=False, + message=f"Installation failed: {error}", + ) + + +@custom_nodes_router.delete( + "/{pack_name}", + operation_id="uninstall_custom_node_pack", + response_model=UninstallNodePackResponse, +) +async def uninstall_custom_node_pack( + current_admin: AdminUserOrDefault, + pack_name: str, +) -> UninstallNodePackResponse: + """Uninstalls a custom node pack by removing its directory. + + Note: A restart is required for the node removal to take full effect. + Installed nodes from the pack will remain registered until restart. + """ + custom_nodes_path = _get_custom_nodes_path() + target_dir = custom_nodes_path / pack_name + + if not target_dir.exists(): + return UninstallNodePackResponse( + name=pack_name, + success=False, + message=f"Node pack '{pack_name}' not found.", + ) + + try: + # Read the manifest BEFORE removing the directory — it records exactly which + # workflow IDs this pack imported, so uninstall doesn't accidentally delete + # user workflows that happen to share the pack tag. + imported_workflow_ids = _read_pack_manifest(target_dir) + + shutil.rmtree(target_dir) + + # Unregister the nodes from the registry so they disappear immediately + removed_types = InvocationRegistry.unregister_pack(pack_name) + if removed_types: + # Invalidate OpenAPI schema cache so frontend gets updated node definitions + from invokeai.app.api_app import app + + app.openapi_schema = None + logger.info( + f"Unregistered {len(removed_types)} node(s) from pack '{pack_name}': {', '.join(removed_types)}" + ) + + # Remove the pack's module subtree from sys.modules. Only dropping the + # root module would leave submodules cached; on reinstall the cached + # submodules would be reused without re-running their @invocation + # decorators, so the pack would show up with 0 nodes until restart. + _purge_pack_modules(pack_name) + + # Remove only workflows this pack imported, using the manifest-recorded IDs + workflows_removed = _remove_workflows_by_ids(imported_workflow_ids, pack_name) + workflow_msg = f" Removed {workflows_removed} workflow(s)." if workflows_removed > 0 else "" + + return UninstallNodePackResponse( + name=pack_name, + success=True, + message=f"Successfully uninstalled node pack '{pack_name}'.{workflow_msg}", + ) + except Exception: + error = traceback.format_exc() + logger.error(f"Failed to uninstall node pack {pack_name}: {error}") + return UninstallNodePackResponse( + name=pack_name, + success=False, + message=f"Uninstall failed: {error}", + ) + + +@custom_nodes_router.post( + "/reload", + operation_id="reload_custom_nodes", +) +async def reload_custom_nodes(current_admin: AdminUserOrDefault) -> dict[str, str]: + """Triggers a reload of all custom nodes. + + This re-scans the nodes directory and loads any new node packs. + Already loaded packs are skipped. + """ + config = get_config() + custom_nodes_path = config.custom_nodes_path + + if not custom_nodes_path.exists(): + return {"status": "No custom nodes directory found."} + + from invokeai.app.invocations.load_custom_nodes import load_custom_nodes + + load_custom_nodes(custom_nodes_path, logger) + + # Invalidate the OpenAPI schema cache so the frontend gets updated node definitions + from invokeai.app.api_app import app + + app.openapi_schema = None + + return {"status": "Custom nodes reloaded successfully."} + + +def _purge_pack_modules(pack_name: str) -> list[str]: + """Removes the pack's root module and all of its submodules from sys.modules. + + After uninstall, cached submodules (e.g. `pack_name.nodes`, `pack_name.foo.bar`) + must be evicted as well — otherwise a subsequent reinstall reuses the cached + objects, the @invocation decorators never re-run, and the pack ends up loaded + with zero registered nodes until a full process restart. + """ + prefix = f"{pack_name}." + to_remove = [name for name in sys.modules if name == pack_name or name.startswith(prefix)] + for name in to_remove: + del sys.modules[name] + return to_remove + + +def _load_node_pack(pack_name: str, pack_dir: Path) -> None: + """Loads a single node pack at runtime.""" + init = pack_dir / "__init__.py" + if not init.exists(): + return + + if pack_name in sys.modules: + logger.info(f"Node pack {pack_name} already loaded, skipping.") + return + + spec = spec_from_file_location(pack_name, init.absolute()) + if spec is None or spec.loader is None: + logger.warning(f"Could not load {init}") + return + + logger.info(f"Loading node pack {pack_name}") + module = module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + # Invalidate OpenAPI schema cache + from invokeai.app.api_app import app + + app.openapi_schema = None + + logger.info(f"Successfully loaded node pack {pack_name}") + + +def _import_workflows_from_pack(pack_dir: Path, pack_name: str, owner_user_id: str) -> list[str]: + """Scans a node pack directory for workflow JSON files and imports them into the workflow library. + + A JSON file is considered a workflow if it contains 'nodes' and 'edges' keys at the top level. + Workflows are imported as user workflows owned by the installing admin and marked public so all + users can see them — a pack is an admin-installed shared resource, not a private asset. + + Returns the list of workflow IDs successfully created, in import order. + """ + imported_ids: list[str] = [] + + # Search for .json files recursively + for json_file in pack_dir.rglob("*.json"): + # Skip our own manifest file + if json_file.name == PACK_MANIFEST_FILENAME: + continue + try: + with open(json_file, "r", encoding="utf-8") as f: + data = json.load(f) + + # Check if this looks like a workflow (must have nodes and edges) + if not isinstance(data, dict): + continue + if "nodes" not in data or "edges" not in data: + continue + + # Ensure the workflow has a meta section with category set to "user" + if "meta" not in data: + data["meta"] = {"version": "3.0.0", "category": "user"} + else: + data["meta"]["category"] = "user" + + # Add the node pack name to tags for discoverability (display only — uninstall + # does not rely on this tag, since users can edit tags on their own workflows). + existing_tags = data.get("tags", "") + pack_tag = f"node-pack:{pack_name}" + if pack_tag not in existing_tags: + data["tags"] = f"{existing_tags}, {pack_tag}".strip(", ") if existing_tags else pack_tag + + # Remove the 'id' field if present — the system will assign a new one + data.pop("id", None) + + # Validate and import the workflow + workflow = WorkflowWithoutIDValidator.validate_python(data) + created = ApiDependencies.invoker.services.workflow_records.create( + workflow=workflow, user_id=owner_user_id, is_public=True + ) + imported_ids.append(created.workflow_id) + logger.info(f"Imported workflow '{workflow.name}' from node pack '{pack_name}'") + + except Exception: + logger.warning(f"Skipped non-workflow or invalid JSON file: {json_file}") + continue + + if imported_ids: + logger.info(f"Imported {len(imported_ids)} workflow(s) from node pack '{pack_name}'") + + return imported_ids + + +def _write_pack_manifest(pack_dir: Path, workflow_ids: list[str]) -> None: + """Writes the pack manifest recording which workflow IDs were imported from the pack.""" + manifest_path = pack_dir / PACK_MANIFEST_FILENAME + try: + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump({"workflow_ids": workflow_ids}, f) + except Exception: + logger.warning(f"Failed to write pack manifest at {manifest_path}") + + +def _read_pack_manifest(pack_dir: Path) -> list[str]: + """Reads workflow IDs that this pack's install recorded in its manifest. + + Returns an empty list if the manifest is missing or malformed. We deliberately do NOT + fall back to tag-based lookup: workflow tags are user-editable and could collide with + unrelated workflows, so we only delete what we recorded ourselves at install time. + """ + manifest_path = pack_dir / PACK_MANIFEST_FILENAME + if not manifest_path.exists(): + return [] + try: + with open(manifest_path, "r", encoding="utf-8") as f: + data = json.load(f) + ids = data.get("workflow_ids", []) + if not isinstance(ids, list): + return [] + return [str(x) for x in ids if isinstance(x, str)] + except Exception: + logger.warning(f"Failed to read pack manifest at {manifest_path}") + return [] + + +def _remove_workflows_by_ids(workflow_ids: list[str], pack_name: str) -> int: + """Deletes the given workflow IDs. Used during uninstall to remove only the workflows + this pack's install recorded in its manifest. + """ + if not workflow_ids: + return 0 + + removed_count = 0 + for workflow_id in workflow_ids: + try: + ApiDependencies.invoker.services.workflow_records.delete(workflow_id) + removed_count += 1 + except Exception: + logger.warning(f"Failed to remove workflow '{workflow_id}' (from node pack '{pack_name}')") + + if removed_count > 0: + logger.info(f"Removed {removed_count} workflow(s) from node pack '{pack_name}'") + + return removed_count diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 2ca6746b496..f63ef8f7ac4 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -21,6 +21,7 @@ board_images, boards, client_state, + custom_nodes, download_queue, images, model_manager, @@ -184,6 +185,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(style_presets.style_presets_router, prefix="/api") app.include_router(client_state.client_state_router, prefix="/api") app.include_router(recall_parameters.recall_parameters_router, prefix="/api") +app.include_router(custom_nodes.custom_nodes_router, prefix="/api") app.openapi = get_openapi_func(app) diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py index 9141995e460..0546dabebb5 100644 --- a/invokeai/app/invocations/baseinvocation.py +++ b/invokeai/app/invocations/baseinvocation.py @@ -338,6 +338,32 @@ def invalidate_invocation_typeadapter(cls) -> None: """Invalidates the cached invocation type adapter.""" cls.get_invocation_typeadapter.cache_clear() + @classmethod + def unregister_pack(cls, node_pack: str) -> list[str]: + """Unregisters all invocations and outputs belonging to a node pack. + + Returns a list of the invocation types that were removed. + """ + removed_types: list[str] = [] + + invocations_to_remove = {inv for inv in cls._invocation_classes if inv.UIConfig.node_pack == node_pack} + for inv in invocations_to_remove: + removed_types.append(inv.get_type()) + cls._invocation_classes.discard(inv) + + if invocations_to_remove: + cls.invalidate_invocation_typeadapter() + + # Also remove any output classes from this pack's modules + outputs_to_remove = {out for out in cls._output_classes if out.__module__.split(".")[0] == node_pack} + for out in outputs_to_remove: + cls._output_classes.discard(out) + + if outputs_to_remove: + cls.invalidate_output_typeadapter() + + return removed_types + @classmethod def get_invocation_classes(cls) -> Iterable[type[BaseInvocation]]: """Gets all invocations, respecting the allowlist and denylist.""" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 75c5ad6671f..511d822aeb2 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -3197,6 +3197,8 @@ "queue": "Queue", "upscaling": "Upscaling", "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", + "customNodes": "Nodes", + "customNodesTab": "$t(ui.tabs.customNodes) $t(common.tab)", "gallery": "Gallery" }, "panels": { @@ -3360,5 +3362,35 @@ "description": "Deep dive sessions exploring advanced Invoke features, creative workflows, and community discussions." } } + }, + "customNodes": { + "title": "Custom Nodes", + "installTitle": "Install Node Pack", + "gitUrl": "Git Repository URL", + "gitUrlLabel": "Repository URL", + "gitUrlPlaceholder": "https://github.com/user/node-pack.git", + "install": "Install", + "installing": "Installing", + "installSuccess": "Node pack installed", + "installFailed": "Installation failed", + "installError": "An unexpected error occurred during installation.", + "securityWarning": "Custom nodes execute code on your system. Only install node packs from authors you trust. Malicious nodes could harm your system or compromise your data.", + "installDescription": "Clones the repository into your nodes directory. Workflow files (.json) are imported into your library. Python dependencies (requirements.txt or pyproject.toml) are NOT installed automatically — follow the node pack's documentation to install them manually.", + "dependenciesRequiredTitle": "Manual dependency install required", + "dependenciesRequiredDescription": "'{{name}}' includes a {{file}}. Follow the node pack's documentation to install its Python dependencies before using its nodes.", + "uninstall": "Uninstall", + "reload": "Reload", + "reloading": "Reloading", + "noNodePacks": "No custom node packs installed.", + "scanFolder": "Scan Folder", + "scanFolderDescription": "Node packs placed in the nodes directory are automatically detected at startup. Use the Reload button to detect newly added packs without restarting.", + "nodesDirectory": "Nodes directory", + "installQueue": "Install Log", + "queueEmpty": "No recent install activity.", + "name": "Name", + "message": "Message", + "nodeCount_one": "{{count}} node", + "nodeCount_other": "{{count}} nodes", + "uninstalled": "Uninstalled" } } diff --git a/invokeai/frontend/web/src/features/customNodes/CustomNodesInstallLog.tsx b/invokeai/frontend/web/src/features/customNodes/CustomNodesInstallLog.tsx new file mode 100644 index 00000000000..d8e53f45cc8 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/CustomNodesInstallLog.tsx @@ -0,0 +1,130 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Badge, Box, Button, Flex, Heading, Table, Tbody, Td, Text, Th, Thead, Tr } from '@invoke-ai/ui-library'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import type { TFunction } from 'i18next'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiBroomBold } from 'react-icons/pi'; + +import type { InstallLogEntry } from './useCustomNodesInstallLog'; +import { useCustomNodesInstallLog } from './useCustomNodesInstallLog'; + +const tableSx: SystemStyleObject = { + '& tbody tr:nth-of-type(odd)': { + backgroundColor: 'rgba(255, 255, 255, 0.04)', + }, + '& tbody tr:nth-of-type(even)': { + backgroundColor: 'transparent', + }, + 'td, th': { + borderColor: 'base.700', + }, + th: { + position: 'sticky', + top: 0, + zIndex: 1, + backgroundColor: 'base.800', + py: 2, + }, + 'th:first-of-type': { + borderTopLeftRadius: 'base', + }, + 'th:last-of-type': { + borderTopRightRadius: 'base', + }, + 'tr:last-of-type td:first-of-type': { + borderBottomLeftRadius: 'base', + }, + 'tr:last-of-type td:last-of-type': { + borderBottomRightRadius: 'base', + }, +}; + +const getStatusColor = (status: InstallLogEntry['status']) => { + switch (status) { + case 'installing': + return 'invokeBlue'; + case 'completed': + return 'invokeGreen'; + case 'error': + return 'error'; + case 'uninstalled': + return 'invokeYellow'; + default: + return 'base'; + } +}; + +const getStatusLabel = (status: InstallLogEntry['status'], t: TFunction) => { + switch (status) { + case 'installing': + return t('customNodes.installing'); + case 'completed': + return t('queue.completed'); + case 'error': + return t('common.error'); + case 'uninstalled': + return t('customNodes.uninstalled'); + default: + return status; + } +}; + +export const CustomNodesInstallLog = memo(() => { + const { t } = useTranslation(); + const { log, clearLog } = useCustomNodesInstallLog(); + + return ( + + + {t('customNodes.installQueue')} + + + + + + + + + + + + + + + {log.length === 0 ? ( + + + + ) : ( + log.map((entry) => ( + + + + + + )) + )} + +
{t('customNodes.name')}{t('queue.status')}{t('customNodes.message')}
+ {t('customNodes.queueEmpty')} +
+ + {entry.name} + + + {getStatusLabel(entry.status, t)} + + + {entry.message} + +
+
+
+
+ ); +}); + +CustomNodesInstallLog.displayName = 'CustomNodesInstallLog'; diff --git a/invokeai/frontend/web/src/features/customNodes/CustomNodesInstallPane.tsx b/invokeai/frontend/web/src/features/customNodes/CustomNodesInstallPane.tsx new file mode 100644 index 00000000000..f1b488c0a4e --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/CustomNodesInstallPane.tsx @@ -0,0 +1,66 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Divider, Flex, Heading, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFolderOpenBold, PiLinkSimpleBold } from 'react-icons/pi'; + +import { CustomNodesInstallLog } from './CustomNodesInstallLog'; +import { InstallFromGitForm } from './InstallFromGitForm'; +import { ScanNodesForm } from './ScanNodesForm'; + +const paneSx: SystemStyleObject = { + layerStyle: 'first', + p: 4, + borderRadius: 'base', + w: { + base: '50%', + lg: '75%', + '2xl': '85%', + }, + h: 'full', + minWidth: '300px', + overflow: 'auto', +}; + +const installTabSx: SystemStyleObject = { + display: 'flex', + gap: 2, + px: 2, +}; + +export const CustomNodesInstallPane = memo(() => { + const { t } = useTranslation(); + const [tabIndex, setTabIndex] = useState(0); + + return ( + + {t('customNodes.installTitle')} + + + + + {t('customNodes.gitUrl')} + + + + {t('customNodes.scanFolder')} + + + + + + + + + + + + + + + + + ); +}); + +CustomNodesInstallPane.displayName = 'CustomNodesInstallPane'; diff --git a/invokeai/frontend/web/src/features/customNodes/CustomNodesList.tsx b/invokeai/frontend/web/src/features/customNodes/CustomNodesList.tsx new file mode 100644 index 00000000000..e7d1f518be0 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/CustomNodesList.tsx @@ -0,0 +1,109 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Badge, Button, Flex, Heading, Spinner, Text } from '@invoke-ai/ui-library'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowClockwiseBold } from 'react-icons/pi'; +import { + useListCustomNodePacksQuery, + useReloadCustomNodesMutation, + useUninstallCustomNodePackMutation, +} from 'services/api/endpoints/customNodes'; + +const listSx: SystemStyleObject = { + flexDir: 'column', + p: 4, + gap: 4, + borderRadius: 'base', + w: '50%', + minWidth: '360px', + h: 'full', +}; + +type NodePackInfo = { + name: string; + path: string; + node_count: number; + node_types: string[]; +}; + +const NodePackItem = memo(({ pack }: { pack: NodePackInfo }) => { + const { t } = useTranslation(); + const [uninstallPack] = useUninstallCustomNodePackMutation(); + + const handleUninstall = useCallback(() => { + uninstallPack(pack.name); + }, [uninstallPack, pack.name]); + + return ( + + + {pack.name} + + + + {t('customNodes.nodeCount', { count: pack.node_count })} + {pack.node_types.map((nodeType) => ( + + {nodeType} + + ))} + + + {pack.path} + + + ); +}); + +NodePackItem.displayName = 'NodePackItem'; + +export const CustomNodesList = memo(() => { + const { t } = useTranslation(); + const { data, isLoading } = useListCustomNodePacksQuery(); + const [reloadNodes, { isLoading: isReloading }] = useReloadCustomNodesMutation(); + + const handleReload = useCallback(() => { + reloadNodes(); + }, [reloadNodes]); + + return ( + + + + {t('customNodes.title')} + + + + + + {isLoading && ( + + + + )} + + {data && data.node_packs.length === 0 && ( + + {t('customNodes.noNodePacks')} + + )} + + {data?.node_packs.map((pack) => ( + + ))} + + + ); +}); + +CustomNodesList.displayName = 'CustomNodesList'; diff --git a/invokeai/frontend/web/src/features/customNodes/InstallFromGitForm.tsx b/invokeai/frontend/web/src/features/customNodes/InstallFromGitForm.tsx new file mode 100644 index 00000000000..1c1e9f5bf13 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/InstallFromGitForm.tsx @@ -0,0 +1,99 @@ +import { + Alert, + AlertDescription, + AlertIcon, + Button, + Flex, + FormControl, + FormHelperText, + FormLabel, + Input, +} from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import type { ChangeEvent, KeyboardEvent } from 'react'; +import { memo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useInstallCustomNodePackMutation } from 'services/api/endpoints/customNodes'; + +import { useCustomNodesInstallLog } from './useCustomNodesInstallLog'; + +export const InstallFromGitForm = memo(() => { + const { t } = useTranslation(); + const [source, setSource] = useState(''); + const [installPack, { isLoading }] = useInstallCustomNodePackMutation(); + const { addLogEntry } = useCustomNodesInstallLog(); + + const handleInstall = useCallback(async () => { + if (!source.trim()) { + return; + } + + const trimmedSource = source.trim(); + addLogEntry({ name: trimmedSource, status: 'installing' }); + + try { + const result = await installPack({ source: trimmedSource }).unwrap(); + if (result.success) { + addLogEntry({ name: result.name, status: 'completed', message: result.message }); + setSource(''); + if (result.requires_dependencies) { + toast({ + id: `custom-nodes-deps-${result.name}`, + title: t('customNodes.dependenciesRequiredTitle'), + description: t('customNodes.dependenciesRequiredDescription', { + name: result.name, + file: result.dependency_file ?? 'requirements.txt', + }), + status: 'warning', + duration: null, + isClosable: true, + }); + } + } else { + addLogEntry({ name: result.name, status: 'error', message: result.message }); + } + } catch { + addLogEntry({ name: trimmedSource, status: 'error', message: t('customNodes.installError') }); + } + }, [source, installPack, addLogEntry, t]); + + const handleSourceChange = useCallback((e: ChangeEvent) => { + setSource(e.target.value); + }, []); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + handleInstall(); + } + }, + [handleInstall] + ); + + return ( + + + + {t('customNodes.securityWarning')} + + + + {t('customNodes.gitUrlLabel')} + + + + + {t('customNodes.installDescription')} + + + ); +}); + +InstallFromGitForm.displayName = 'InstallFromGitForm'; diff --git a/invokeai/frontend/web/src/features/customNodes/ScanNodesForm.tsx b/invokeai/frontend/web/src/features/customNodes/ScanNodesForm.tsx new file mode 100644 index 00000000000..e53b64f2514 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/ScanNodesForm.tsx @@ -0,0 +1,24 @@ +import { Flex, Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useListCustomNodePacksQuery } from 'services/api/endpoints/customNodes'; + +export const ScanNodesForm = memo(() => { + const { t } = useTranslation(); + const { data } = useListCustomNodePacksQuery(); + + return ( + + + {t('customNodes.scanFolderDescription')} + + {data?.custom_nodes_path && ( + + {t('customNodes.nodesDirectory')}: {data.custom_nodes_path} + + )} + + ); +}); + +ScanNodesForm.displayName = 'ScanNodesForm'; diff --git a/invokeai/frontend/web/src/features/customNodes/useCustomNodesInstallLog.test.ts b/invokeai/frontend/web/src/features/customNodes/useCustomNodesInstallLog.test.ts new file mode 100644 index 00000000000..6e485e19dcb --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/useCustomNodesInstallLog.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { _resetIdCounter, $installLog, addInstallLogEntry, clearInstallLog } from './useCustomNodesInstallLog'; + +describe('Install Log Store', () => { + beforeEach(() => { + clearInstallLog(); + _resetIdCounter(); + }); + + it('starts with an empty log', () => { + expect($installLog.get()).toEqual([]); + }); + + it('adds an entry to the log', () => { + addInstallLogEntry({ name: 'test-pack', status: 'installing' }); + const log = $installLog.get(); + expect(log).toHaveLength(1); + expect(log[0]!.name).toBe('test-pack'); + expect(log[0]!.status).toBe('installing'); + expect(log[0]!.id).toBe('0'); + }); + + it('assigns incrementing IDs', () => { + addInstallLogEntry({ name: 'pack-1', status: 'installing' }); + addInstallLogEntry({ name: 'pack-2', status: 'completed' }); + const log = $installLog.get(); + // Newest first + expect(log[0]!.id).toBe('1'); + expect(log[1]!.id).toBe('0'); + }); + + it('prepends new entries (newest first)', () => { + addInstallLogEntry({ name: 'first', status: 'installing' }); + addInstallLogEntry({ name: 'second', status: 'completed' }); + addInstallLogEntry({ name: 'third', status: 'error' }); + const log = $installLog.get(); + expect(log[0]!.name).toBe('third'); + expect(log[1]!.name).toBe('second'); + expect(log[2]!.name).toBe('first'); + }); + + it('includes a timestamp', () => { + const before = Date.now(); + addInstallLogEntry({ name: 'pack', status: 'installing' }); + const after = Date.now(); + const entry = $installLog.get()[0]!; + expect(entry.timestamp).toBeGreaterThanOrEqual(before); + expect(entry.timestamp).toBeLessThanOrEqual(after); + }); + + it('preserves the message field', () => { + addInstallLogEntry({ name: 'pack', status: 'error', message: 'Something went wrong' }); + expect($installLog.get()[0]!.message).toBe('Something went wrong'); + }); + + it('allows message to be undefined', () => { + addInstallLogEntry({ name: 'pack', status: 'completed' }); + expect($installLog.get()[0]!.message).toBeUndefined(); + }); + + it('clears the log', () => { + addInstallLogEntry({ name: 'pack-1', status: 'installing' }); + addInstallLogEntry({ name: 'pack-2', status: 'completed' }); + expect($installLog.get()).toHaveLength(2); + + clearInstallLog(); + expect($installLog.get()).toEqual([]); + }); + + it('supports all status types', () => { + const statuses = ['installing', 'completed', 'error', 'uninstalled'] as const; + for (const status of statuses) { + addInstallLogEntry({ name: `pack-${status}`, status }); + } + const log = $installLog.get(); + expect(log).toHaveLength(4); + expect(log.map((e) => e.status).sort()).toEqual(['completed', 'error', 'installing', 'uninstalled']); + }); + + it('returns the created entry', () => { + const entry = addInstallLogEntry({ name: 'my-pack', status: 'installing' }); + expect(entry.name).toBe('my-pack'); + expect(entry.id).toBeDefined(); + expect(entry.timestamp).toBeDefined(); + }); +}); diff --git a/invokeai/frontend/web/src/features/customNodes/useCustomNodesInstallLog.ts b/invokeai/frontend/web/src/features/customNodes/useCustomNodesInstallLog.ts new file mode 100644 index 00000000000..65a37edb133 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/useCustomNodesInstallLog.ts @@ -0,0 +1,46 @@ +import { useStore } from '@nanostores/react'; +import { atom } from 'nanostores'; +import { useCallback } from 'react'; + +export type InstallLogEntry = { + id: string; + name: string; + status: 'installing' | 'completed' | 'error' | 'uninstalled'; + message?: string; + timestamp: number; +}; + +export const $installLog = atom([]); + +let nextId = 0; + +/** + * Resets the internal ID counter. Only for testing. + */ +export const _resetIdCounter = () => { + nextId = 0; +}; + +export const addInstallLogEntry = (entry: Omit): InstallLogEntry => { + const newEntry: InstallLogEntry = { + ...entry, + id: String(nextId++), + timestamp: Date.now(), + }; + $installLog.set([newEntry, ...$installLog.get()]); + return newEntry; +}; + +export const clearInstallLog = () => { + $installLog.set([]); +}; + +export const useCustomNodesInstallLog = () => { + const log = useStore($installLog); + + const addLogEntry = useCallback((entry: Omit) => { + addInstallLogEntry(entry); + }, []); + + return { log, addLogEntry, clearLog: clearInstallLog }; +}; diff --git a/invokeai/frontend/web/src/features/customNodes/useIsCustomNodesEnabled.test.ts b/invokeai/frontend/web/src/features/customNodes/useIsCustomNodesEnabled.test.ts new file mode 100644 index 00000000000..5f9a882de14 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/useIsCustomNodesEnabled.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; + +import { deriveCustomNodesPermission, getIsCustomNodesEnabled } from './useIsCustomNodesEnabled'; + +describe('getIsCustomNodesEnabled', () => { + it('returns true in single-user mode regardless of admin status', () => { + expect(getIsCustomNodesEnabled(false, false)).toBe(true); + expect(getIsCustomNodesEnabled(false, true)).toBe(true); + expect(getIsCustomNodesEnabled(false, undefined)).toBe(true); + }); + + it('returns true in multiuser mode for admin users', () => { + expect(getIsCustomNodesEnabled(true, true)).toBe(true); + }); + + it('returns false in multiuser mode for non-admin users', () => { + expect(getIsCustomNodesEnabled(true, false)).toBe(false); + }); + + it('returns false in multiuser mode when user is not yet loaded', () => { + expect(getIsCustomNodesEnabled(true, undefined)).toBe(false); + }); +}); + +/** + * Permission-state tests. + * + * These call deriveCustomNodesPermission directly — the same function the hook + * uses internally — so the test and the hook can never drift. The contract is: + * loading (setupStatus undefined) -> { isKnown: false, isAllowed: false } + * resolved -> { isKnown: true, isAllowed: getIsCustomNodesEnabled(...) } + * + * Consumers read this state: + * VerticalNavBar shows tab only when isAllowed + * AppContent redirects only when isKnown && !isAllowed + */ +describe('deriveCustomNodesPermission', () => { + it('returns unknown/denied while setupStatus is still loading', () => { + expect(deriveCustomNodesPermission(undefined, undefined)).toEqual({ isKnown: false, isAllowed: false }); + expect(deriveCustomNodesPermission(undefined, null)).toEqual({ isKnown: false, isAllowed: false }); + expect(deriveCustomNodesPermission(undefined, { is_admin: false })).toEqual({ isKnown: false, isAllowed: false }); + expect(deriveCustomNodesPermission(undefined, { is_admin: true })).toEqual({ isKnown: false, isAllowed: false }); + }); + + it('resolves to known/allowed in single-user mode regardless of user', () => { + expect(deriveCustomNodesPermission({ multiuser_enabled: false }, undefined)).toEqual({ + isKnown: true, + isAllowed: true, + }); + expect(deriveCustomNodesPermission({ multiuser_enabled: false }, null)).toEqual({ + isKnown: true, + isAllowed: true, + }); + expect(deriveCustomNodesPermission({ multiuser_enabled: false }, { is_admin: false })).toEqual({ + isKnown: true, + isAllowed: true, + }); + }); + + it('resolves to known/allowed for multiuser admin', () => { + expect(deriveCustomNodesPermission({ multiuser_enabled: true }, { is_admin: true })).toEqual({ + isKnown: true, + isAllowed: true, + }); + }); + + it('resolves to known/denied for multiuser non-admin or missing user', () => { + expect(deriveCustomNodesPermission({ multiuser_enabled: true }, { is_admin: false })).toEqual({ + isKnown: true, + isAllowed: false, + }); + expect(deriveCustomNodesPermission({ multiuser_enabled: true }, null)).toEqual({ + isKnown: true, + isAllowed: false, + }); + expect(deriveCustomNodesPermission({ multiuser_enabled: true }, undefined)).toEqual({ + isKnown: true, + isAllowed: false, + }); + }); + + it('non-admin multiuser user never sees isAllowed=true in any state', () => { + // Regression: during loading AND after resolution, a non-admin in multiuser + // mode must never get isAllowed=true, so the tab never renders content. + expect(deriveCustomNodesPermission(undefined, { is_admin: false }).isAllowed).toBe(false); + expect(deriveCustomNodesPermission({ multiuser_enabled: true }, { is_admin: false }).isAllowed).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/features/customNodes/useIsCustomNodesEnabled.ts b/invokeai/frontend/web/src/features/customNodes/useIsCustomNodesEnabled.ts new file mode 100644 index 00000000000..926e51d5474 --- /dev/null +++ b/invokeai/frontend/web/src/features/customNodes/useIsCustomNodesEnabled.ts @@ -0,0 +1,65 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; +import { useMemo } from 'react'; +import { useGetSetupStatusQuery } from 'services/api/endpoints/auth'; + +/** + * Pure decision function: determines whether custom node management is enabled. + * + * Returns true if: + * - Multiuser mode is disabled (single-user mode = always admin) + * - Multiuser mode is enabled AND user is an admin + * + * Returns false if: + * - Multiuser mode is enabled AND user is not an admin + */ +export const getIsCustomNodesEnabled = (multiuserEnabled: boolean, isAdmin: boolean | undefined): boolean => { + if (!multiuserEnabled) { + return true; + } + return isAdmin ?? false; +}; + +type CustomNodesPermission = { + /** Whether setup status has loaded and a permission decision can be made. */ + isKnown: boolean; + /** Whether the current user is allowed to access custom node management. + * Only meaningful when isKnown is true; defaults to false while loading. */ + isAllowed: boolean; +}; + +/** Minimal shapes the derivation needs — matches the runtime types from auth slice + RTK Query. */ +type SetupStatusLike = { multiuser_enabled: boolean } | undefined; +type UserLike = { is_admin: boolean } | null | undefined; + +/** + * Pure derivation of the permission state from the raw inputs the hook reads. + * Both the hook and the tests consume this directly so the two can never drift. + * + * - loading (setupStatus undefined) -> { isKnown: false, isAllowed: false } + * - resolved (setupStatus defined) -> { isKnown: true, isAllowed: getIsCustomNodesEnabled(...) } + */ +export const deriveCustomNodesPermission = (setupStatus: SetupStatusLike, user: UserLike): CustomNodesPermission => { + if (!setupStatus) { + return { isKnown: false, isAllowed: false }; + } + return { isKnown: true, isAllowed: getIsCustomNodesEnabled(setupStatus.multiuser_enabled, user?.is_admin) }; +}; + +/** + * Hook that returns two-state permission info for custom node management. + * + * - `isKnown`: false while setupStatus is still loading; true once resolved. + * - `isAllowed`: the actual permission decision (only trustworthy when isKnown is true). + * + * Consumers use these separately: + * - **VerticalNavBar**: show the tab only when `isAllowed` (conservative — hidden while loading). + * - **AppContent redirect**: only redirect away once `isKnown && !isAllowed` (avoids kicking + * a legitimate single-user session off a persisted customNodes tab before the query resolves). + */ +export const useIsCustomNodesEnabled = (): CustomNodesPermission => { + const user = useAppSelector(selectCurrentUser); + const { data: setupStatus } = useGetSetupStatusQuery(); + + return useMemo(() => deriveCustomNodesPermission(setupStatus, user), [setupStatus, user]); +}; diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index d3ab650d7b8..49e89323891 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -5,8 +5,10 @@ import { Flex } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import Loading from 'common/components/Loading/Loading'; +import { useIsCustomNodesEnabled } from 'features/customNodes/useIsCustomNodesEnabled'; import { VerticalNavBar } from 'features/ui/components/VerticalNavBar'; import { CanvasTabAutoLayout } from 'features/ui/layouts/canvas-tab-auto-layout'; +import { CustomNodesTabAutoLayout } from 'features/ui/layouts/customnodes-tab-auto-layout'; import { GenerateTabAutoLayout } from 'features/ui/layouts/generate-tab-auto-layout'; import { ModelsTabAutoLayout } from 'features/ui/layouts/models-tab-auto-layout'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -14,7 +16,7 @@ import { QueueTabAutoLayout } from 'features/ui/layouts/queue-tab-auto-layout'; import { UpscalingTabAutoLayout } from 'features/ui/layouts/upscaling-tab-auto-layout'; import { WorkflowsTabAutoLayout } from 'features/ui/layouts/workflows-tab-auto-layout'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; export const AppContent = memo(() => { return ( @@ -28,6 +30,18 @@ AppContent.displayName = 'AppContent'; const TabContent = memo(() => { const tab = useAppSelector(selectActiveTab); + const { isKnown: isCustomNodesKnown, isAllowed: isCustomNodesAllowed } = useIsCustomNodesEnabled(); + + // Redirect away from customNodes only once we *know* the user is denied. + // While setup status is still loading (isKnown=false), we do nothing — + // the tab content is already suppressed (isAllowed=false), and we avoid + // kicking a legitimate single-user session off a persisted tab before + // the query resolves. + useEffect(() => { + if (tab === 'customNodes' && isCustomNodesKnown && !isCustomNodesAllowed) { + navigationApi.switchToTab('generate'); + } + }, [tab, isCustomNodesKnown, isCustomNodesAllowed]); return ( @@ -36,6 +50,7 @@ const TabContent = memo(() => { {tab === 'upscaling' && } {tab === 'workflows' && } {tab === 'models' && } + {tab === 'customNodes' && isCustomNodesAllowed && } {tab === 'queue' && } diff --git a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx index be3fa3e6898..aee4c7bca21 100644 --- a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx +++ b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx @@ -1,5 +1,6 @@ import { Divider, Flex, Spacer } from '@invoke-ai/ui-library'; import { UserMenu } from 'features/auth/components/UserMenu'; +import { useIsCustomNodesEnabled } from 'features/customNodes/useIsCustomNodesEnabled'; import InvokeAILogoComponent from 'features/system/components/InvokeAILogoComponent'; import SettingsMenu from 'features/system/components/SettingsModal/SettingsMenu'; import StatusIndicator from 'features/system/components/StatusIndicator'; @@ -8,6 +9,7 @@ import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiBoundingBoxBold, + PiCircuitryBold, PiCubeBold, PiFlowArrowBold, PiFrameCornersBold, @@ -20,6 +22,7 @@ import { TabButton } from './TabButton'; export const VerticalNavBar = memo(() => { const { t } = useTranslation(); + const { isAllowed: isCustomNodesAllowed } = useIsCustomNodesEnabled(); return ( @@ -36,6 +39,9 @@ export const VerticalNavBar = memo(() => { } label={t('ui.tabs.models')} /> + {isCustomNodesAllowed && ( + } label={t('ui.tabs.customNodes')} /> + )} } label={t('ui.tabs.queue')} /> diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/CustomNodesManagerTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/CustomNodesManagerTab.tsx new file mode 100644 index 00000000000..ede4991653a --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/tabs/CustomNodesManagerTab.tsx @@ -0,0 +1,15 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { CustomNodesInstallPane } from 'features/customNodes/CustomNodesInstallPane'; +import { CustomNodesList } from 'features/customNodes/CustomNodesList'; +import { memo } from 'react'; + +const CustomNodesManagerTab = () => { + return ( + + + + + ); +}; + +export default memo(CustomNodesManagerTab); diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx index fe7588aaeda..68165b65c07 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next'; import type { IconType } from 'react-icons'; import { PiBoundingBoxBold, + PiCircuitryBold, PiCubeBold, PiFlowArrowBold, PiFrameCornersBold, @@ -25,6 +26,7 @@ const TAB_ICONS: Record = { upscaling: PiFrameCornersBold, workflows: PiFlowArrowBold, models: PiCubeBold, + customNodes: PiCircuitryBold, queue: PiQueueBold, }; diff --git a/invokeai/frontend/web/src/features/ui/layouts/customnodes-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/customnodes-tab-auto-layout.tsx new file mode 100644 index 00000000000..dae23594309 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/customnodes-tab-auto-layout.tsx @@ -0,0 +1,49 @@ +import type { GridviewApi, IGridviewReactProps } from 'dockview'; +import { GridviewReact, LayoutPriority, Orientation } from 'dockview'; +import CustomNodesManagerTab from 'features/ui/components/tabs/CustomNodesManagerTab'; +import type { RootLayoutGridviewComponents } from 'features/ui/layouts/auto-layout-context'; +import { AutoLayoutProvider } from 'features/ui/layouts/auto-layout-context'; +import type { TabName } from 'features/ui/store/uiTypes'; +import { memo, useCallback, useEffect } from 'react'; + +import { navigationApi } from './navigation-api'; +import { CUSTOM_NODES_PANEL_ID } from './shared'; + +const rootPanelComponents: RootLayoutGridviewComponents = { + [CUSTOM_NODES_PANEL_ID]: CustomNodesManagerTab, +}; + +const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'root', api, () => { + api.addPanel({ + id: CUSTOM_NODES_PANEL_ID, + component: CUSTOM_NODES_PANEL_ID, + priority: LayoutPriority.High, + }); + }); +}; + +export const CustomNodesTabAutoLayout = memo(() => { + const onReady = useCallback(({ api }) => { + initializeRootPanelLayout('customNodes', api); + }, []); + + useEffect( + () => () => { + navigationApi.unregisterTab('customNodes'); + }, + [] + ); + + return ( + + + + ); +}); +CustomNodesTabAutoLayout.displayName = 'CustomNodesTabAutoLayout'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/shared.ts b/invokeai/frontend/web/src/features/ui/layouts/shared.ts index efb17037ee8..191ccfd82dd 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/shared.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/shared.ts @@ -13,6 +13,7 @@ export const LAYERS_PANEL_ID = 'layers'; export const SETTINGS_PANEL_ID = 'settings'; export const MODELS_PANEL_ID = 'models'; +export const CUSTOM_NODES_PANEL_ID = 'customNodes'; export const QUEUE_PANEL_ID = 'queue'; export const DOCKVIEW_TAB_ID = 'tab-default'; diff --git a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts index 4e6e851ed1f..72d57b86c60 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts @@ -1,7 +1,7 @@ import { isPlainObject } from 'es-toolkit'; import { z } from 'zod'; -const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'queue']); +const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'customNodes', 'queue']); export type TabName = z.infer; const zPartialDimensions = z.object({ diff --git a/invokeai/frontend/web/src/services/api/endpoints/customNodes.ts b/invokeai/frontend/web/src/services/api/endpoints/customNodes.ts new file mode 100644 index 00000000000..ce35d19bd11 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/customNodes.ts @@ -0,0 +1,71 @@ +import { api, buildV2Url } from '..'; + +type NodePackInfo = { + name: string; + path: string; + node_count: number; + node_types: string[]; +}; + +type NodePackListResponse = { + node_packs: NodePackInfo[]; + custom_nodes_path: string; +}; + +type InstallNodePackResponse = { + name: string; + success: boolean; + message: string; + workflows_imported: number; + requires_dependencies: boolean; + dependency_file: string | null; +}; + +type UninstallNodePackResponse = { + name: string; + success: boolean; + message: string; +}; + +const buildCustomNodesUrl = (path: string = '') => buildV2Url(`custom_nodes/${path}`); + +const customNodesApi = api.injectEndpoints({ + endpoints: (build) => ({ + listCustomNodePacks: build.query({ + query: () => ({ + url: buildCustomNodesUrl(), + method: 'GET', + }), + providesTags: ['CustomNodePacks'], + }), + installCustomNodePack: build.mutation({ + query: (body) => ({ + url: buildCustomNodesUrl('install'), + method: 'POST', + body, + }), + invalidatesTags: ['CustomNodePacks', 'Workflow', 'Schema'], + }), + uninstallCustomNodePack: build.mutation({ + query: (packName) => ({ + url: buildCustomNodesUrl(packName), + method: 'DELETE', + }), + invalidatesTags: ['CustomNodePacks', 'Workflow', 'Schema'], + }), + reloadCustomNodes: build.mutation<{ status: string }, void>({ + query: () => ({ + url: buildCustomNodesUrl('reload'), + method: 'POST', + }), + invalidatesTags: ['CustomNodePacks', 'Schema'], + }), + }), +}); + +export const { + useListCustomNodePacksQuery, + useInstallCustomNodePackMutation, + useUninstallCustomNodePackMutation, + useReloadCustomNodesMutation, +} = customNodesApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 85a5d320a1a..ac84376f0cd 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -60,6 +60,7 @@ const tagTypes = [ 'FetchOnReconnect', 'ClientState', 'UserList', + 'CustomNodePacks', ] as const; export type ApiTagDescription = TagDescription<(typeof tagTypes)[number]>; export const LIST_TAG = 'LIST'; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 4b8e4da95a5..d5904b8ddf0 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -2575,6 +2575,95 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v2/custom_nodes/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Custom Node Packs + * @description Lists all installed custom node packs. + * + * Admin-only: the response includes absolute filesystem paths, and non-admins have no + * legitimate use for pack management data (install/uninstall/reload are also admin-only). + */ + get: operations["list_custom_node_packs"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/custom_nodes/install": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Install Custom Node Pack + * @description Installs a custom node pack from a git URL by cloning it into the nodes directory. + */ + post: operations["install_custom_node_pack"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/custom_nodes/{pack_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Uninstall Custom Node Pack + * @description Uninstalls a custom node pack by removing its directory. + * + * Note: A restart is required for the node removal to take full effect. + * Installed nodes from the pack will remain registered until restart. + */ + delete: operations["uninstall_custom_node_pack"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v2/custom_nodes/reload": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reload Custom Nodes + * @description Triggers a reload of all custom nodes. + * + * This re-scans the nodes directory and loads any new node packs. + * Already loaded packs are skipped. + */ + post: operations["reload_custom_nodes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; }; export type webhooks = Record; export type components = { @@ -14751,6 +14840,55 @@ export type components = { */ ui_model_provider_id: string[] | null; }; + /** + * InstallNodePackRequest + * @description Request to install a node pack from a git URL. + */ + InstallNodePackRequest: { + /** + * Source + * @description Git URL of the node pack to install. + */ + source: string; + }; + /** + * InstallNodePackResponse + * @description Response after installing a node pack. + */ + InstallNodePackResponse: { + /** + * Name + * @description The name of the installed node pack. + */ + name: string; + /** + * Success + * @description Whether the installation was successful. + */ + success: boolean; + /** + * Message + * @description Status message. + */ + message: string; + /** + * Workflows Imported + * @description Number of workflows imported from the pack. + * @default 0 + */ + workflows_imported?: number; + /** + * Requires Dependencies + * @description Whether the pack ships a dependency manifest (requirements.txt or pyproject.toml) that the user must install manually following the pack's documentation. + * @default false + */ + requires_dependencies?: boolean; + /** + * Dependency File + * @description Name of the detected dependency manifest file, if any. + */ + dependency_file?: string | null; + }; /** * InstallStatus * @description State of an install job running in the background. @@ -23115,6 +23253,48 @@ export type components = { */ value: string | number | components["schemas"]["ImageField"]; }; + /** + * NodePackInfo + * @description Information about an installed node pack. + */ + NodePackInfo: { + /** + * Name + * @description The name of the node pack. + */ + name: string; + /** + * Path + * @description The path to the node pack directory. + */ + path: string; + /** + * Node Count + * @description The number of nodes in the pack. + */ + node_count: number; + /** + * Node Types + * @description The invocation types provided by this node pack. + */ + node_types: string[]; + }; + /** + * NodePackListResponse + * @description Response for listing installed node packs. + */ + NodePackListResponse: { + /** + * Node Packs + * @description List of installed node packs. + */ + node_packs: components["schemas"]["NodePackInfo"][]; + /** + * Custom Nodes Path + * @description The configured custom nodes directory path. + */ + custom_nodes_path: string; + }; /** * Create Latent Noise * @description Generates latent noise. @@ -29075,6 +29255,27 @@ export type components = { */ token: string; }; + /** + * UninstallNodePackResponse + * @description Response after uninstalling a node pack. + */ + UninstallNodePackResponse: { + /** + * Name + * @description The name of the uninstalled node pack. + */ + name: string; + /** + * Success + * @description Whether the uninstall was successful. + */ + success: boolean; + /** + * Message + * @description Status message. + */ + message: string; + }; /** * Unknown_Config * @description Model config for unknown models, used as a fallback when we cannot positively identify a model. @@ -36331,4 +36532,110 @@ export interface operations { }; }; }; + list_custom_node_packs: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NodePackListResponse"]; + }; + }; + }; + }; + install_custom_node_pack: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["InstallNodePackRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstallNodePackResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + uninstall_custom_node_pack: { + parameters: { + query?: never; + header?: never; + path: { + pack_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UninstallNodePackResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reload_custom_nodes: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + }; + }; } diff --git a/tests/app/routers/test_custom_nodes.py b/tests/app/routers/test_custom_nodes.py new file mode 100644 index 00000000000..0cb6580e167 --- /dev/null +++ b/tests/app/routers/test_custom_nodes.py @@ -0,0 +1,499 @@ +"""Tests for the custom nodes router.""" + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +from invokeai.app.api.routers.custom_nodes import ( + PACK_MANIFEST_FILENAME, + _get_installed_packs, + _import_workflows_from_pack, + _load_node_pack, + _purge_pack_modules, + _read_pack_manifest, + _remove_workflows_by_ids, + _write_pack_manifest, +) +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + InvocationRegistry, +) + + +class TestGetInstalledPacks: + """Tests for _get_installed_packs().""" + + def test_returns_empty_when_dir_not_exists(self, tmp_path: Path) -> None: + nonexistent = tmp_path / "nonexistent" + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=nonexistent): + packs = _get_installed_packs() + assert packs == [] + + def test_returns_empty_when_dir_empty(self, tmp_path: Path) -> None: + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path): + packs = _get_installed_packs() + assert packs == [] + + def test_skips_files(self, tmp_path: Path) -> None: + (tmp_path / "some_file.py").touch() + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path): + packs = _get_installed_packs() + assert packs == [] + + def test_skips_hidden_dirs(self, tmp_path: Path) -> None: + hidden = tmp_path / ".hidden_pack" + hidden.mkdir() + (hidden / "__init__.py").touch() + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path): + packs = _get_installed_packs() + assert packs == [] + + def test_skips_dirs_without_init(self, tmp_path: Path) -> None: + no_init = tmp_path / "no_init_pack" + no_init.mkdir() + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path): + packs = _get_installed_packs() + assert packs == [] + + def test_finds_valid_pack(self, tmp_path: Path) -> None: + pack = tmp_path / "my_pack" + pack.mkdir() + (pack / "__init__.py").touch() + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path): + packs = _get_installed_packs() + assert len(packs) == 1 + assert packs[0].name == "my_pack" + assert packs[0].path == str(pack) + + def test_finds_multiple_packs_sorted(self, tmp_path: Path) -> None: + for name in ["zebra_pack", "alpha_pack", "middle_pack"]: + d = tmp_path / name + d.mkdir() + (d / "__init__.py").touch() + with patch("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", return_value=tmp_path): + packs = _get_installed_packs() + assert len(packs) == 3 + assert [p.name for p in packs] == ["alpha_pack", "middle_pack", "zebra_pack"] + + +class TestImportWorkflowsFromPack: + """Tests for _import_workflows_from_pack().""" + + @staticmethod + def _mock_service_with_id(workflow_id: str = "new-id") -> MagicMock: + """Returns a mock workflow_records service whose create() yields a DTO with the given id.""" + mock_service = MagicMock() + mock_service.create.return_value = MagicMock(workflow_id=workflow_id) + return mock_service + + def test_no_json_files(self, tmp_path: Path) -> None: + (tmp_path / "__init__.py").touch() + (tmp_path / "node.py").write_text("# node code") + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"): + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + assert ids == [] + + def test_skips_non_workflow_json(self, tmp_path: Path) -> None: + # JSON without nodes/edges should be skipped + config = {"setting": "value"} + (tmp_path / "config.json").write_text(json.dumps(config)) + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"): + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + assert ids == [] + + def test_imports_valid_workflow(self, tmp_path: Path) -> None: + workflow = { + "name": "Test Workflow", + "author": "Test", + "description": "A test workflow", + "version": "1.0.0", + "contact": "", + "tags": "test", + "notes": "", + "exposedFields": [], + "meta": {"version": "3.0.0", "category": "user"}, + "nodes": [{"id": "1", "type": "test_node"}], + "edges": [], + } + workflows_dir = tmp_path / "workflows" + workflows_dir.mkdir() + (workflows_dir / "test_workflow.json").write_text(json.dumps(workflow)) + + mock_service = self._mock_service_with_id("wf-new-1") + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + + assert ids == ["wf-new-1"] + mock_service.create.assert_called_once() + # Verify the workflow was tagged + create_kwargs = mock_service.create.call_args.kwargs + assert "node-pack:test_pack" in create_kwargs["workflow"].tags + assert create_kwargs["user_id"] == "admin" + assert create_kwargs["is_public"] is True + + def test_adds_pack_tag_to_existing_tags(self, tmp_path: Path) -> None: + workflow = { + "name": "Tagged Workflow", + "author": "Test", + "description": "", + "version": "1.0.0", + "contact": "", + "tags": "existing, tags", + "notes": "", + "exposedFields": [], + "meta": {"version": "3.0.0", "category": "user"}, + "nodes": [{"id": "1"}], + "edges": [], + } + (tmp_path / "workflow.json").write_text(json.dumps(workflow)) + + mock_service = self._mock_service_with_id() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + ids = _import_workflows_from_pack(tmp_path, "my_pack", owner_user_id="admin") + + assert len(ids) == 1 + created_workflow = mock_service.create.call_args.kwargs["workflow"] + assert "existing, tags" in created_workflow.tags + assert "node-pack:my_pack" in created_workflow.tags + + def test_removes_id_before_import(self, tmp_path: Path) -> None: + workflow = { + "id": "should-be-removed", + "name": "Workflow With ID", + "author": "Test", + "description": "", + "version": "1.0.0", + "contact": "", + "tags": "", + "notes": "", + "exposedFields": [], + "meta": {"version": "3.0.0", "category": "user"}, + "nodes": [], + "edges": [], + } + (tmp_path / "workflow.json").write_text(json.dumps(workflow)) + + mock_service = self._mock_service_with_id() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + + assert len(ids) == 1 + + def test_sets_category_to_user(self, tmp_path: Path) -> None: + workflow = { + "name": "Default-like Workflow", + "author": "Test", + "description": "", + "version": "1.0.0", + "contact": "", + "tags": "", + "notes": "", + "exposedFields": [], + "meta": {"version": "3.0.0", "category": "default"}, + "nodes": [], + "edges": [], + } + (tmp_path / "workflow.json").write_text(json.dumps(workflow)) + + mock_service = self._mock_service_with_id() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + + assert len(ids) == 1 + created_workflow = mock_service.create.call_args.kwargs["workflow"] + assert created_workflow.meta.category.value == "user" + + def test_skips_invalid_json(self, tmp_path: Path) -> None: + (tmp_path / "broken.json").write_text("{invalid json") + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"): + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + assert ids == [] + + def test_finds_workflows_recursively(self, tmp_path: Path) -> None: + workflow = { + "name": "Nested Workflow", + "author": "Test", + "description": "", + "version": "1.0.0", + "contact": "", + "tags": "", + "notes": "", + "exposedFields": [], + "meta": {"version": "3.0.0", "category": "user"}, + "nodes": [{"id": "1"}], + "edges": [], + } + nested = tmp_path / "sub" / "dir" + nested.mkdir(parents=True) + (nested / "deep_workflow.json").write_text(json.dumps(workflow)) + + mock_service = self._mock_service_with_id() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + + assert len(ids) == 1 + + def test_skips_manifest_file(self, tmp_path: Path) -> None: + # A manifest inside the pack must not be mistaken for a workflow during import + (tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": ["wf-old"]})) + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies"): + ids = _import_workflows_from_pack(tmp_path, "test_pack", owner_user_id="admin") + assert ids == [] + + +class TestPackManifest: + """Tests for _write_pack_manifest() and _read_pack_manifest().""" + + def test_write_then_read_roundtrip(self, tmp_path: Path) -> None: + _write_pack_manifest(tmp_path, ["wf-1", "wf-2"]) + assert _read_pack_manifest(tmp_path) == ["wf-1", "wf-2"] + + def test_read_returns_empty_when_manifest_missing(self, tmp_path: Path) -> None: + assert _read_pack_manifest(tmp_path) == [] + + def test_read_returns_empty_when_manifest_malformed(self, tmp_path: Path) -> None: + (tmp_path / PACK_MANIFEST_FILENAME).write_text("{not valid json") + assert _read_pack_manifest(tmp_path) == [] + + def test_read_returns_empty_when_workflow_ids_not_a_list(self, tmp_path: Path) -> None: + (tmp_path / PACK_MANIFEST_FILENAME).write_text(json.dumps({"workflow_ids": "oops"})) + assert _read_pack_manifest(tmp_path) == [] + + +class TestRemoveWorkflowsByIds: + """Tests for _remove_workflows_by_ids().""" + + def test_deletes_only_given_ids(self) -> None: + mock_service = MagicMock() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + count = _remove_workflows_by_ids(["wf-1", "wf-2"], "test_pack") + + assert count == 2 + assert mock_service.delete.call_count == 2 + deleted_ids = [ + call.args[0] if call.args else call.kwargs.get("workflow_id") for call in mock_service.delete.call_args_list + ] + assert deleted_ids == ["wf-1", "wf-2"] + + def test_returns_zero_when_no_ids(self) -> None: + mock_service = MagicMock() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + count = _remove_workflows_by_ids([], "empty_pack") + + assert count == 0 + mock_service.delete.assert_not_called() + + def test_continues_on_individual_delete_error(self) -> None: + # One workflow is already gone; the helper still removes the others + mock_service = MagicMock() + mock_service.delete.side_effect = [Exception("not found"), None] + + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + count = _remove_workflows_by_ids(["wf-gone", "wf-still-here"], "test_pack") + + assert count == 1 + + def test_preserves_user_workflow_with_colliding_tag(self, tmp_path: Path) -> None: + # Regression test for the data-destruction risk the reviewer raised: + # If a user-authored workflow reuses the 'node-pack:' tag, uninstall + # must NOT delete it. The full flow is exercised here: a manifest records + # only the pack's own workflow IDs, and _remove_workflows_by_ids operates + # only on those — so the user's workflow (whose id is NOT in the manifest) + # is never touched. + pack_wf_id = "pack-wf-1" + user_wf_id = "user-owned-wf-with-same-tag" + + _write_pack_manifest(tmp_path, [pack_wf_id]) + manifest_ids = _read_pack_manifest(tmp_path) + + mock_service = MagicMock() + with patch("invokeai.app.api.routers.custom_nodes.ApiDependencies") as mock_deps: + mock_deps.invoker.services.workflow_records = mock_service + _remove_workflows_by_ids(manifest_ids, "test_pack") + + assert mock_service.delete.call_count == 1 + deleted_id = ( + mock_service.delete.call_args.args[0] + if mock_service.delete.call_args.args + else mock_service.delete.call_args.kwargs.get("workflow_id") + ) + assert deleted_id == pack_wf_id + # The user-owned workflow id is never passed to delete() + all_delete_args = [ + (call.args[0] if call.args else call.kwargs.get("workflow_id")) + for call in mock_service.delete.call_args_list + ] + assert user_wf_id not in all_delete_args + + +class TestUnregisterPack: + """Tests for InvocationRegistry.unregister_pack().""" + + def test_unregister_removes_invocations(self) -> None: + # Save original state + original_invocations = InvocationRegistry._invocation_classes.copy() + original_outputs = InvocationRegistry._output_classes.copy() + + try: + # Create a mock invocation class + mock_inv = MagicMock(spec=BaseInvocation) + mock_inv.UIConfig = MagicMock() + mock_inv.UIConfig.node_pack = "test_removable_pack" + mock_inv.get_type.return_value = "test_removable_node" + + InvocationRegistry._invocation_classes.add(mock_inv) + + # Verify it's registered + assert mock_inv in InvocationRegistry._invocation_classes + + # Unregister + removed = InvocationRegistry.unregister_pack("test_removable_pack") + + assert "test_removable_node" in removed + assert mock_inv not in InvocationRegistry._invocation_classes + finally: + # Restore original state + InvocationRegistry._invocation_classes = original_invocations + InvocationRegistry._output_classes = original_outputs + + def test_unregister_returns_empty_for_unknown_pack(self) -> None: + removed = InvocationRegistry.unregister_pack("nonexistent_pack_xyz") + assert removed == [] + + def test_unregister_removes_multiple_invocations(self) -> None: + original_invocations = InvocationRegistry._invocation_classes.copy() + original_outputs = InvocationRegistry._output_classes.copy() + + try: + mock_inv_1 = MagicMock(spec=BaseInvocation) + mock_inv_1.UIConfig = MagicMock() + mock_inv_1.UIConfig.node_pack = "multi_pack" + mock_inv_1.get_type.return_value = "multi_node_1" + + mock_inv_2 = MagicMock(spec=BaseInvocation) + mock_inv_2.UIConfig = MagicMock() + mock_inv_2.UIConfig.node_pack = "multi_pack" + mock_inv_2.get_type.return_value = "multi_node_2" + + mock_inv_other = MagicMock(spec=BaseInvocation) + mock_inv_other.UIConfig = MagicMock() + mock_inv_other.UIConfig.node_pack = "other_pack" + mock_inv_other.get_type.return_value = "other_node" + + InvocationRegistry._invocation_classes.update({mock_inv_1, mock_inv_2, mock_inv_other}) + + removed = InvocationRegistry.unregister_pack("multi_pack") + + assert len(removed) == 2 + assert "multi_node_1" in removed + assert "multi_node_2" in removed + # Other pack's node should remain + assert mock_inv_other in InvocationRegistry._invocation_classes + finally: + InvocationRegistry._invocation_classes = original_invocations + InvocationRegistry._output_classes = original_outputs + + +class TestPurgePackModules: + """Tests for _purge_pack_modules() — clears the pack subtree from sys.modules.""" + + def test_removes_root_module(self) -> None: + sys.modules["purge_test_root"] = MagicMock() + try: + removed = _purge_pack_modules("purge_test_root") + assert "purge_test_root" in removed + assert "purge_test_root" not in sys.modules + finally: + sys.modules.pop("purge_test_root", None) + + def test_removes_submodules(self) -> None: + sys.modules["purge_test_pack"] = MagicMock() + sys.modules["purge_test_pack.nodes"] = MagicMock() + sys.modules["purge_test_pack.utils.helpers"] = MagicMock() + try: + removed = _purge_pack_modules("purge_test_pack") + assert set(removed) == { + "purge_test_pack", + "purge_test_pack.nodes", + "purge_test_pack.utils.helpers", + } + assert "purge_test_pack" not in sys.modules + assert "purge_test_pack.nodes" not in sys.modules + assert "purge_test_pack.utils.helpers" not in sys.modules + finally: + for key in ("purge_test_pack", "purge_test_pack.nodes", "purge_test_pack.utils.helpers"): + sys.modules.pop(key, None) + + def test_does_not_remove_unrelated_modules_with_prefix_collision(self) -> None: + # "foo_pack_extra" must NOT be removed when purging "foo_pack" + sys.modules["foo_pack"] = MagicMock() + sys.modules["foo_pack_extra"] = MagicMock() + sys.modules["foo_pack.sub"] = MagicMock() + try: + removed = _purge_pack_modules("foo_pack") + assert set(removed) == {"foo_pack", "foo_pack.sub"} + assert "foo_pack_extra" in sys.modules + finally: + for key in ("foo_pack", "foo_pack_extra", "foo_pack.sub"): + sys.modules.pop(key, None) + + def test_noop_when_pack_not_loaded(self) -> None: + removed = _purge_pack_modules("never_loaded_pack_xyz") + assert removed == [] + + +class TestUninstallReinstallReloadsSubmodules: + """Regression test for the uninstall -> reinstall cache bug. + + Before the fix, uninstall only cleared sys.modules[pack_name] and left + submodules cached. On reinstall, Python reused the cached submodules, + their @invocation decorators never re-ran, and the pack loaded with + zero registered nodes until a full process restart. + """ + + def test_reinstall_re_executes_submodule(self, tmp_path: Path) -> None: + pack_name = "reinstall_regression_pack" + pack_dir = tmp_path / pack_name + pack_dir.mkdir() + + # __init__.py imports from a submodule — this is the shape that triggered the bug + (pack_dir / "__init__.py").write_text("from .nodes import * # noqa: F401,F403\n") + submodule = pack_dir / "nodes.py" + + # Each import of the submodule must append a marker to this file. + # If the submodule gets reused from sys.modules instead of re-executed, + # the second install won't produce a second marker. + marker_file = tmp_path / "exec_markers.txt" + submodule.write_text( + f"from pathlib import Path\nPath(r'{marker_file.as_posix()}').open('a').write('exec\\n')\n" + ) + + try: + # First install + _load_node_pack(pack_name, pack_dir) + assert pack_name in sys.modules + assert f"{pack_name}.nodes" in sys.modules + assert marker_file.read_text().count("exec") == 1 + + # Simulate uninstall's module cleanup + _purge_pack_modules(pack_name) + assert pack_name not in sys.modules + assert f"{pack_name}.nodes" not in sys.modules + + # Reinstall — submodule MUST re-execute + _load_node_pack(pack_name, pack_dir) + assert marker_file.read_text().count("exec") == 2, ( + "Submodule was not re-executed on reinstall — the @invocation " + "decorators would not have re-registered the pack's nodes." + ) + finally: + _purge_pack_modules(pack_name) diff --git a/tests/app/routers/test_multiuser_authorization.py b/tests/app/routers/test_multiuser_authorization.py index 85354c6a577..3461f37e7e9 100644 --- a/tests/app/routers/test_multiuser_authorization.py +++ b/tests/app/routers/test_multiuser_authorization.py @@ -178,6 +178,7 @@ def enable_multiuser(monkeypatch: Any, mock_invoker: Invoker): monkeypatch.setattr("invokeai.app.api.routers.session_queue.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.recall_parameters.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.model_manager.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes.ApiDependencies", mock_deps) yield @@ -1818,3 +1819,117 @@ def test_queue_cleared_still_broadcast(self, socketio: Any) -> None: rooms_emitted_to = [call.kwargs.get("room") for call in mock_emit.call_args_list] assert "default" in rooms_emitted_to + + +class TestCustomNodesAuthorization: + """Tests that custom_nodes endpoints enforce AdminUserOrDefault. + + All four routes (list, install, uninstall, reload) should reject + unauthenticated callers and non-admin users in multiuser mode, + and succeed for admin callers. + """ + + # -- unauthenticated ------------------------------------------------------- + + def test_list_rejects_unauthenticated(self, client: TestClient, enable_multiuser: Any) -> None: + r = client.get("/api/v2/custom_nodes/") + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + def test_install_rejects_unauthenticated(self, client: TestClient, enable_multiuser: Any) -> None: + r = client.post("/api/v2/custom_nodes/install", json={"source": "https://example.com/repo.git"}) + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + def test_uninstall_rejects_unauthenticated(self, client: TestClient, enable_multiuser: Any) -> None: + r = client.delete("/api/v2/custom_nodes/some_pack") + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + def test_reload_rejects_unauthenticated(self, client: TestClient, enable_multiuser: Any) -> None: + r = client.post("/api/v2/custom_nodes/reload") + assert r.status_code == status.HTTP_401_UNAUTHORIZED + + # -- non-admin user -------------------------------------------------------- + + def test_list_rejects_non_admin(self, client: TestClient, user1_token: str) -> None: + r = client.get("/api/v2/custom_nodes/", headers=_auth(user1_token)) + assert r.status_code == status.HTTP_403_FORBIDDEN + + def test_install_rejects_non_admin(self, client: TestClient, user1_token: str) -> None: + r = client.post( + "/api/v2/custom_nodes/install", + json={"source": "https://example.com/repo.git"}, + headers=_auth(user1_token), + ) + assert r.status_code == status.HTTP_403_FORBIDDEN + + def test_uninstall_rejects_non_admin(self, client: TestClient, user1_token: str) -> None: + r = client.delete("/api/v2/custom_nodes/some_pack", headers=_auth(user1_token)) + assert r.status_code == status.HTTP_403_FORBIDDEN + + def test_reload_rejects_non_admin(self, client: TestClient, user1_token: str) -> None: + r = client.post("/api/v2/custom_nodes/reload", headers=_auth(user1_token)) + assert r.status_code == status.HTTP_403_FORBIDDEN + + # -- admin caller succeeds ------------------------------------------------- + + def test_list_allows_admin(self, client: TestClient, admin_token: str) -> None: + r = client.get("/api/v2/custom_nodes/", headers=_auth(admin_token)) + assert r.status_code == status.HTTP_200_OK + + def test_reload_allows_admin(self, client: TestClient, admin_token: str, monkeypatch: Any) -> None: + # Stub load_custom_nodes so it doesn't actually scan the filesystem + monkeypatch.setattr( + "invokeai.app.api.routers.custom_nodes.load_custom_nodes", lambda *a, **kw: None, raising=False + ) + r = client.post("/api/v2/custom_nodes/reload", headers=_auth(admin_token)) + assert r.status_code == status.HTTP_200_OK + + def test_install_allows_admin(self, client: TestClient, admin_token: str, monkeypatch: Any, tmp_path: Any) -> None: + """Admin caller can successfully install a node pack (filesystem/subprocess mocked).""" + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", lambda: tmp_path) + + # Simulate a successful git clone by creating the target dir with __init__.py + def fake_git_clone(cmd: list[str], **kwargs: Any) -> MagicMock: + target_dir = tmp_path / "test-pack" + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "__init__.py").touch() + result = MagicMock() + result.returncode = 0 + return result + + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes.subprocess.run", fake_git_clone) + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._load_node_pack", lambda *a, **kw: None) + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._import_workflows_from_pack", lambda *a, **kw: []) + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._write_pack_manifest", lambda *a, **kw: None) + + r = client.post( + "/api/v2/custom_nodes/install", + json={"source": "https://example.com/test-pack.git"}, + headers=_auth(admin_token), + ) + assert r.status_code == status.HTTP_200_OK + data = r.json() + assert data["success"] is True + assert data["name"] == "test-pack" + + def test_uninstall_allows_admin( + self, client: TestClient, admin_token: str, monkeypatch: Any, tmp_path: Any + ) -> None: + """Admin caller can successfully uninstall a node pack (filesystem mocked).""" + # Create a fake installed pack directory + pack_dir = tmp_path / "test-pack" + pack_dir.mkdir() + (pack_dir / "__init__.py").touch() + + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._get_custom_nodes_path", lambda: tmp_path) + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._read_pack_manifest", lambda *a, **kw: []) + monkeypatch.setattr( + "invokeai.app.api.routers.custom_nodes.InvocationRegistry.unregister_pack", + lambda *a, **kw: [], + ) + monkeypatch.setattr("invokeai.app.api.routers.custom_nodes._remove_workflows_by_ids", lambda *a, **kw: 0) + + r = client.delete("/api/v2/custom_nodes/test-pack", headers=_auth(admin_token)) + assert r.status_code == status.HTTP_200_OK + data = r.json() + assert data["success"] is True + assert data["name"] == "test-pack"