feat(catalog-config): mirror manifest v2 schema in validator + site

Phase D of v2.3 template configuration — closes the loop between the
Swift app and the catalog pipeline. Authors can now ship schemaful
bundles; the Python validator enforces the same invariants the Swift
installer does; the catalog site displays the schema so visitors see
what they'll need to configure before installing.

Python validator (tools/build-catalog.py):
- SUPPORTED_SCHEMA_VERSIONS accepts both 1 and 2 (v1 bundles are
  unchanged; v2 adds optional manifest.config).
- New _validate_config_schema function mirrors the Swift
  ProjectConfigService.validateSchema rules: unique keys, supported
  types, enum option presence + unique values, list itemType ==
  "string", secret-field cannot declare a default,
  modelRecommendation.preferred non-empty when present.
- _validate_contents_claim cross-checks contents.config (field count)
  against config.schema actual length — mismatch refused.
- TemplateRecord.to_catalog_entry exposes `config` in catalog.json so
  the site can render the schema.
- render_site copies each bundle's template.json to the detail dir as
  manifest.json (only when the manifest has a config block — keeps
  the served tree lean and makes "no manifest.json" a meaningful
  404 signal in the frontend).
- catalog.json's own schemaVersion stays at 1 (independent of per-
  template manifest schemaVersion).

Python tests (tools/test_build_catalog.py): 8 new cases in a new
ConfigSchemaValidationTests suite — accepts schemaful bundle, rejects
duplicate keys, rejects secret-with-default, rejects enum-without-
options, rejects unsupported field type, rejects contents.config
count mismatch, rejects unsupported list itemType, legacy v1
manifests pass unchanged. 24/24 Python tests total.

Site (site/widgets.js):
- New renderConfigSchema(container, config) — mirrors the display
  on the Scarf install preview. Renders each field as a <dt>/<dd>
  pair with type + required badges; enum shows choice labels; list
  fields show min/max bounds; string fields show pattern/length;
  secret fields get a "Stored in Keychain" reassurance. Optional
  modelRecommendation panel at the bottom with preferred + rationale
  + alternatives.
- The renderer is display-only — the site never collects values;
  that's the Scarf app's job.

template.html.tmpl adds a #config-schema <section>. The inline script
fetches manifest.json from the detail dir; on success hands the
config block to ScarfWidgets.renderConfigSchema; on 404 (schema-less
templates) silently leaves the section empty. CSS in styles.css
adds a config-schema panel matching the accent-green aesthetic.

24/24 Python + 50/50 Swift tests pass. site-status-checker still
renders correctly (schema-less; manifest.json isn't copied for it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-23 02:00:34 +02:00
parent f8c086ee7a
commit 68f6b98fcf
6 changed files with 555 additions and 10 deletions
+141 -7
View File
@@ -45,11 +45,18 @@ from typing import Iterable
# Schema + invariants
# ---------------------------------------------------------------------------
SCHEMA_VERSION = 1
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2}
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.
@@ -100,7 +107,9 @@ class TemplateRecord:
def to_catalog_entry(self) -> dict:
"""Subset suitable for catalog.json. Keep fields stable — the
site's widgets.js reads this shape."""
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"],
@@ -111,6 +120,7 @@ class TemplateRecord:
"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,
@@ -154,8 +164,12 @@ def _validate_manifest(manifest: dict, template_dir: Path, errors: list[Validati
for field in required:
if field not in manifest:
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
if manifest.get("schemaVersion") != SCHEMA_VERSION:
errors.append(ValidationError(template_dir, f"unsupported schemaVersion: {manifest.get('schemaVersion')}"))
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:
@@ -232,6 +246,114 @@ def _validate_contents_claim(
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)"
))
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
@@ -351,6 +473,7 @@ def validate_template(template_dir: Path) -> tuple[TemplateRecord | None, list[V
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)
@@ -443,7 +566,10 @@ def _check_staging_matches_bundle(record: TemplateRecord) -> list[ValidationErro
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
catalog = {
"schemaVersion": SCHEMA_VERSION,
# 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],
}
@@ -567,12 +693,20 @@ def render_site(records: list[TemplateRecord], out_dir: Path, repo_root: Path) -
render_detail(template_tmpl, r),
encoding="utf-8",
)
# Copy the unpacked dashboard.json so widgets.js can fetch it
# without cross-directory relative paths.
# 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.