562 lines
26 KiB
Python
562 lines
26 KiB
Python
"""Domain adapters over the site's existing editors and importer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import configparser
|
|
from dataclasses import asdict
|
|
from datetime import date
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
from .errors import LabyricornMcpError
|
|
from .safety import RepositoryGuard
|
|
|
|
|
|
DESIGN_ROOTS = ("templates", "assets/static")
|
|
DESIGN_SUFFIXES = {".html", ".css", ".js", ".json", ".svg", ".txt", ".xml"}
|
|
CONTENT_MODELS = {"article", "blog", "project", "devlog"}
|
|
|
|
|
|
def _hash(payload: bytes) -> str:
|
|
return hashlib.sha256(payload).hexdigest()
|
|
|
|
|
|
def _load_site_modules(root: Path) -> dict[str, Any]:
|
|
scripts = str(root / "scripts")
|
|
if scripts not in sys.path:
|
|
sys.path.insert(0, scripts)
|
|
try:
|
|
import article_editor
|
|
import blog_editor
|
|
import project_editor
|
|
import tag_editor
|
|
except (ImportError, OSError) as exc:
|
|
raise LabyricornMcpError(
|
|
"INVALID_LABYRICORN_REPOSITORY", "The repository's management modules could not be loaded."
|
|
) from exc
|
|
return {
|
|
"article": article_editor,
|
|
"blog": blog_editor,
|
|
"project": project_editor,
|
|
"tag": tag_editor,
|
|
}
|
|
|
|
|
|
class LabyricornDomain:
|
|
def __init__(self, guard: RepositoryGuard):
|
|
self.guard = guard
|
|
self.modules = _load_site_modules(guard.root)
|
|
|
|
def _article_repository(self) -> Any:
|
|
repository = self.modules["article"].ArticleRepository(self.guard.root)
|
|
self._require_contained(repository.article_root, repository.tags_root)
|
|
return repository
|
|
|
|
def _blog_repository(self) -> Any:
|
|
repository = self.modules["blog"].BlogRepository(self.guard.root)
|
|
self._require_contained(repository.blog_root, repository.tags_root)
|
|
return repository
|
|
|
|
def _tag_repository(self) -> Any:
|
|
repository = self.modules["tag"].TagRepository(self.guard.root)
|
|
self._require_contained(repository.tags_root)
|
|
return repository
|
|
|
|
def _project_registry(self) -> Any:
|
|
return self.modules["project"].ProjectSourceRegistry(self.guard.root)
|
|
|
|
def _require_contained(self, *paths: Path) -> None:
|
|
if any(not path.resolve().is_relative_to(self.guard.root) for path in paths):
|
|
raise LabyricornMcpError(
|
|
"PATH_OUTSIDE_REPOSITORY", "A required content path resolves outside the repository."
|
|
)
|
|
|
|
def _translate(self, exc: Exception, *, not_found: bool = False) -> LabyricornMcpError:
|
|
message = str(exc).replace(str(self.guard.root), ".")
|
|
folded = message.casefold()
|
|
if "changed on disk" in folded or "reload" in folded:
|
|
code = "FILE_CHANGED"
|
|
elif not_found or "does not exist" in folded:
|
|
code = "CONTENT_NOT_FOUND"
|
|
elif "already exists" in folded:
|
|
code = "WORKTREE_CONFLICT"
|
|
elif "unsafe" in folded or "outside" in folded or "linked" in folded:
|
|
code = "PATH_OUTSIDE_REPOSITORY"
|
|
else:
|
|
code = "VALIDATION_FAILED"
|
|
return LabyricornMcpError(code, message)
|
|
|
|
def site_structure(self) -> dict[str, Any]:
|
|
return {
|
|
"content": {
|
|
"articles": {"path": "content/articles", "model": "entry", "kicker": "Article", "ownership": "site"},
|
|
"blog": {"path": "content/blog", "model": "entry", "kicker": "Blog", "ownership": "site"},
|
|
"tags": {"path": "content/tags", "model": "tag", "ownership": "site"},
|
|
"projects": {
|
|
"path": "content/projects",
|
|
"models": ["project", "devlog", "devlog-entry"],
|
|
"ownership": "validated remote sources",
|
|
"source_registry": "configs/project-sources.ini",
|
|
},
|
|
},
|
|
"design": {
|
|
"inspectable_roots": list(DESIGN_ROOTS),
|
|
"hash_guarded_text_mutation_roots": list(DESIGN_ROOTS),
|
|
"binary_assets": "metadata and hash inspection only",
|
|
},
|
|
"models_path": "models",
|
|
"build_entry_point": "scripts/build_with_projects.py",
|
|
"transport": "stdio",
|
|
}
|
|
|
|
def describe_model(self, model: str) -> dict[str, Any]:
|
|
if not isinstance(model, str) or not re.fullmatch(r"[a-z0-9-]+", model):
|
|
raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Model names must use lowercase letters, numbers, and hyphens.")
|
|
path = self.guard.resolve(f"models/{model}.ini", allowed_roots=("models",), must_exist=True)
|
|
parser = configparser.ConfigParser(interpolation=None)
|
|
try:
|
|
parser.read(path, encoding="utf-8")
|
|
except configparser.Error as exc:
|
|
raise LabyricornMcpError("VALIDATION_FAILED", "The model definition is malformed.") from exc
|
|
fields: list[dict[str, Any]] = []
|
|
for section in parser.sections():
|
|
if not section.startswith("fields."):
|
|
continue
|
|
values = dict(parser.items(section))
|
|
fields.append({"name": section.removeprefix("fields."), **values})
|
|
return {
|
|
"model": model,
|
|
"name": parser.get("model", "name", fallback=model),
|
|
"label": parser.get("model", "label", fallback=""),
|
|
"hidden": parser.getboolean("model", "hidden", fallback=False),
|
|
"protected": parser.getboolean("model", "protected", fallback=False),
|
|
"children": dict(parser.items("children")) if parser.has_section("children") else None,
|
|
"fields": fields,
|
|
"source": self.guard.relative(path),
|
|
"sha256": _hash(path.read_bytes()),
|
|
}
|
|
|
|
def _entry_result(self, model: str, entry: Any, repository: Any, *, include_body: bool) -> dict[str, Any]:
|
|
values = repository.values_for(entry.document)
|
|
if not include_body:
|
|
values.pop("body", None)
|
|
return {
|
|
"model": model,
|
|
"slug": entry.slug,
|
|
"path": self.guard.relative(entry.path),
|
|
"fields": values,
|
|
"sha256": _hash(entry.original_bytes),
|
|
}
|
|
|
|
def list_content(self, filters: dict[str, Any]) -> dict[str, Any]:
|
|
requested = filters.get("model")
|
|
models = [requested] if requested else ["article", "blog", "project", "devlog"]
|
|
if any(model not in CONTENT_MODELS for model in models):
|
|
raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Supported content models are article, blog, project, and devlog.")
|
|
items: list[dict[str, Any]] = []
|
|
if "article" in models:
|
|
repository = self._article_repository()
|
|
items.extend(self._entry_result("article", item, repository, include_body=False) for item in repository.list_entries())
|
|
if "blog" in models:
|
|
repository = self._blog_repository()
|
|
items.extend(self._entry_result("blog", item, repository, include_body=False) for item in repository.list_entries())
|
|
if "project" in models or "devlog" in models:
|
|
for project in self.list_projects()["projects"]:
|
|
if "project" in models:
|
|
items.append({"model": "project", **project})
|
|
if "devlog" in models:
|
|
try:
|
|
items.extend(self.list_project_devlogs(project["project_id"])["entries"])
|
|
except LabyricornMcpError as exc:
|
|
if exc.code not in {"CONTENT_NOT_FOUND", "RUNTIME_MISSING"}:
|
|
raise
|
|
|
|
tag = str(filters.get("tag") or "").strip()
|
|
project_id = str(filters.get("project") or "").strip()
|
|
slug = str(filters.get("slug") or "").strip()
|
|
date_value = str(filters.get("date") or "").strip()
|
|
search = str(filters.get("search") or "").strip().casefold()
|
|
|
|
def matches(item: dict[str, Any]) -> bool:
|
|
fields = item.get("fields", {})
|
|
tags = fields.get("tags", [])
|
|
if isinstance(tags, str):
|
|
tags = [part.strip() for part in re.split(r"[,\n]", tags) if part.strip()]
|
|
if tag and tag not in tags and tag not in fields.get("topic_tag_slugs", []):
|
|
return False
|
|
if project_id and item.get("project_id") != project_id:
|
|
return False
|
|
if slug and item.get("slug") != slug:
|
|
return False
|
|
if date_value and fields.get("date") != date_value:
|
|
return False
|
|
if search and search not in json.dumps(item, ensure_ascii=False).casefold():
|
|
return False
|
|
return True
|
|
|
|
filtered = [item for item in items if matches(item)]
|
|
return {"count": len(filtered), "items": filtered}
|
|
|
|
def get_content(self, model: str, slug: str, project: str | None = None) -> dict[str, Any]:
|
|
try:
|
|
if model == "article":
|
|
repository = self._article_repository()
|
|
return self._entry_result(model, repository.load_entry(slug), repository, include_body=True)
|
|
if model == "blog":
|
|
repository = self._blog_repository()
|
|
return self._entry_result(model, repository.load_entry(slug), repository, include_body=True)
|
|
if model == "project":
|
|
return self.get_project(slug)
|
|
if model == "devlog":
|
|
if not project:
|
|
raise LabyricornMcpError("VALIDATION_FAILED", "The project argument is required for devlog content.")
|
|
return self.get_devlog(project, slug)
|
|
except LabyricornMcpError:
|
|
raise
|
|
except Exception as exc:
|
|
raise self._translate(exc, not_found=True) from exc
|
|
raise LabyricornMcpError("INVALID_CONTENT_MODEL", "Supported content models are article, blog, project, and devlog.")
|
|
|
|
def _create_entry(self, model: str, slug: str, fields: dict[str, Any]) -> dict[str, Any]:
|
|
repository = self._article_repository() if model == "article" else self._blog_repository()
|
|
kicker = "Article" if model == "article" else "Blog"
|
|
values = {
|
|
"title": str(fields.get("title", "")),
|
|
"date": str(fields.get("date", date.today().isoformat())),
|
|
"updated": str(fields.get("updated", "")),
|
|
"author": str(fields.get("author", "")),
|
|
"tags": self._tag_input(fields.get("tags", "")),
|
|
"kicker": kicker,
|
|
"summary": str(fields.get("summary", "")),
|
|
"body": str(fields.get("body", "")),
|
|
"external_url": str(fields.get("external_url", "")),
|
|
}
|
|
if model == "article":
|
|
values["published_urls"] = self._lines_input(fields.get("published_urls", ""))
|
|
try:
|
|
entry = repository.create_entry(slug, values)
|
|
return self._entry_result(model, entry, repository, include_body=True)
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
|
|
def _update_entry(self, model: str, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]:
|
|
repository = self._article_repository() if model == "article" else self._blog_repository()
|
|
try:
|
|
entry = repository.load_entry(slug)
|
|
actual_hash = _hash(entry.original_bytes)
|
|
if actual_hash != expected_hash:
|
|
raise LabyricornMcpError(
|
|
"FILE_CHANGED", "The content changed after it was read; reload it before updating.",
|
|
{"expected_sha256": expected_hash, "actual_sha256": actual_hash},
|
|
)
|
|
values = repository.values_for(entry.document)
|
|
for key, value in fields.items():
|
|
if key not in values:
|
|
raise LabyricornMcpError("VALIDATION_FAILED", f"Unsupported {model} field: {key}")
|
|
if key == "tags":
|
|
values[key] = self._tag_input(value)
|
|
elif key == "published_urls":
|
|
values[key] = self._lines_input(value)
|
|
else:
|
|
values[key] = str(value)
|
|
saved = repository.save_entry(entry, values)
|
|
return self._entry_result(model, saved, repository, include_body=True)
|
|
except LabyricornMcpError:
|
|
raise
|
|
except Exception as exc:
|
|
raise self._translate(exc, not_found=True) from exc
|
|
|
|
@staticmethod
|
|
def _tag_input(value: Any) -> str:
|
|
if isinstance(value, list):
|
|
return ", ".join(str(item) for item in value)
|
|
return str(value)
|
|
|
|
@staticmethod
|
|
def _lines_input(value: Any) -> str:
|
|
if isinstance(value, list):
|
|
return "\n".join(str(item) for item in value)
|
|
return str(value)
|
|
|
|
def create_article(self, slug: str, fields: dict[str, Any]) -> dict[str, Any]:
|
|
return self._create_entry("article", slug, fields)
|
|
|
|
def update_article(self, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]:
|
|
return self._update_entry("article", slug, fields, expected_hash)
|
|
|
|
def create_blog_post(self, slug: str, fields: dict[str, Any]) -> dict[str, Any]:
|
|
return self._create_entry("blog", slug, fields)
|
|
|
|
def update_blog_post(self, slug: str, fields: dict[str, Any], expected_hash: str) -> dict[str, Any]:
|
|
return self._update_entry("blog", slug, fields, expected_hash)
|
|
|
|
def list_tags(self) -> dict[str, Any]:
|
|
try:
|
|
tags = self._tag_repository().list_tags()
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
return {
|
|
"count": len(tags),
|
|
"tags": [
|
|
{"slug": tag.slug, "title": tag.title, "summary": tag.summary, "sha256": _hash(tag.original_bytes)}
|
|
for tag in tags
|
|
],
|
|
}
|
|
|
|
def get_tag_usage(self, slug: str) -> dict[str, Any]:
|
|
usages: list[dict[str, str]] = []
|
|
for model in ("article", "blog"):
|
|
repository = self._article_repository() if model == "article" else self._blog_repository()
|
|
for entry in repository.list_entries():
|
|
tags = [part.strip() for part in re.split(r"[,\n]", entry.document.get("tags")) if part.strip()]
|
|
if slug in tags:
|
|
usages.append({"model": model, "slug": entry.slug})
|
|
for project in self.list_projects()["projects"]:
|
|
try:
|
|
snapshot = self._cached_project_snapshot(project["project_id"])
|
|
except LabyricornMcpError:
|
|
continue
|
|
project_record = snapshot["records"][".labyricorn/project/contents.lr"]
|
|
if slug in self._split(project_record.get("tags", "")):
|
|
usages.append({"model": "project", "slug": project["project_id"]})
|
|
for path, record in snapshot["records"].items():
|
|
if path.startswith(".labyricorn/devlog/") and path.count("/") == 3 and slug in self._split(record.get("tags", "")):
|
|
usages.append({"model": "devlog", "slug": Path(path).parent.name, "project": project["project_id"]})
|
|
tracked = any(tag["slug"] == slug for tag in self.list_tags()["tags"])
|
|
return {"slug": slug, "tracked": tracked, "count": len(usages), "usages": usages}
|
|
|
|
def create_tag(self, slug: str, title: str, summary: str) -> dict[str, Any]:
|
|
try:
|
|
tag = self._tag_repository().create_tag(slug, title, summary)
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
return {"slug": tag.slug, "title": tag.title, "summary": tag.summary, "sha256": _hash(tag.original_bytes)}
|
|
|
|
def update_tag(self, slug: str, title: str, summary: str, expected_hash: str) -> dict[str, Any]:
|
|
repository = self._tag_repository()
|
|
try:
|
|
tag = repository.load_tag(slug)
|
|
actual = _hash(tag.original_bytes)
|
|
if actual != expected_hash:
|
|
raise LabyricornMcpError("FILE_CHANGED", "The tracked tag changed after it was read.", {"actual_sha256": actual})
|
|
saved = repository.save_tag(tag, title, summary)
|
|
except LabyricornMcpError:
|
|
raise
|
|
except Exception as exc:
|
|
raise self._translate(exc, not_found=True) from exc
|
|
return {"slug": saved.slug, "title": saved.title, "summary": saved.summary, "sha256": _hash(saved.original_bytes)}
|
|
|
|
def untrack_tag(self, slug: str, expected_hash: str) -> dict[str, Any]:
|
|
repository = self._tag_repository()
|
|
try:
|
|
tag = repository.load_tag(slug)
|
|
actual = _hash(tag.original_bytes)
|
|
if actual != expected_hash:
|
|
raise LabyricornMcpError("FILE_CHANGED", "The tracked tag changed after it was read.", {"actual_sha256": actual})
|
|
usage = self.get_tag_usage(slug)
|
|
repository.untrack_tag(tag)
|
|
except LabyricornMcpError:
|
|
raise
|
|
except Exception as exc:
|
|
raise self._translate(exc, not_found=True) from exc
|
|
return {"slug": slug, "untracked": True, "preserved_content_references": usage["count"]}
|
|
|
|
def list_projects(self) -> dict[str, Any]:
|
|
try:
|
|
sources = self._project_registry().list_sources()
|
|
except Exception as exc:
|
|
raise self._translate(exc) from exc
|
|
state = self._project_state()
|
|
projects = []
|
|
for source in sources:
|
|
cached = state.get("projects", {}).get(source.project_id, {})
|
|
projects.append(
|
|
{
|
|
"project_id": source.project_id,
|
|
"repository": source.web_url,
|
|
"branch": source.branch,
|
|
"featured_order": source.featured_order,
|
|
"cached_commit": cached.get("commit"),
|
|
"synchronized_at": cached.get("synchronized_at"),
|
|
"metadata": cached.get("metadata", {}),
|
|
}
|
|
)
|
|
return {"count": len(projects), "projects": projects}
|
|
|
|
def get_project(self, project_id: str) -> dict[str, Any]:
|
|
snapshot = self._cached_project_snapshot(project_id)
|
|
record = snapshot["records"][".labyricorn/project/contents.lr"]
|
|
return {
|
|
"model": "project",
|
|
"project_id": project_id,
|
|
"slug": project_id,
|
|
"fields": record,
|
|
"commit": snapshot["commit"],
|
|
"source": snapshot["source"],
|
|
"metadata": snapshot["state"].get("metadata", {}),
|
|
}
|
|
|
|
def list_project_devlogs(self, project_id: str) -> dict[str, Any]:
|
|
snapshot = self._cached_project_snapshot(project_id)
|
|
entries = []
|
|
for path, record in snapshot["records"].items():
|
|
pure = Path(path.replace("/", "\\")) if sys.platform == "win32" else Path(path)
|
|
if not path.startswith(".labyricorn/devlog/") or path.count("/") != 3:
|
|
continue
|
|
slug = pure.parent.name
|
|
entries.append(
|
|
{"model": "devlog", "project_id": project_id, "slug": slug, "fields": record, "commit": snapshot["commit"]}
|
|
)
|
|
entries.sort(key=lambda item: (item["fields"].get("date", ""), item["slug"]), reverse=True)
|
|
return {"project_id": project_id, "count": len(entries), "entries": entries}
|
|
|
|
def get_devlog(self, project_id: str, slug: str) -> dict[str, Any]:
|
|
for entry in self.list_project_devlogs(project_id)["entries"]:
|
|
if entry["slug"] == slug:
|
|
return entry
|
|
raise LabyricornMcpError("CONTENT_NOT_FOUND", "The requested project devlog entry does not exist.")
|
|
|
|
def list_project_sources(self) -> dict[str, Any]:
|
|
projects = self.list_projects()["projects"]
|
|
return {
|
|
"count": len(projects),
|
|
"sources": [
|
|
{key: value for key, value in project.items() if key != "metadata"}
|
|
for project in projects
|
|
],
|
|
}
|
|
|
|
def inspect_project_source(self, project_id: str) -> dict[str, Any]:
|
|
snapshot = self._cached_project_snapshot(project_id)
|
|
return {
|
|
"project_id": project_id,
|
|
"source": snapshot["source"],
|
|
"cached_commit": snapshot["commit"],
|
|
"validated_files": sorted(snapshot["files"]),
|
|
"records": snapshot["records"],
|
|
"network_accessed": False,
|
|
}
|
|
|
|
def validate_project_source(self, project_id: str) -> dict[str, Any]:
|
|
try:
|
|
source = self._project_registry().load_source(project_id)
|
|
readiness = self.modules["project"].check_devlog_readiness(source)
|
|
except Exception as exc:
|
|
raise self._translate(exc, not_found=True) from exc
|
|
return {"project_id": project_id, **asdict(readiness), "network_accessed": True, "repository_modified": False}
|
|
|
|
def _project_state(self) -> dict[str, Any]:
|
|
path = self.guard.resolve(".cache/project-sources/state.json")
|
|
if not path.is_file():
|
|
return {"version": 1, "projects": {}}
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
raise LabyricornMcpError("VALIDATION_FAILED", "The local project cache state is malformed.") from exc
|
|
|
|
def _cached_project_snapshot(self, project_id: str) -> dict[str, Any]:
|
|
try:
|
|
source = self._project_registry().load_source(project_id)
|
|
except Exception as exc:
|
|
raise self._translate(exc, not_found=True) from exc
|
|
state = self._project_state().get("projects", {}).get(project_id)
|
|
if not state or not state.get("commit"):
|
|
raise LabyricornMcpError("CONTENT_NOT_FOUND", "No validated local snapshot is cached for this project.")
|
|
try:
|
|
import project_sources
|
|
except ModuleNotFoundError as exc:
|
|
raise LabyricornMcpError(
|
|
"RUNTIME_MISSING", "Inspecting cached project content requires the existing Lektor Python environment."
|
|
) from exc
|
|
mirror = self.guard.resolve(f".cache/project-sources/repos/{project_id}.git", must_exist=True)
|
|
try:
|
|
files, records = project_sources.validate_snapshot(source.importer_source(), mirror, state["commit"])
|
|
except Exception as exc:
|
|
raise LabyricornMcpError("VALIDATION_FAILED", f"The cached project snapshot failed validation: {exc}") from exc
|
|
return {
|
|
"source": {key: value for key, value in source.importer_source().items() if key != "api_url"},
|
|
"commit": state["commit"],
|
|
"state": state,
|
|
"files": files,
|
|
"records": records,
|
|
}
|
|
|
|
@staticmethod
|
|
def _split(value: str) -> list[str]:
|
|
return [part.strip() for part in re.split(r"[,\n]", value) if part.strip()]
|
|
|
|
def list_design_files(self, area: str | None = None) -> dict[str, Any]:
|
|
roots = DESIGN_ROOTS
|
|
if area:
|
|
if area not in DESIGN_ROOTS:
|
|
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Area must be templates or assets/static.")
|
|
roots = (area,)
|
|
files = []
|
|
for root in roots:
|
|
base = self.guard.resolve(root, allowed_roots=DESIGN_ROOTS, must_exist=True)
|
|
for path in sorted(base.rglob("*")):
|
|
if not path.resolve().is_relative_to(self.guard.root):
|
|
raise LabyricornMcpError(
|
|
"PATH_OUTSIDE_REPOSITORY", "A design path resolves outside the repository."
|
|
)
|
|
if path.is_file() and not path.is_symlink():
|
|
files.append(
|
|
{
|
|
"path": self.guard.relative(path),
|
|
"size": path.stat().st_size,
|
|
"sha256": _hash(path.read_bytes()),
|
|
"text_readable": path.suffix.casefold() in DESIGN_SUFFIXES,
|
|
}
|
|
)
|
|
return {"count": len(files), "files": files}
|
|
|
|
def get_design_file(self, relative: str) -> dict[str, Any]:
|
|
path = self.guard.resolve(relative, allowed_roots=DESIGN_ROOTS, must_exist=True)
|
|
if path.suffix.casefold() not in DESIGN_SUFFIXES or not path.is_file():
|
|
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Only allowlisted text design files can be read.")
|
|
try:
|
|
payload = path.read_bytes()
|
|
content = payload.decode("utf-8")
|
|
except (OSError, UnicodeError) as exc:
|
|
raise LabyricornMcpError("VALIDATION_FAILED", "The design file is not readable UTF-8 text.") from exc
|
|
return {"path": self.guard.relative(path), "content": content, "sha256": _hash(payload)}
|
|
|
|
def update_design_file(self, relative: str, content: str, expected_hash: str) -> dict[str, Any]:
|
|
path = self.guard.resolve(relative, allowed_roots=DESIGN_ROOTS, must_exist=True, reject_links=True)
|
|
if path.suffix.casefold() not in DESIGN_SUFFIXES or not path.is_file():
|
|
raise LabyricornMcpError("PATH_NOT_ALLOWED", "Only existing allowlisted text design files can be updated.")
|
|
current = path.read_bytes()
|
|
actual = _hash(current)
|
|
if actual != expected_hash:
|
|
raise LabyricornMcpError("FILE_CHANGED", "The design file changed after it was read.", {"actual_sha256": actual})
|
|
payload = content.encode("utf-8")
|
|
temporary: Path | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="wb", prefix=f".{path.name}.labyricorn-mcp-", dir=path.parent, delete=False
|
|
) as output:
|
|
temporary = Path(output.name)
|
|
output.write(payload)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
if path.read_bytes() != current:
|
|
temporary.unlink(missing_ok=True)
|
|
raise LabyricornMcpError(
|
|
"FILE_CHANGED", "The design file changed while the update was being prepared."
|
|
)
|
|
os.chmod(temporary, path.stat().st_mode)
|
|
os.replace(temporary, path)
|
|
except LabyricornMcpError:
|
|
raise
|
|
except OSError as exc:
|
|
if temporary is not None:
|
|
temporary.unlink(missing_ok=True)
|
|
raise LabyricornMcpError("VALIDATION_FAILED", "The design file could not be updated.") from exc
|
|
return {"path": self.guard.relative(path), "sha256": _hash(payload), "changed": payload != current}
|