v0.2.1: redeploy_project post-start polling (30s timeout)
DSM starts containers asynchronously - start_project returns immediately while containers are still initialising. Adds _wait_for_project_running: polls SYNO.Docker.Project/list every 2s up to 30s after issuing start. Reports RUNNING on success; emits a warning instead of failure on timeout so callers can still verify with get_project_status. Applies to all three redeploy paths (RUNNING, STOPPED, BUILD_FAILED). Also bumps version 0.2.0 → 0.2.1 and adds CHANGELOG entry. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-04-21
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `redeploy_project`: After issuing `start`, the tool now polls the project status every 2 seconds
|
||||||
|
for up to 30 seconds until the project reaches `RUNNING`. Previously DSM returned immediately
|
||||||
|
while containers were still starting, causing the project to appear as `exited` when checked
|
||||||
|
right after redeploy. On timeout a warning is returned instead of an error.
|
||||||
|
- `delete_image`: Now distinguishes between running and stopped container references.
|
||||||
|
A stopped container holding the image produces a clear hint to use `delete_container`
|
||||||
|
or `system_prune` instead of a generic "in use" error.
|
||||||
|
- `redeploy_project` (BUILD_FAILED path): Added explicit image pull step before restart
|
||||||
|
(`stop → pull → start`). Previously the old cached image could be reused.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `delete_container` — delete a stopped container by name; refuses if container is still running;
|
||||||
|
requires `confirmed=True`.
|
||||||
|
|
||||||
## [0.2.0] - 2026-04-14
|
## [0.2.0] - 2026-04-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
description = "MCP server for Synology Container Manager"
|
description = "MCP server for Synology Container Manager"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
@@ -14,6 +15,9 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_POLL_INTERVAL = 2 # seconds between status checks
|
||||||
|
_POLL_TIMEOUT = 30 # maximum seconds to wait for RUNNING
|
||||||
|
|
||||||
|
|
||||||
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> None:
|
||||||
"""Register all project management tools with the MCP server."""
|
"""Register all project management tools with the MCP server."""
|
||||||
@@ -124,6 +128,10 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
- STOPPED → start directly (nothing to stop)
|
- STOPPED → start directly (nothing to stop)
|
||||||
- BUILD_FAILED → stop, pull images, then start
|
- BUILD_FAILED → stop, pull images, then start
|
||||||
|
|
||||||
|
After issuing start, polls the project status every 2 seconds for up
|
||||||
|
to 30 seconds until the project reaches RUNNING. Reports the final
|
||||||
|
status; emits a warning on timeout instead of failing.
|
||||||
|
|
||||||
This operation will briefly take the project offline.
|
This operation will briefly take the project offline.
|
||||||
Requires confirmation before executing.
|
Requires confirmation before executing.
|
||||||
|
|
||||||
@@ -149,30 +157,30 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
try:
|
try:
|
||||||
if status == "STOPPED":
|
if status == "STOPPED":
|
||||||
results.append("Project is STOPPED — starting directly.")
|
results.append("Project is STOPPED — starting directly.")
|
||||||
results.append("Step 1/1: Starting project...")
|
results.append("Step 1/2: Starting project...")
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||||
results.append(" Project started.")
|
results.append(" Start issued.")
|
||||||
|
|
||||||
elif status == "BUILD_FAILED":
|
elif status == "BUILD_FAILED":
|
||||||
results.append("Step 1/3: Stopping failed build...")
|
results.append("Step 1/4: Stopping failed build...")
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||||
results.append(" Build stopped.")
|
results.append(" Build stopped.")
|
||||||
results.append("Step 2/3: Pulling updated images...")
|
results.append("Step 2/4: Pulling updated images...")
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await client.request("SYNO.Docker.Image", "pull", params={"id": project_id})
|
await client.request("SYNO.Docker.Image", "pull", params={"id": project_id})
|
||||||
results.append(" Images pulled.")
|
results.append(" Images pulled.")
|
||||||
results.append("Step 3/3: Starting project...")
|
results.append("Step 3/4: Starting project...")
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||||
results.append(" Project started.")
|
results.append(" Start issued.")
|
||||||
|
|
||||||
elif status in ("RUNNING", ""):
|
elif status in ("RUNNING", ""):
|
||||||
results.append("Step 1/2: Stopping project...")
|
results.append("Step 1/3: Stopping project...")
|
||||||
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "stop", params={"id": project_id})
|
||||||
results.append(" Project stopped.")
|
results.append(" Project stopped.")
|
||||||
results.append("Step 2/2: Starting project...")
|
results.append("Step 2/3: Starting project...")
|
||||||
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
await client.request("SYNO.Docker.Project", "start", params={"id": project_id})
|
||||||
results.append(" Project started.")
|
results.append(" Start issued.")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return (
|
return (
|
||||||
@@ -180,7 +188,21 @@ def register_projects(mcp: FastMCP, config: AppConfig, client: DsmClient) -> Non
|
|||||||
f"Workaround: use stop_project + start_project separately."
|
f"Workaround: use stop_project + start_project separately."
|
||||||
)
|
)
|
||||||
|
|
||||||
results.append(f"\nProject '{project_name}' redeployed successfully.")
|
# Poll until RUNNING or timeout
|
||||||
|
poll_step = (
|
||||||
|
"2/2" if status == "STOPPED" else ("4/4" if status == "BUILD_FAILED" else "3/3")
|
||||||
|
)
|
||||||
|
results.append(f"Step {poll_step}: Waiting for project to reach RUNNING state...")
|
||||||
|
final_status = await _wait_for_project_running(client, project_name)
|
||||||
|
if final_status == "RUNNING":
|
||||||
|
results.append(" Project is RUNNING.")
|
||||||
|
results.append(f"\nProject '{project_name}' redeployed successfully.")
|
||||||
|
else:
|
||||||
|
results.append(
|
||||||
|
f" Warning: project status is '{final_status}' after {_POLL_TIMEOUT}s. "
|
||||||
|
f"Containers may still be starting — check with get_project_status."
|
||||||
|
)
|
||||||
|
results.append(f"\nProject '{project_name}' start issued (status: {final_status}).")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
results.append(f"Error during redeploy: {e}")
|
results.append(f"Error during redeploy: {e}")
|
||||||
@@ -211,6 +233,39 @@ async def _find_project(client: DsmClient, name: str) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_for_project_running(
|
||||||
|
client: DsmClient,
|
||||||
|
name: str,
|
||||||
|
timeout: int = _POLL_TIMEOUT,
|
||||||
|
interval: int = _POLL_INTERVAL,
|
||||||
|
) -> str:
|
||||||
|
"""Poll until the project reaches RUNNING status or timeout expires.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: DsmClient instance.
|
||||||
|
name: Project name to watch.
|
||||||
|
timeout: Maximum seconds to wait (default 30).
|
||||||
|
interval: Seconds between polls (default 2).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Final project status string (may not be "RUNNING" on timeout).
|
||||||
|
"""
|
||||||
|
elapsed = 0
|
||||||
|
while elapsed < timeout:
|
||||||
|
await asyncio.sleep(interval)
|
||||||
|
elapsed += interval
|
||||||
|
project = await _find_project(client, name)
|
||||||
|
if project is None:
|
||||||
|
continue
|
||||||
|
current = (project.get("status") or "").upper()
|
||||||
|
logger.debug("Polling '%s': status=%s elapsed=%ds", name, current, elapsed)
|
||||||
|
if current == "RUNNING":
|
||||||
|
return current
|
||||||
|
# Return whatever status we last saw (or UNKNOWN on repeated failures)
|
||||||
|
project = await _find_project(client, name)
|
||||||
|
return (project.get("status") or "UNKNOWN").upper() if project else "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
def _format_project_detail(project: dict[str, Any]) -> str:
|
def _format_project_detail(project: dict[str, Any]) -> str:
|
||||||
"""Format project details as human-readable text."""
|
"""Format project details as human-readable text."""
|
||||||
lines = [
|
lines = [
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Tests for modules/projects.py."""
|
"""Tests for modules/projects.py."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
from mcp_synology_container.modules.projects import _find_project, _format_project_detail
|
from mcp_synology_container.modules.projects import _find_project, _format_project_detail
|
||||||
|
|
||||||
|
|
||||||
SAMPLE_PROJECTS = {
|
SAMPLE_PROJECTS = {
|
||||||
"uuid-1": {
|
"uuid-1": {
|
||||||
"id": "uuid-1",
|
"id": "uuid-1",
|
||||||
@@ -83,8 +83,8 @@ def test_format_project_detail_no_containers():
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_list_projects_tool():
|
async def test_list_projects_tool():
|
||||||
"""Test list_projects tool via function registration."""
|
"""Test list_projects tool via function registration."""
|
||||||
from mcp_synology_container.modules.projects import register_projects
|
|
||||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||||
|
from mcp_synology_container.modules.projects import register_projects
|
||||||
|
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
@@ -114,8 +114,8 @@ async def test_list_projects_tool():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_project_requires_confirmation():
|
async def test_stop_project_requires_confirmation():
|
||||||
from mcp_synology_container.modules.projects import register_projects
|
|
||||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||||
|
from mcp_synology_container.modules.projects import register_projects
|
||||||
|
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
@@ -141,8 +141,8 @@ async def test_stop_project_requires_confirmation():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_project_requires_confirmation():
|
async def test_redeploy_project_requires_confirmation():
|
||||||
from mcp_synology_container.modules.projects import register_projects
|
|
||||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||||
|
from mcp_synology_container.modules.projects import register_projects
|
||||||
|
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
@@ -172,8 +172,8 @@ async def test_redeploy_project_requires_confirmation():
|
|||||||
|
|
||||||
|
|
||||||
def make_projects_tools(client):
|
def make_projects_tools(client):
|
||||||
from mcp_synology_container.modules.projects import register_projects
|
|
||||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||||
|
from mcp_synology_container.modules.projects import register_projects
|
||||||
|
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
schema_version=1,
|
schema_version=1,
|
||||||
@@ -206,43 +206,58 @@ def project_list(status: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
def make_stateful_redeploy_mock(initial_status: str, stop_raises=None, pull_raises=None):
|
||||||
async def test_redeploy_running_project():
|
"""Create a stateful client mock for redeploy tests.
|
||||||
"""RUNNING project: stop then start (2 steps)."""
|
|
||||||
|
Returns (client, calls_list). After ``start`` is called, subsequent
|
||||||
|
``list`` calls return RUNNING so the polling loop terminates immediately.
|
||||||
|
asyncio.sleep is NOT patched here — patch it at call-site.
|
||||||
|
"""
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
calls = []
|
calls = []
|
||||||
|
start_called = False
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
|
nonlocal start_called
|
||||||
calls.append((api, method))
|
calls.append((api, method))
|
||||||
return project_list("RUNNING") if method == "list" else {}
|
if method == "start":
|
||||||
|
start_called = True
|
||||||
|
if method == "stop" and stop_raises:
|
||||||
|
raise stop_raises
|
||||||
|
if method == "pull" and pull_raises:
|
||||||
|
raise pull_raises
|
||||||
|
if method == "list":
|
||||||
|
return project_list("RUNNING") if start_called else project_list(initial_status)
|
||||||
|
return {}
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
|
return client, calls
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redeploy_running_project():
|
||||||
|
"""RUNNING project: stop then start; polls until RUNNING."""
|
||||||
|
client, calls = make_stateful_redeploy_mock("RUNNING")
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
assert "stop" in methods
|
assert "stop" in methods
|
||||||
assert "start" in methods
|
assert "start" in methods
|
||||||
# stop must come before start
|
|
||||||
assert methods.index("stop") < methods.index("start")
|
assert methods.index("stop") < methods.index("start")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_stopped_project_starts_directly():
|
async def test_redeploy_stopped_project_starts_directly():
|
||||||
"""STOPPED project: skip stop, just start."""
|
"""STOPPED project: skip stop, just start; polls until RUNNING."""
|
||||||
client = AsyncMock()
|
client, calls = make_stateful_redeploy_mock("STOPPED")
|
||||||
calls = []
|
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
|
||||||
calls.append((api, method))
|
|
||||||
return project_list("STOPPED") if method == "list" else {}
|
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
@@ -253,51 +268,69 @@ async def test_redeploy_stopped_project_starts_directly():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_build_failed_project():
|
async def test_redeploy_build_failed_project():
|
||||||
"""BUILD_FAILED project: stop, pull images, then start (3 steps)."""
|
"""BUILD_FAILED project: stop → pull → start; polls until RUNNING."""
|
||||||
client = AsyncMock()
|
client, calls = make_stateful_redeploy_mock("BUILD_FAILED")
|
||||||
calls = []
|
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
|
||||||
calls.append((api, method))
|
|
||||||
return project_list("BUILD_FAILED") if method == "list" else {}
|
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" in result
|
assert "redeployed successfully" in result
|
||||||
methods = [m for _, m in calls]
|
methods = [m for _, m in calls]
|
||||||
assert "stop" in methods
|
assert "stop" in methods
|
||||||
assert "pull" in methods # New: pull step
|
assert "pull" in methods
|
||||||
assert "start" in methods
|
assert "start" in methods
|
||||||
# Order: stop → pull → start
|
|
||||||
assert methods.index("stop") < methods.index("pull")
|
assert methods.index("stop") < methods.index("pull")
|
||||||
assert methods.index("pull") < methods.index("start")
|
assert methods.index("pull") < methods.index("start")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_redeploy_build_failed_stop_error_nonfatal():
|
async def test_redeploy_build_failed_stop_error_nonfatal():
|
||||||
"""BUILD_FAILED: stop/pull failure must not abort the redeploy."""
|
"""BUILD_FAILED: stop/pull failures must not abort the redeploy."""
|
||||||
from mcp_synology_container.dsm_client import SynologyError
|
from mcp_synology_container.dsm_client import SynologyError
|
||||||
|
|
||||||
|
client, _ = make_stateful_redeploy_mock(
|
||||||
|
"BUILD_FAILED",
|
||||||
|
stop_raises=SynologyError("already stopped", code=2101),
|
||||||
|
pull_raises=SynologyError("pull failed", code=2102),
|
||||||
|
)
|
||||||
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
|
with patch("mcp_synology_container.modules.projects.asyncio.sleep"):
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
|
assert "redeployed successfully" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_redeploy_poll_timeout():
|
||||||
|
"""If project never reaches RUNNING after start, a warning is emitted."""
|
||||||
client = AsyncMock()
|
client = AsyncMock()
|
||||||
|
start_called = False
|
||||||
|
|
||||||
async def mock_request(api, method, **kwargs):
|
async def mock_request(api, method, **kwargs):
|
||||||
|
nonlocal start_called
|
||||||
|
if method == "start":
|
||||||
|
start_called = True
|
||||||
if method == "list":
|
if method == "list":
|
||||||
return project_list("BUILD_FAILED")
|
# Before start: return RUNNING so initial status check picks a valid path.
|
||||||
if method == "stop":
|
# After start: return STARTING to simulate a stuck container — triggers timeout.
|
||||||
raise SynologyError("already stopped", code=2101)
|
return project_list("STARTING") if start_called else project_list("RUNNING")
|
||||||
if method == "pull":
|
return {}
|
||||||
raise SynologyError("pull failed", code=2102)
|
|
||||||
return {} # start succeeds
|
|
||||||
|
|
||||||
client.request.side_effect = mock_request
|
client.request.side_effect = mock_request
|
||||||
tools = make_projects_tools(client)
|
tools = make_projects_tools(client)
|
||||||
|
|
||||||
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
# Use tiny timeout so the test is instant (interval=1, timeout=1 → 1 poll)
|
||||||
|
with (
|
||||||
|
patch("mcp_synology_container.modules.projects.asyncio.sleep"),
|
||||||
|
patch("mcp_synology_container.modules.projects._POLL_TIMEOUT", 1),
|
||||||
|
patch("mcp_synology_container.modules.projects._POLL_INTERVAL", 1),
|
||||||
|
):
|
||||||
|
result = await tools["redeploy_project"]("myapp", confirmed=True)
|
||||||
|
|
||||||
assert "redeployed successfully" in result
|
assert "Warning" in result
|
||||||
|
assert "redeployed successfully" not in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp-synology-container"
|
name = "mcp-synology-container"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
|||||||
Reference in New Issue
Block a user