mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
b247942e1f
Slash commands now travel with .scarftemplate bundles. Schema bumps to v3 when a manifest declares contents.slashCommands; v1/v2 bundles keep parsing unchanged. Swift side: - TemplateContents gains slashCommands: [String]? — names only. Bundle layout: slash-commands/<name>.md at the root. - ProjectTemplateService.buildInstallPlan copies each claimed name into <projectDir>/.scarf/slash-commands/<name>.md. - ProjectTemplateService.verifyClaims cross-checks: each name must pass ProjectSlashCommand.validateName, the file must exist, and the bundle can't contain unclaimed slash-commands/ files. - TemplateLock gains slashCommandFiles: [String]? (relative to project root). The uninstaller's existing tracked-file logic removes them; user-authored slash commands in the same dir survive (they're not in the lock). - ProjectTemplateExporter scans <project>/.scarf/slash-commands/ on export and copies each .md into the bundle root, populating the manifest contents claim. SchemaVersion bumps to 3 only when slash commands are present. Python catalog validator (tools/build-catalog.py): - SUPPORTED_SCHEMA_VERSIONS gains 3. - SLASH_COMMAND_NAME_RE mirrors the Swift validation pattern. - _validate_contents_claim picks up slashCommands: rejects malformed names, missing files, and unclaimed extras with the same error shapes the Swift verifier uses. Tests: - 4 new test_build_catalog cases. 28/28 catalog tests pass. - ProjectTemplateTests literal updated for the new TemplateContents field. Verified: Mac + iOS builds succeed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
831 lines
34 KiB
Python
Executable File
831 lines
34 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Scarf template catalog builder + validator.
|
|
|
|
Walks every `templates/<author>/<name>/` in this repo, validates the
|
|
`.scarftemplate` bundle against its manifest claim (same invariants the
|
|
Swift `ProjectTemplateService.verifyClaims` enforces at install time), and
|
|
produces:
|
|
|
|
templates/catalog.json aggregate index for the site
|
|
.gh-pages-worktree/templates/... per-template HTML + dashboard.json
|
|
(only produced by --build / --publish)
|
|
|
|
This is stdlib-only Python so it runs in a GitHub Action with zero
|
|
dependencies and in under a second even when the catalog has thousands of
|
|
templates. Schema drift between this validator and the Swift installer
|
|
breaks one of two contracts — add a failing test in both places when you
|
|
change anything here.
|
|
|
|
Usage:
|
|
tools/build-catalog.py --check validate; no output written
|
|
tools/build-catalog.py --build validate + write catalog.json + site
|
|
tools/build-catalog.py --preview DIR render a self-contained preview
|
|
site into DIR (for local viewing)
|
|
|
|
Exit codes:
|
|
0 success
|
|
1 validation failure (one or more templates rejected)
|
|
2 IO / usage error
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import sys
|
|
import zipfile
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Schema + invariants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
|
|
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
|
|
SCHEMA_VERSION_V3 = 3 # v2.5 — adds optional contents.slashCommands block
|
|
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2, SCHEMA_VERSION_V3}
|
|
|
|
# Slash command names: lowercase letters, digits, hyphens; must start
|
|
# with a letter. Mirrors `ProjectSlashCommand.validNamePattern` on the
|
|
# Swift side so catalog ↔ installer round-trip is byte-clean.
|
|
SLASH_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
|
|
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
|
|
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
|
|
|
|
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
|
|
# for error messages that echo this set.
|
|
SUPPORTED_CONFIG_FIELD_TYPES = {"string", "text", "number", "bool", "enum", "list", "secret"}
|
|
SUPPORTED_CONFIG_LIST_ITEM_TYPES = {"string"}
|
|
|
|
# Common secret patterns — keep in sync with `scripts/wiki.sh` and reuse a
|
|
# conservative subset. The validator rejects hard matches; the site's
|
|
# CONTRIBUTING guide covers the rest.
|
|
SECRET_PATTERNS = [
|
|
(re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), "private key block"),
|
|
(re.compile(r"(?i)\bgh[pousr]_[A-Za-z0-9]{36,}"), "github personal access token"),
|
|
(re.compile(r"(?i)\bxox[abpso]-[A-Za-z0-9-]{10,}"), "slack token"),
|
|
(re.compile(r"(?i)\bAKIA[0-9A-Z]{16}"), "aws access key id"),
|
|
(re.compile(r"(?i)\bsk-[A-Za-z0-9]{32,}"), "openai/anthropic api key"),
|
|
]
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Data classes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class ValidationError:
|
|
template_path: Path
|
|
message: str
|
|
|
|
def __str__(self) -> str:
|
|
# Render a repo-relative path when possible for concise CLI output;
|
|
# fall back to the absolute path when the template lives outside
|
|
# the repo tree (unit tests use temp dirs).
|
|
try:
|
|
rel: Path | str = self.template_path.relative_to(REPO_ROOT)
|
|
except ValueError:
|
|
rel = self.template_path
|
|
return f"{rel}: {self.message}"
|
|
|
|
|
|
@dataclass
|
|
class TemplateRecord:
|
|
"""One entry in the generated catalog.json. Mirrors the Swift
|
|
ProjectTemplateManifest but with a few derived fields added."""
|
|
|
|
path: Path
|
|
manifest: dict
|
|
bundle_path: Path
|
|
bundle_sha256: str
|
|
bundle_size: int
|
|
install_url: str
|
|
detail_slug: str
|
|
|
|
def to_catalog_entry(self) -> dict:
|
|
"""Subset suitable for catalog.json. Keep fields stable — the
|
|
site's widgets.js reads this shape. The optional `config` key
|
|
mirrors the manifest's `config` block so the site can render
|
|
the Configuration section on the detail page."""
|
|
m = self.manifest
|
|
return {
|
|
"id": m["id"],
|
|
"name": m["name"],
|
|
"version": m["version"],
|
|
"description": m["description"],
|
|
"author": m.get("author"),
|
|
"category": m.get("category"),
|
|
"tags": m.get("tags") or [],
|
|
"contents": m["contents"],
|
|
"config": m.get("config"), # None for schema-less
|
|
"installUrl": self.install_url,
|
|
"detailSlug": self.detail_slug,
|
|
"bundleSha256": self.bundle_sha256,
|
|
"bundleSize": self.bundle_size,
|
|
"minScarfVersion": m.get("minScarfVersion"),
|
|
"minHermesVersion": m.get("minHermesVersion"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Validation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def manifest_slug(manifest_id: str) -> str:
|
|
"""Mirror of Swift `ProjectTemplateManifest.slug`. Non-alphanumeric
|
|
runs collapse to single hyphens; empty collapses to 'template'."""
|
|
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", manifest_id).strip("-")
|
|
return cleaned or "template"
|
|
|
|
|
|
def _iter_templates(repo_root: Path) -> Iterable[Path]:
|
|
"""Yield every `templates/<author>/<name>/` directory (those that hold
|
|
a `template.json` or a built `.scarftemplate`). Authors whose dirs
|
|
only hold a README are silently skipped."""
|
|
root = repo_root / "templates"
|
|
if not root.is_dir():
|
|
return
|
|
for author_dir in sorted(root.iterdir()):
|
|
if not author_dir.is_dir() or author_dir.name.startswith("."):
|
|
continue
|
|
for template_dir in sorted(author_dir.iterdir()):
|
|
if not template_dir.is_dir():
|
|
continue
|
|
if (template_dir / "staging").is_dir():
|
|
yield template_dir
|
|
|
|
|
|
def _validate_manifest(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
|
|
required = ["schemaVersion", "id", "name", "version", "description", "contents"]
|
|
for field in required:
|
|
if field not in manifest:
|
|
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
|
|
if manifest.get("schemaVersion") not in SUPPORTED_SCHEMA_VERSIONS:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"unsupported schemaVersion: {manifest.get('schemaVersion')} "
|
|
f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})"
|
|
))
|
|
# Manifest id must match the directory layout.
|
|
mid = manifest.get("id", "")
|
|
if "/" not in mid:
|
|
errors.append(ValidationError(template_dir, f"manifest id must be owner/name, got {mid!r}"))
|
|
else:
|
|
expected_author = template_dir.parent.name
|
|
author_part, _, _ = mid.partition("/")
|
|
if author_part != expected_author:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"manifest id {mid!r} author component does not match directory "
|
|
f"({expected_author!r})"
|
|
))
|
|
|
|
|
|
def _validate_contents_claim(
|
|
manifest: dict,
|
|
bundle_files: set[str],
|
|
cron_job_count: int,
|
|
template_dir: Path,
|
|
errors: list[ValidationError],
|
|
) -> None:
|
|
"""Mirrors Swift `ProjectTemplateService.verifyClaims`. Rejects any
|
|
mismatch between what the manifest says and what's actually in the
|
|
bundle so the catalog site can't misrepresent a template."""
|
|
contents = manifest.get("contents", {})
|
|
|
|
for required in REQUIRED_BUNDLE_FILES:
|
|
if required not in bundle_files:
|
|
errors.append(ValidationError(template_dir, f"bundle missing required file: {required}"))
|
|
|
|
# Optional instructions/ dir — claim must match presence exactly.
|
|
claimed_instructions = contents.get("instructions") or []
|
|
claimed_full = {f"instructions/{p}" for p in claimed_instructions}
|
|
present_instructions = {f for f in bundle_files if f.startswith("instructions/")}
|
|
for claim in claimed_full:
|
|
if claim not in bundle_files:
|
|
errors.append(ValidationError(template_dir, f"contents.instructions claims {claim} but file is missing"))
|
|
for present in present_instructions - claimed_full:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"bundle has {present} but it's not listed in contents.instructions"
|
|
))
|
|
|
|
# Skills — each claimed skill name must exist as a subdir with at least
|
|
# one file; extra skill dirs not listed are rejected.
|
|
claimed_skills = set(contents.get("skills") or [])
|
|
present_skills = set()
|
|
for f in bundle_files:
|
|
if f.startswith("skills/"):
|
|
rest = f[len("skills/"):]
|
|
if "/" in rest:
|
|
present_skills.add(rest.split("/", 1)[0])
|
|
for skill in claimed_skills:
|
|
if not any(f.startswith(f"skills/{skill}/") for f in bundle_files):
|
|
errors.append(ValidationError(template_dir, f"contents.skills claims {skill!r} but skills/{skill}/ is empty"))
|
|
for extra in present_skills - claimed_skills:
|
|
errors.append(ValidationError(template_dir, f"bundle has skills/{extra}/ not listed in contents.skills"))
|
|
|
|
# Cron — numeric count must match bundle.
|
|
claimed_cron = int(contents.get("cron") or 0)
|
|
if claimed_cron != cron_job_count:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"contents.cron={claimed_cron} but bundle contains {cron_job_count} cron jobs"
|
|
))
|
|
|
|
# Memory appendix — claim must match file presence.
|
|
claimed_memory = bool((contents.get("memory") or {}).get("append"))
|
|
has_memory_file = "memory/append.md" in bundle_files
|
|
if claimed_memory != has_memory_file:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"contents.memory.append={claimed_memory} disagrees with memory/append.md presence={has_memory_file}"
|
|
))
|
|
|
|
# Config (schemaVersion 2+) — claim field-count must match schema
|
|
# field count. `None`/`0` on both sides means schema-less, which is
|
|
# always legal.
|
|
claimed_config = int(contents.get("config") or 0)
|
|
schema = manifest.get("config")
|
|
schema_field_count = len((schema or {}).get("schema") or []) if schema else 0
|
|
if claimed_config != schema_field_count:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"contents.config={claimed_config} but config.schema has {schema_field_count} field(s)"
|
|
))
|
|
|
|
# Slash commands (schemaVersion 3+) — each claimed name must match
|
|
# SLASH_COMMAND_NAME_RE and have a corresponding `slash-commands/<n>.md`
|
|
# file at the bundle root. Extra slash-commands/ files not in the
|
|
# claim list are rejected. Mirrors Swift's verifyClaims slash-command
|
|
# check.
|
|
claimed_slash = contents.get("slashCommands")
|
|
if claimed_slash is not None:
|
|
if not isinstance(claimed_slash, list):
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
"contents.slashCommands must be a list of names"
|
|
))
|
|
claimed_slash = []
|
|
claimed_full = set()
|
|
for name in claimed_slash:
|
|
if not isinstance(name, str) or not SLASH_COMMAND_NAME_RE.match(name):
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"contents.slashCommands name {name!r} must match {SLASH_COMMAND_NAME_RE.pattern}"
|
|
))
|
|
continue
|
|
path = f"slash-commands/{name}.md"
|
|
claimed_full.add(path)
|
|
if path not in bundle_files:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"contents.slashCommands claims {name!r} but {path} is missing from the bundle"
|
|
))
|
|
for present in {f for f in bundle_files if f.startswith("slash-commands/")} - claimed_full:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"bundle has {present} but it's not listed in contents.slashCommands"
|
|
))
|
|
elif any(f.startswith("slash-commands/") for f in bundle_files):
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
"bundle contains slash-commands/ files but contents.slashCommands is missing"
|
|
))
|
|
|
|
|
|
def _validate_config_schema(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
|
|
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
|
|
invariants only — user-value validation happens in the app at
|
|
commit time, not at catalog-build time."""
|
|
schema = manifest.get("config")
|
|
if schema is None:
|
|
return
|
|
if not isinstance(schema, dict):
|
|
errors.append(ValidationError(template_dir, "manifest.config must be an object"))
|
|
return
|
|
fields = schema.get("schema")
|
|
if not isinstance(fields, list):
|
|
errors.append(ValidationError(template_dir, "manifest.config.schema must be a list"))
|
|
return
|
|
|
|
seen_keys: set[str] = set()
|
|
for i, field in enumerate(fields):
|
|
if not isinstance(field, dict):
|
|
errors.append(ValidationError(template_dir, f"config.schema[{i}] must be an object"))
|
|
continue
|
|
key = field.get("key")
|
|
ftype = field.get("type")
|
|
label = field.get("label")
|
|
if not isinstance(key, str) or not key:
|
|
errors.append(ValidationError(template_dir, f"config.schema[{i}] missing/empty key"))
|
|
continue
|
|
if key in seen_keys:
|
|
errors.append(ValidationError(template_dir, f"config.schema has duplicate key: {key!r}"))
|
|
continue
|
|
seen_keys.add(key)
|
|
if not isinstance(label, str) or not label:
|
|
errors.append(ValidationError(template_dir, f"config.schema[{key}] missing/empty label"))
|
|
if ftype not in SUPPORTED_CONFIG_FIELD_TYPES:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] uses unsupported type {ftype!r} "
|
|
f"(supported: {sorted(SUPPORTED_CONFIG_FIELD_TYPES)})"
|
|
))
|
|
continue
|
|
# Type-specific rules.
|
|
if ftype == "enum":
|
|
options = field.get("options") or []
|
|
if not isinstance(options, list) or not options:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] (enum) must declare at least one option"
|
|
))
|
|
else:
|
|
seen_values: set[str] = set()
|
|
for opt in options:
|
|
if not isinstance(opt, dict):
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] option must be an object"
|
|
))
|
|
continue
|
|
val = opt.get("value")
|
|
if not isinstance(val, str) or not val:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] option missing/empty value"
|
|
))
|
|
continue
|
|
if val in seen_values:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] has duplicate option value: {val!r}"
|
|
))
|
|
seen_values.add(val)
|
|
elif ftype == "list":
|
|
item_type = field.get("itemType", "string")
|
|
if item_type not in SUPPORTED_CONFIG_LIST_ITEM_TYPES:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] (list) uses unsupported itemType {item_type!r}"
|
|
))
|
|
elif ftype == "secret":
|
|
if "default" in field:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"config.schema[{key}] is a secret field and must not declare a default"
|
|
))
|
|
# modelRecommendation — preferred must be non-empty when present.
|
|
rec = schema.get("modelRecommendation")
|
|
if rec is not None:
|
|
if not isinstance(rec, dict):
|
|
errors.append(ValidationError(template_dir, "config.modelRecommendation must be an object"))
|
|
else:
|
|
preferred = rec.get("preferred")
|
|
if not isinstance(preferred, str) or not preferred.strip():
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
"config.modelRecommendation.preferred must be a non-empty string"
|
|
))
|
|
|
|
|
|
def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
|
"""Decode dashboard.json against the widget-type vocabulary the Swift
|
|
renderer knows. An unknown widget type means the app will render an
|
|
'unknown widget' placeholder — that's a bad catalog experience."""
|
|
try:
|
|
dashboard = json.loads(zf.read("dashboard.json"))
|
|
except Exception as e:
|
|
errors.append(ValidationError(template_dir, f"dashboard.json failed to parse: {e}"))
|
|
return
|
|
if dashboard.get("version") != 1:
|
|
errors.append(ValidationError(template_dir, f"dashboard.version must be 1, got {dashboard.get('version')}"))
|
|
sections = dashboard.get("sections") or []
|
|
if not isinstance(sections, list):
|
|
errors.append(ValidationError(template_dir, "dashboard.sections must be a list"))
|
|
return
|
|
for section in sections:
|
|
for widget in section.get("widgets") or []:
|
|
widget_type = widget.get("type")
|
|
if widget_type not in SUPPORTED_WIDGET_TYPES:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"dashboard widget {widget.get('title')!r} has unknown type {widget_type!r}"
|
|
))
|
|
|
|
|
|
def _scan_for_secrets(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
|
"""Refuse bundles containing obvious secret patterns. Conservative —
|
|
matches only high-confidence substrings (no keyword-only warnings)."""
|
|
for info in zf.infolist():
|
|
if info.is_dir() or info.file_size > 256 * 1024:
|
|
continue # skip big binaries
|
|
try:
|
|
data = zf.read(info.filename).decode("utf-8", errors="replace")
|
|
except Exception:
|
|
continue
|
|
for pattern, label in SECRET_PATTERNS:
|
|
if pattern.search(data):
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"bundle file {info.filename} matches {label} pattern — refusing"
|
|
))
|
|
break
|
|
|
|
|
|
def _parse_cron_jobs(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> int:
|
|
"""Parse cron/jobs.json if present; return the job count. Logs a
|
|
validation error on a malformed file."""
|
|
if "cron/jobs.json" not in set(zf.namelist()):
|
|
return 0
|
|
try:
|
|
data = json.loads(zf.read("cron/jobs.json"))
|
|
except Exception as e:
|
|
errors.append(ValidationError(template_dir, f"cron/jobs.json failed to parse: {e}"))
|
|
return 0
|
|
if not isinstance(data, list):
|
|
errors.append(ValidationError(template_dir, "cron/jobs.json must be a JSON array"))
|
|
return 0
|
|
for i, job in enumerate(data):
|
|
if not isinstance(job, dict):
|
|
errors.append(ValidationError(template_dir, f"cron/jobs.json[{i}] must be an object"))
|
|
continue
|
|
if "name" not in job or "schedule" not in job:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"cron/jobs.json[{i}] missing required field (name, schedule)"
|
|
))
|
|
return len(data)
|
|
|
|
|
|
def _bundle_files(zf: zipfile.ZipFile) -> set[str]:
|
|
"""Unique regular-file paths in the bundle, excluding dir entries and
|
|
macOS __MACOSX/ metadata."""
|
|
return {
|
|
info.filename
|
|
for info in zf.infolist()
|
|
if not info.is_dir() and not info.filename.startswith("__MACOSX/")
|
|
}
|
|
|
|
|
|
def validate_template(template_dir: Path) -> tuple[TemplateRecord | None, list[ValidationError]]:
|
|
"""Validate one template dir and return a (record, errors) pair.
|
|
record is None when errors are fatal enough that we can't build a
|
|
catalog entry at all."""
|
|
errors: list[ValidationError] = []
|
|
|
|
# Find the bundle. By convention it's `<dir>/<dir-basename>.scarftemplate`
|
|
# or any single .scarftemplate in the dir.
|
|
bundles = sorted(template_dir.glob("*.scarftemplate"))
|
|
if not bundles:
|
|
errors.append(ValidationError(template_dir, "no .scarftemplate found in template directory"))
|
|
return None, errors
|
|
if len(bundles) > 1:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"more than one .scarftemplate present: {[b.name for b in bundles]}"
|
|
))
|
|
bundle_path = bundles[0]
|
|
|
|
bundle_size = bundle_path.stat().st_size
|
|
if bundle_size > MAX_BUNDLE_BYTES:
|
|
errors.append(ValidationError(
|
|
template_dir,
|
|
f"bundle size {bundle_size} exceeds catalog cap of {MAX_BUNDLE_BYTES} bytes"
|
|
))
|
|
|
|
try:
|
|
with zipfile.ZipFile(bundle_path, "r") as zf:
|
|
bundle_files = _bundle_files(zf)
|
|
if "template.json" not in bundle_files:
|
|
errors.append(ValidationError(template_dir, "bundle is missing template.json"))
|
|
return None, errors
|
|
try:
|
|
manifest = json.loads(zf.read("template.json"))
|
|
except Exception as e:
|
|
errors.append(ValidationError(template_dir, f"template.json failed to parse: {e}"))
|
|
return None, errors
|
|
|
|
_validate_manifest(manifest, template_dir, errors)
|
|
_validate_config_schema(manifest, template_dir, errors)
|
|
cron_count = _parse_cron_jobs(zf, template_dir, errors)
|
|
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
|
|
_validate_dashboard(zf, template_dir, errors)
|
|
_scan_for_secrets(zf, template_dir, errors)
|
|
except zipfile.BadZipFile:
|
|
errors.append(ValidationError(template_dir, "bundle is not a valid zip archive"))
|
|
return None, errors
|
|
|
|
# Compute the catalog-ready record.
|
|
sha = hashlib.sha256(bundle_path.read_bytes()).hexdigest()
|
|
author = template_dir.parent.name
|
|
short_name = template_dir.name
|
|
install_url = (
|
|
"https://raw.githubusercontent.com/awizemann/scarf/main/"
|
|
f"templates/{author}/{short_name}/{bundle_path.name}"
|
|
)
|
|
detail_slug = manifest_slug(manifest.get("id", f"{author}/{short_name}"))
|
|
|
|
record = TemplateRecord(
|
|
path=template_dir,
|
|
manifest=manifest,
|
|
bundle_path=bundle_path,
|
|
bundle_sha256=sha,
|
|
bundle_size=bundle_size,
|
|
install_url=install_url,
|
|
detail_slug=detail_slug,
|
|
)
|
|
return record, errors
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Staging/bundle drift check — keeps authors honest
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _check_staging_matches_bundle(record: TemplateRecord) -> list[ValidationError]:
|
|
"""If the template dir has a staging/ source tree, rebuild the bundle
|
|
in memory and diff against the committed one. Catches the common
|
|
failure mode of an author editing staging/ but forgetting to
|
|
regenerate the .scarftemplate."""
|
|
errors: list[ValidationError] = []
|
|
staging = record.path / "staging"
|
|
if not staging.is_dir():
|
|
return errors
|
|
|
|
committed = {}
|
|
with zipfile.ZipFile(record.bundle_path, "r") as zf:
|
|
for info in zf.infolist():
|
|
if info.is_dir() or info.filename.startswith("__MACOSX/"):
|
|
continue
|
|
committed[info.filename] = zf.read(info.filename)
|
|
|
|
source = {}
|
|
for path in staging.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
rel = path.relative_to(staging).as_posix()
|
|
if rel.startswith(".") or "/.DS_Store" in rel or rel.endswith("/.DS_Store") or rel == ".DS_Store":
|
|
continue
|
|
source[rel] = path.read_bytes()
|
|
|
|
missing_in_bundle = sorted(set(source) - set(committed))
|
|
if missing_in_bundle:
|
|
errors.append(ValidationError(
|
|
record.path,
|
|
f"staging has files not in the built bundle: {missing_in_bundle} "
|
|
"(rebuild with `zip -qq -r <name>.scarftemplate .` from staging/)"
|
|
))
|
|
missing_in_source = sorted(set(committed) - set(source))
|
|
if missing_in_source:
|
|
errors.append(ValidationError(
|
|
record.path,
|
|
f"bundle has files not in staging/: {missing_in_source} "
|
|
"(commit them to staging/ or rebuild the bundle from staging/)"
|
|
))
|
|
diff = [name for name, data in source.items() if name in committed and committed[name] != data]
|
|
if diff:
|
|
errors.append(ValidationError(
|
|
record.path,
|
|
f"staging content differs from built bundle: {diff} "
|
|
"(rebuild the bundle from staging/)"
|
|
))
|
|
return errors
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build: write catalog.json (site rendering comes in a later commit)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
|
|
catalog = {
|
|
# The aggregate catalog itself is versioned independently of
|
|
# individual bundle manifests — bumping template manifest schema
|
|
# from 1 → 2 doesn't change the catalog.json shape.
|
|
"schemaVersion": 1,
|
|
"generated": True, # human reminder; a timestamp would churn the diff every run
|
|
"templates": [r.to_catalog_entry() for r in records],
|
|
}
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text(json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument("--check", action="store_true", help="validate every template; don't write output")
|
|
group.add_argument("--build", action="store_true", help="validate + write catalog.json")
|
|
group.add_argument("--preview", metavar="DIR", help="render a self-contained site preview into DIR")
|
|
parser.add_argument("--only", metavar="PATH", action="append", default=[],
|
|
help="validate only the given template dir (may repeat); useful for PR-diff runs")
|
|
parser.add_argument("--repo", metavar="PATH", default=str(REPO_ROOT),
|
|
help="repo root to operate on (default: auto-detect)")
|
|
args = parser.parse_args(argv)
|
|
|
|
repo_root = Path(args.repo).resolve()
|
|
template_dirs = list(_iter_templates(repo_root))
|
|
if args.only:
|
|
only = {Path(p).resolve() for p in args.only}
|
|
template_dirs = [t for t in template_dirs if t.resolve() in only]
|
|
|
|
if not template_dirs:
|
|
if args.only:
|
|
print(f"no templates matched --only filter", file=sys.stderr)
|
|
return 2
|
|
print("no templates found under templates/ — nothing to do", file=sys.stderr)
|
|
return 0
|
|
|
|
records: list[TemplateRecord] = []
|
|
all_errors: list[ValidationError] = []
|
|
for tdir in template_dirs:
|
|
record, errors = validate_template(tdir)
|
|
all_errors.extend(errors)
|
|
if record is not None:
|
|
all_errors.extend(_check_staging_matches_bundle(record))
|
|
records.append(record)
|
|
|
|
if all_errors:
|
|
print(f"✗ {len(all_errors)} validation error(s):", file=sys.stderr)
|
|
for err in all_errors:
|
|
print(f" {err}", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"✓ {len(records)} template(s) validated", file=sys.stderr)
|
|
for r in records:
|
|
rel = r.path.relative_to(repo_root)
|
|
print(f" {rel} — {r.manifest['id']} v{r.manifest['version']}")
|
|
|
|
if args.check:
|
|
return 0
|
|
|
|
catalog_path = repo_root / "templates" / "catalog.json"
|
|
write_catalog_json(records, catalog_path)
|
|
print(f"wrote {catalog_path.relative_to(repo_root)}", file=sys.stderr)
|
|
|
|
if args.preview:
|
|
preview_dir = Path(args.preview).resolve()
|
|
render_site(records, preview_dir, repo_root)
|
|
print(f"preview site rendered to {preview_dir}", file=sys.stderr)
|
|
|
|
if args.build:
|
|
# --build renders into .gh-pages-worktree/templates/ so the
|
|
# maintainer's publish step just has to commit + push gh-pages.
|
|
gh_pages = repo_root / ".gh-pages-worktree" / "templates"
|
|
render_site(records, gh_pages, repo_root)
|
|
print(f"site rendered to {gh_pages.relative_to(repo_root)}", file=sys.stderr)
|
|
|
|
return 0
|
|
|
|
|
|
def render_site(records: list[TemplateRecord], out_dir: Path, repo_root: Path) -> None:
|
|
"""Render the catalog site. Defined here as a stub so --build and
|
|
--preview both have a landing spot; the real HTML templates ship in
|
|
the next commit (Phase 3)."""
|
|
site_src = repo_root / "site"
|
|
if not site_src.is_dir():
|
|
# Phase 2: no site/ yet. Write just catalog.json into out_dir so
|
|
# the preview mode is still demonstrable (and --build stays
|
|
# idempotent).
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
write_catalog_json(records, out_dir / "catalog.json")
|
|
return
|
|
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
index_tmpl = (site_src / "index.html.tmpl").read_text(encoding="utf-8")
|
|
template_tmpl = (site_src / "template.html.tmpl").read_text(encoding="utf-8")
|
|
|
|
# Copy static site assets (widgets.js, styles.css, assets/).
|
|
for name in ("widgets.js", "styles.css"):
|
|
src = site_src / name
|
|
if src.exists():
|
|
shutil.copy2(src, out_dir / name)
|
|
assets_src = site_src / "assets"
|
|
if assets_src.is_dir():
|
|
assets_dst = out_dir / "assets"
|
|
if assets_dst.exists():
|
|
shutil.rmtree(assets_dst)
|
|
shutil.copytree(assets_src, assets_dst)
|
|
|
|
# Catalog index
|
|
(out_dir / "index.html").write_text(
|
|
render_index(index_tmpl, records),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
# Per-template detail pages + dashboard.json copies
|
|
for r in records:
|
|
detail_dir = out_dir / r.detail_slug
|
|
detail_dir.mkdir(parents=True, exist_ok=True)
|
|
(detail_dir / "index.html").write_text(
|
|
render_detail(template_tmpl, r),
|
|
encoding="utf-8",
|
|
)
|
|
# Copy the unpacked dashboard.json, README.md, and template.json
|
|
# (as manifest.json so the site can fetch the config schema for
|
|
# the Configuration section without conflicting with any file
|
|
# named `template.json` somewhere else in the served tree).
|
|
with zipfile.ZipFile(r.bundle_path, "r") as zf:
|
|
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
|
|
if "README.md" in zf.namelist():
|
|
(detail_dir / "README.md").write_bytes(zf.read("README.md"))
|
|
# Only copy the manifest when the template has a config
|
|
# schema — avoids bloating the served tree for schema-less
|
|
# templates and makes the 404 fallback in widgets.js a
|
|
# meaningful signal ("no config to show here").
|
|
if r.manifest.get("config"):
|
|
(detail_dir / "manifest.json").write_bytes(zf.read("template.json"))
|
|
|
|
# The aggregate catalog.json is copied in so the frontend can fetch
|
|
# /templates/catalog.json without reaching back into the repo.
|
|
write_catalog_json(records, out_dir / "catalog.json")
|
|
|
|
|
|
def render_index(tmpl: str, records: list[TemplateRecord]) -> str:
|
|
"""Very light string substitution — the site's JS does most of the
|
|
rendering from catalog.json at page load."""
|
|
cards = []
|
|
for r in records:
|
|
m = r.manifest
|
|
author = (m.get("author") or {}).get("name", "")
|
|
tags_html = "".join(f'<span class="tag">{t}</span>' for t in (m.get("tags") or []))
|
|
cards.append(
|
|
'<a class="card" href="{slug}/">'
|
|
'<h3>{name}</h3>'
|
|
'<p class="desc">{desc}</p>'
|
|
'<div class="meta"><span class="author">{author}</span>'
|
|
'<span class="version">v{version}</span></div>'
|
|
'<div class="tags">{tags}</div>'
|
|
'</a>'.format(
|
|
slug=_html_escape(r.detail_slug),
|
|
name=_html_escape(m["name"]),
|
|
desc=_html_escape(m["description"]),
|
|
author=_html_escape(author),
|
|
version=_html_escape(m["version"]),
|
|
tags=tags_html,
|
|
)
|
|
)
|
|
count = len(records)
|
|
return (
|
|
tmpl.replace("{{CARDS}}", "\n".join(cards))
|
|
.replace("{{COUNT}}", str(count))
|
|
.replace("{{COUNT_PLURAL}}", "" if count == 1 else "s")
|
|
)
|
|
|
|
|
|
def render_detail(tmpl: str, record: TemplateRecord) -> str:
|
|
m = record.manifest
|
|
author = m.get("author") or {}
|
|
author_html = _html_escape(author.get("name", ""))
|
|
author_url = author.get("url") or ""
|
|
if author_url:
|
|
author_html = f'<a href="{_html_escape(author_url)}">{author_html}</a>'
|
|
tags_html = "".join(f'<span class="tag">{_html_escape(t)}</span>' for t in (m.get("tags") or []))
|
|
install_url = record.install_url
|
|
tokens = {
|
|
"ID": m["id"],
|
|
"NAME": m["name"],
|
|
"VERSION": m["version"],
|
|
"DESC": m["description"],
|
|
"AUTHOR_HTML": author_html,
|
|
"CATEGORY": m.get("category") or "",
|
|
"TAGS_HTML": tags_html,
|
|
"INSTALL_URL_ENCODED": install_url,
|
|
"SCARF_INSTALL_URL": f"scarf://install?url={install_url}",
|
|
}
|
|
out = tmpl
|
|
for k, v in tokens.items():
|
|
out = out.replace("{{" + k + "}}", _html_escape(v) if k != "TAGS_HTML" and k != "AUTHOR_HTML" else v)
|
|
return out
|
|
|
|
|
|
def _html_escape(s: str) -> str:
|
|
return (
|
|
s.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
.replace("'", "'")
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|