diff --git a/site/styles.css b/site/styles.css index 13384db..ad9a87a 100644 --- a/site/styles.css +++ b/site/styles.css @@ -233,6 +233,106 @@ h1, h2, h3 { line-height: 1.25; } padding: 24px; } +/* ---------- config schema panel (v2.3) ---------- */ + +.detail-config { margin-bottom: 32px; } +.detail-config:empty, .detail-config > div:empty { display: none; } + +.config-schema { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} +.config-schema-header { margin-top: 0; } +.config-schema-desc { + color: var(--fg-muted); + font-size: 13px; + margin-top: 4px; + margin-bottom: 16px; +} +.config-schema-list { + margin: 0; + padding: 0; + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} +.config-field-header { + display: flex; + align-items: baseline; + gap: 8px; + margin-top: 4px; + font-weight: 500; +} +.config-field-key { font-family: var(--mono); font-size: 13px; } +.config-field-type { + font-family: var(--mono); + font-size: 11px; + padding: 1px 6px; + border-radius: 10px; + background: rgba(0,0,0,0.08); + color: var(--fg-muted); +} +.config-field-required { + font-size: 11px; + color: var(--red); + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 6px; + border-radius: 10px; + background: rgba(217,83,79,0.12); +} +.config-field-body { + margin: 0 0 4px 0; + padding-left: 0; + font-size: 14px; +} +.config-field-label { + font-size: 14px; + margin-bottom: 2px; +} +.config-field-description { + color: var(--fg-muted); + font-size: 13px; + margin-bottom: 4px; +} +.config-field-constraint { + font-size: 12px; + color: var(--fg-muted); + font-style: italic; +} + +.config-model-rec { + margin-top: 20px; + padding: 14px 16px; + border-radius: var(--radius); + background: rgba(42,168,118,0.08); + border: 1px solid rgba(42,168,118,0.2); +} +.config-model-label { + font-size: 11px; + color: var(--accent-dark); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; + margin-bottom: 4px; +} +.config-model-preferred { + font-family: var(--mono); + font-size: 14px; + margin-bottom: 4px; +} +.config-model-rationale { + color: var(--fg-muted); + font-size: 13px; +} +.config-model-alternatives { + color: var(--fg-muted); + font-size: 12px; + margin-top: 4px; +} + /* ---------- dashboard preview ---------- */ .dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; } diff --git a/site/template.html.tmpl b/site/template.html.tmpl index 83c73cc..9f9c9d4 100644 --- a/site/template.html.tmpl +++ b/site/template.html.tmpl @@ -48,6 +48,10 @@
+
+
+
+

README

@@ -63,11 +67,14 @@ diff --git a/site/widgets.js b/site/widgets.js index a63f65f..fc72e7e 100644 --- a/site/widgets.js +++ b/site/widgets.js @@ -408,12 +408,116 @@ .replace(/'/g, "'"); } + // --------------------------------------------------------------------- + // Config-schema display (v2.3 — template configuration). + // --------------------------------------------------------------------- + // + // Renders the author-declared schema as a read-only listing on the + // catalog detail page. The site itself never collects values — the + // form UI lives inside the Scarf app. This is purely informational + // so visitors know what they'll need to fill in before installing. + + /** + * Render a manifest.config block into `container` as a summary. + * Safe to call with a null schema (no-op). + * @param {HTMLElement} container + * @param {{schema: Array, modelRecommendation?: object} | null | undefined} config + */ + function renderConfigSchema(container, config) { + container.innerHTML = ""; + if (!config || !Array.isArray(config.schema) || config.schema.length === 0) { + return; + } + const wrap = elt("div", "config-schema"); + const header = elt("h3", "config-schema-header", "Configuration"); + wrap.appendChild(header); + const desc = elt("p", "config-schema-desc", + "Fields you'll fill in during install. Secrets are stored in the macOS Keychain; non-secret values live at /.scarf/config.json."); + wrap.appendChild(desc); + + const list = elt("dl", "config-schema-list"); + for (const field of config.schema) { + const dt = elt("dt", "config-field-header"); + dt.appendChild(elt("span", "config-field-key", field.key || "")); + dt.appendChild(elt("span", "config-field-type", field.type || "")); + if (field.required) { + const req = elt("span", "config-field-required", "required"); + dt.appendChild(req); + } + list.appendChild(dt); + + const dd = elt("dd", "config-field-body"); + if (field.label) { + dd.appendChild(elt("div", "config-field-label", field.label)); + } + if (field.description) { + const descEl = elt("div", "config-field-description"); + descEl.innerHTML = renderInline(field.description); + dd.appendChild(descEl); + } + const constraint = summariseConstraint(field); + if (constraint) { + dd.appendChild(elt("div", "config-field-constraint", constraint)); + } + list.appendChild(dd); + } + wrap.appendChild(list); + + if (config.modelRecommendation) { + const rec = config.modelRecommendation; + const recBlock = elt("div", "config-model-rec"); + recBlock.appendChild(elt("div", "config-model-label", "Recommended model")); + recBlock.appendChild(elt("div", "config-model-preferred", rec.preferred || "")); + if (rec.rationale) { + recBlock.appendChild(elt("div", "config-model-rationale", rec.rationale)); + } + if (Array.isArray(rec.alternatives) && rec.alternatives.length > 0) { + recBlock.appendChild(elt("div", "config-model-alternatives", + "Also works: " + rec.alternatives.join(", "))); + } + wrap.appendChild(recBlock); + } + + container.appendChild(wrap); + } + + /** One-line human summary of a field's type-specific constraints. + * Empty string if nothing noteworthy to say. */ + function summariseConstraint(field) { + const type = field.type; + if (type === "enum") { + const opts = Array.isArray(field.options) ? field.options : []; + const values = opts.map(o => o && o.label ? o.label : (o && o.value) || "").filter(Boolean); + if (values.length > 0) return "Choices: " + values.join(", "); + } else if (type === "list") { + const min = field.minItems, max = field.maxItems; + if (min && max) return `${min}–${max} items`; + if (min) return `At least ${min} item${min === 1 ? "" : "s"}`; + if (max) return `At most ${max} item${max === 1 ? "" : "s"}`; + } else if (type === "string" || type === "text") { + if (field.pattern) return `Pattern: ${field.pattern}`; + const min = field.minLength, max = field.maxLength; + if (min && max) return `${min}–${max} characters`; + if (min) return `At least ${min} characters`; + if (max) return `At most ${max} characters`; + } else if (type === "number") { + const min = field.min, max = field.max; + if (min !== undefined && max !== undefined) return `${min}–${max}`; + if (min !== undefined) return `≥ ${min}`; + if (max !== undefined) return `≤ ${max}`; + } else if (type === "secret") { + return "Stored in the macOS Keychain on install — never in git, never in config.json."; + } + return ""; + } + // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- global.ScarfWidgets = { renderDashboard, - renderMarkdown, // exposed for the template detail page's README block + renderMarkdown, // used by the detail page's README block + renderConfigSchema, // used by the detail page's Configuration block }; })(typeof window !== "undefined" ? window : this); diff --git a/templates/catalog.json b/templates/catalog.json index 16dc397..ded59fc 100644 --- a/templates/catalog.json +++ b/templates/catalog.json @@ -10,6 +10,7 @@ "bundleSha256": "32b8c12706de8596be63dcdda32d46fc5bf478d5b9f7c1fc4c6d96ced251186a", "bundleSize": 5410, "category": "monitoring", + "config": null, "contents": { "agentsMd": true, "cron": 1, diff --git a/tools/build-catalog.py b/tools/build-catalog.py index a9c074b..ff1b2ef 100755 --- a/tools/build-catalog.py +++ b/tools/build-catalog.py @@ -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. diff --git a/tools/test_build_catalog.py b/tools/test_build_catalog.py index 7b57109..f3f855d 100644 --- a/tools/test_build_catalog.py +++ b/tools/test_build_catalog.py @@ -335,6 +335,194 @@ class ValidationTests(unittest.TestCase): return records, errors +class ConfigSchemaValidationTests(unittest.TestCase): + """Mirrors the Swift `ProjectConfigServiceTests` schema-validation + suite. Every rule enforced on the Swift side must be enforced on + the Python side — schema drift is a catastrophic failure for the + catalog (CI would accept bundles the app later refuses at install).""" + + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.repo = make_fake_repo(Path(self._dir.name)) + self.addCleanup(self._dir.cleanup) + + def _make_schema_manifest(self, fields, cron: int = 0): + """Convenience — build a v2 manifest with the given config fields.""" + return { + "schemaVersion": 2, + "id": "tester/configured", + "name": "Configured", + "version": "1.0.0", + "description": "test", + "contents": { + "dashboard": True, + "agentsMd": True, + "cron": cron, + "config": len(fields), + }, + "config": {"schema": fields}, + } + + def test_accepts_schemaful_bundle(self): + manifest = self._make_schema_manifest([ + {"key": "name", "type": "string", "label": "Name", "required": True}, + {"key": "enabled", "type": "bool", "label": "Enabled"}, + ]) + make_template_dir( + self.repo, "tester", "configured", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# readme", + "AGENTS.md": b"# agents", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + records = [] + errors = [] + for tdir in build_catalog._iter_templates(self.repo): + rec, errs = build_catalog.validate_template(tdir) + errors.extend(errs) + if rec is not None: + records.append(rec) + self.assertEqual(errors, []) + self.assertEqual(len(records), 1) + self.assertEqual(records[0].manifest["schemaVersion"], 2) + + def test_rejects_duplicate_keys(self): + manifest = self._make_schema_manifest([ + {"key": "same", "type": "string", "label": "A"}, + {"key": "same", "type": "bool", "label": "B"}, + ]) + make_template_dir( + self.repo, "tester", "dup", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertTrue(any("duplicate key" in str(e) for e in errors), errors) + + def test_rejects_secret_with_default(self): + manifest = self._make_schema_manifest([ + { + "key": "api_key", "type": "secret", "label": "API Key", + "required": True, "default": "sk-leaked-in-template" + }, + ]) + make_template_dir( + self.repo, "tester", "secret-default", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertTrue(any("must not declare a default" in str(e) for e in errors), errors) + + def test_rejects_enum_without_options(self): + manifest = self._make_schema_manifest([ + {"key": "choice", "type": "enum", "label": "Choice", "options": []}, + ]) + make_template_dir( + self.repo, "tester", "enum-empty", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertTrue(any("at least one option" in str(e) for e in errors), errors) + + def test_rejects_unsupported_field_type(self): + manifest = self._make_schema_manifest([ + {"key": "wat", "type": "hologram", "label": "W"}, + ]) + make_template_dir( + self.repo, "tester", "bad-type", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertTrue(any("unsupported type" in str(e) for e in errors), errors) + + def test_rejects_contents_config_count_mismatch(self): + # Schema has 1 field; contents.config claims 2. + manifest = self._make_schema_manifest([ + {"key": "only", "type": "string", "label": "Only"}, + ]) + manifest["contents"]["config"] = 2 + make_template_dir( + self.repo, "tester", "mismatch", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertTrue(any("contents.config=2" in str(e) for e in errors), errors) + + def test_rejects_unsupported_list_item_type(self): + manifest = self._make_schema_manifest([ + {"key": "items", "type": "list", "label": "Items", "itemType": "number"}, + ]) + make_template_dir( + self.repo, "tester", "list-type", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertTrue(any("unsupported itemType" in str(e) for e in errors), errors) + + def test_accepts_schemaless_v1_manifest_unchanged(self): + # Pre-v2.3 bundles without any config block should keep working. + manifest = { + "schemaVersion": 1, + "id": "tester/legacy", + "name": "Legacy", + "version": "1.0.0", + "description": "no config", + "contents": {"dashboard": True, "agentsMd": True}, + } + make_template_dir( + self.repo, "tester", "legacy", + manifest=manifest, + bundle_files={ + "template.json": json.dumps(manifest).encode("utf-8"), + "README.md": b"# r", "AGENTS.md": b"# a", + "dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"), + }, + ) + errors = self._collect_errors() + self.assertEqual(errors, []) + + def _collect_errors(self): + errors = [] + for tdir in build_catalog._iter_templates(self.repo): + rec, errs = build_catalog.validate_template(tdir) + errors.extend(errs) + if rec is not None: + errors.extend(build_catalog._check_staging_matches_bundle(rec)) + return errors + + class CatalogJsonTests(unittest.TestCase): """Shape of the emitted catalog.json must stay stable — the site's widgets.js reads these fields by name."""