mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de5b278da4 | |||
| fb7a80f191 | |||
| 18640293f7 | |||
| 19750597cd | |||
| 69e9cc6c7b | |||
| 03bf5262bb | |||
| 3af99d9d9c | |||
| 3bd95de8f4 | |||
| 81e8da91d6 | |||
| bb750e237e | |||
| 68f6b98fcf | |||
| f8c086ee7a | |||
| eb34aec1f1 |
@@ -105,6 +105,43 @@ Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTe
|
|||||||
|
|
||||||
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
|
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
|
||||||
|
|
||||||
|
### Template configuration (v2.3, schemaVersion 2)
|
||||||
|
|
||||||
|
Templates can declare a typed configuration schema in `template.json`'s new `config` block. The installer renders a **Configure** step between the parent-directory pick and the preview sheet; values land at `<project>/.scarf/config.json` (non-secret) and in the login Keychain (secret). A post-install **Configuration** button on the dashboard header (shown when `<project>/.scarf/manifest.json` exists) opens the same form pre-filled for editing.
|
||||||
|
|
||||||
|
Manifest shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||||
|
"config": {
|
||||||
|
"schema": [
|
||||||
|
{"key": "site_url", "type": "string", "label": "Site URL", "required": true},
|
||||||
|
{"key": "api_token", "type": "secret", "label": "API Token", "required": true}
|
||||||
|
],
|
||||||
|
"modelRecommendation": {
|
||||||
|
"preferred": "claude-sonnet-4.5",
|
||||||
|
"rationale": "Tool-heavy workload — reasoning helps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported field types: `string`, `text`, `number`, `bool`, `enum` (with `options: [{value, label}]`), `list` (itemType `"string"` only in v1), `secret`. Type-specific constraints (`pattern`, `min`/`max`, `minLength`/`maxLength`, `minItems`/`maxItems`) are optional. `secret` fields **must not** declare a `default` — the validator refuses.
|
||||||
|
|
||||||
|
Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swift) (schema + value models + Keychain ref helpers), [ProjectConfigKeychain.swift](scarf/scarf/Core/Services/ProjectConfigKeychain.swift) (thin `SecItemAdd`/`Copy`/`Delete` wrapper; the only Keychain user in Scarf today), [ProjectConfigService.swift](scarf/scarf/Core/Services/ProjectConfigService.swift) (load/save config.json, resolve secrets, cache manifest, validate schema + values). UI in [Features/Templates/ViewModels/TemplateConfigViewModel.swift](scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift) + [Features/Templates/Views/TemplateConfigSheet.swift](scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift).
|
||||||
|
|
||||||
|
**Secret storage.** Keychain service name is `com.scarf.template.<slug>`, account is `<fieldKey>:<project-path-hash-short>`. The path-hash suffix means two installs of the same template in different dirs don't collide on Keychain entries. Values in `config.json` are `"keychain://service/account"` URIs — never plaintext. The bytes hit the Keychain only on form commit, so cancelling never leaves orphan entries.
|
||||||
|
|
||||||
|
**Uninstall.** `TemplateLock` v2 gains `config_keychain_items` and `config_fields` arrays. The uninstaller iterates each URI through `SecItemDelete` before removing the lock file. Absent items (user hand-cleaned) are no-ops.
|
||||||
|
|
||||||
|
**Exporter.** Carries the *schema* from `<project>/.scarf/manifest.json` through into exported bundles, never values. Exporting never leaks anyone's secrets. `schemaVersion` bumps to 2 only when a schema is forwarded; schema-less exports stay at 1.
|
||||||
|
|
||||||
|
**Catalog site.** [tools/build-catalog.py](tools/build-catalog.py) mirrors the Swift schema validator. Each v2 template's `template.json` is copied into `.gh-pages-worktree/templates/<slug>/manifest.json` and the site's `widgets.js` calls `ScarfWidgets.renderConfigSchema` to display the schema on the detail page (display-only — the form lives in-app).
|
||||||
|
|
||||||
|
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
|
||||||
|
|
||||||
## Template Catalog
|
## Template Catalog
|
||||||
|
|
||||||
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
|
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
|
||||||
|
|||||||
@@ -21,11 +21,14 @@
|
|||||||
|
|
||||||
## What's New in 2.2
|
## What's New in 2.2
|
||||||
|
|
||||||
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, and cron jobs into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the new **Templates** menu in the Projects toolbar.
|
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the new **Templates** menu in the Projects toolbar.
|
||||||
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), and a live diff of any memory appendix. Nothing writes until you click Install.
|
- **Typed configuration with Keychain-backed secrets** — Templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`. A slider icon in the dashboard header opens the same form post-install for edits — rotate a token, change a site, toggle a feature, and the next cron run picks it up.
|
||||||
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records everything written for easy uninstall. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
|
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) is a static catalog site generated from `templates/<author>/<name>/` in this repo. Each template has a detail page with a live dashboard preview, the schema rendered with constraint summaries, and a one-click install button. Community submissions go through a CI-enforced Python validator that mirrors the Swift-side invariants.
|
||||||
|
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), every Keychain secret that will be written, and a live diff of any memory appendix. Markdown fields render inline. Nothing writes until you click Install.
|
||||||
|
- **Site tab** — A dashboard with at least one `webview` widget gets a second tab next to Dashboard. The example `awizemann/site-status-checker` template uses this to render whatever URL you configured as your first watched site, updating on every cron run.
|
||||||
|
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records every file, cron job, Keychain ref, and memory block for one-click uninstall. Exports carry the configuration schema but never the user's values — safe on projects with live config. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
|
||||||
|
|
||||||
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0).
|
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) and the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates).
|
||||||
|
|
||||||
### Previously, in 2.1
|
### Previously, in 2.1
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,50 @@
|
|||||||
## What's New in 2.2.0
|
## What's New in 2.2.0
|
||||||
|
|
||||||
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, and cron jobs into a single file anyone can install with one click from a local file or an `scarf://install?url=…` deep link.
|
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a single file anyone can install with one click. Bundles are agent-portable by design: every template ships with a cross-agent [`AGENTS.md`](https://agents.md/) so the instructions work natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and every other agent that reads the Linux Foundation standard.
|
||||||
|
|
||||||
|
This is also the first release to ship a public **template catalog website** — a static site generated from `templates/<author>/<name>/` in this repo, previewed at [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/), with a CI-enforced validator for community submissions.
|
||||||
|
|
||||||
### Project Templates
|
### Project Templates
|
||||||
|
|
||||||
- **Bundle format: `.scarftemplate`.** A zip archive carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix. Every bundle is agent-portable out of the box.
|
- **Bundle format: `.scarftemplate`.** A zip carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix.
|
||||||
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
|
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. Markdown fields — the README, field descriptions, cron prompts — render inline. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
|
||||||
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
|
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
|
||||||
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter builds the bundle and you can hand it to anyone.
|
- **Install-time token substitution.** Template authors use `{{PROJECT_DIR}}`, `{{TEMPLATE_ID}}`, and `{{TEMPLATE_SLUG}}` placeholders in cron prompts; the installer resolves them to absolute paths at install time so the registered cron job works regardless of where Hermes sets CWD.
|
||||||
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, and memory block id. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`. Uninstalling by hand is a matter of deleting the project directory, the skills namespace folder, and any `[tmpl:<id>] …` cron jobs — no hidden state.
|
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter carries the authored configuration schema forward but **never** the user's values — exports are safe on projects with live config.
|
||||||
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused so nothing unexpected kicks off on install. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
|
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, memory block id, and Keychain reference. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`.
|
||||||
|
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
|
||||||
|
|
||||||
|
### Template Configuration (schemaVersion 2)
|
||||||
|
|
||||||
|
Templates can now declare a typed configuration schema that drives a form step during install — no more "edit a `sites.txt` file to get started."
|
||||||
|
|
||||||
|
- **Typed field vocabulary.** Seven field types: `string`, `text` (multiline), `number` (with `min`/`max`), `bool`, `enum` (with `{value, label}` options), `list` (of strings, with `minItems`/`maxItems`), and `secret` (routed to the macOS Keychain). Constraints per type — `pattern` for regex, `minLength`/`maxLength` for text, etc. — are enforced at install and at edit time.
|
||||||
|
- **Configure step in the install flow.** If the template declares a schema, a **Configure** screen is inserted between "pick parent directory" and the preview sheet. Non-secret values land in `<project>/.scarf/config.json`; secrets land in the macOS Keychain with a service name of `com.scarf.template.<slug>` and an account keyed to the project-directory hash (so two installs of the same template in different dirs don't collide on Keychain entries).
|
||||||
|
- **Post-install Configuration editor.** A slider icon in the dashboard header opens the same form pre-filled with the current values. Change a site, rotate a token, toggle a feature — the cron job picks up the new values on its next run. Secrets are never echoed back ("Saved in Keychain — leave empty to keep the stored value").
|
||||||
|
- **Model recommendations.** Templates can suggest a preferred model (`claude-sonnet-4.5`, `claude-haiku-4`, `gpt-4.1`, etc.) with a rationale. Scarf surfaces the recommendation in the configure sheet without auto-switching your active model — always your call.
|
||||||
|
- **Secrets are tracked in the lock file.** Uninstalling a template runs `SecItemDelete` on every Keychain ref recorded at install, so a full clean-up leaves nothing behind. Absent entries (user already cleaned them) are no-ops.
|
||||||
|
|
||||||
|
### Template Catalog
|
||||||
|
|
||||||
|
A Sparkle-style pipeline for community-contributed templates, living on the same `gh-pages` branch as the auto-update feed.
|
||||||
|
|
||||||
|
- **Static site.** [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) — generated from every `templates/<author>/<name>/` directory. Each template gets a detail page showing the README, a live preview of the post-install dashboard, and the configuration schema rendered with human-readable constraint summaries. One-click install via the `scarf://install?url=…` button.
|
||||||
|
- **Stdlib-only Python validator.** `tools/build-catalog.py` is a no-external-dependencies Python script that mirrors the Swift-side schema and validation invariants (supported widget types, supported field types, `contents` claim verification, secret-with-default rejection, bundle-size cap, high-confidence secret patterns). Run it locally with `./scripts/catalog.sh check` before submitting a PR.
|
||||||
|
- **CI gate on PRs.** [`.github/workflows/validate-template-pr.yml`](https://github.com/awizemann/scarf/blob/main/.github/workflows/validate-template-pr.yml) runs the validator + its 24-test suite on every PR touching `templates/`, the validator itself, or its tests. Failing PRs get an inline comment with the last 3 KB of the validator output; passing PRs get a tailored checklist naming the specific template directory being changed.
|
||||||
|
- **Install-URL hosting.** Bundles are raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template GitHub Releases ceremony.
|
||||||
|
- **Dogfood: the site uses Scarf's dashboard format.** `site/widgets.js` is ~300 lines of vanilla JS that renders a `ProjectDashboard` JSON using the same widget vocabulary the app uses, so each detail page's "live preview" is the actual dashboard the user will get.
|
||||||
|
|
||||||
|
### Example template: `awizemann/site-status-checker`
|
||||||
|
|
||||||
|
Ships as the first catalog entry and exercises every v2.2 surface. [See it in the catalog →](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/)
|
||||||
|
|
||||||
|
- Configure step asks for a list of URLs and a per-URL timeout.
|
||||||
|
- A paused cron job runs daily at 09:00 (editable from the Cron sidebar), does HTTP GETs with 3-redirect follow, writes a timestamped results table to `status-log.md`, updates the dashboard's Sites Up / Sites Down / Last Checked stat widgets plus the Watched Sites list, and rewrites the Site tab's webview URL to the first configured site.
|
||||||
|
- Works in any agent — the `AGENTS.md` is the single source of truth; no per-agent shim needed.
|
||||||
|
|
||||||
|
### Site tab
|
||||||
|
|
||||||
|
A dashboard with at least one `webview` widget now exposes a **Site** tab next to Dashboard. Useful for templates that watch something renderable (a site, a preview endpoint, a Grafana panel). The `site-status-checker` example rewrites the webview URL to the first configured site on every cron run, so the tab stays in sync with live config.
|
||||||
|
|
||||||
### Using templates
|
### Using templates
|
||||||
|
|
||||||
@@ -17,29 +52,43 @@ Scarf projects can now travel. This release introduces **Project Templates** —
|
|||||||
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
|
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
|
||||||
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
|
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
|
||||||
- **Export:** select a project → Projects → Templates → *Export "<name>" as Template…*, fill the form, save.
|
- **Export:** select a project → Projects → Templates → *Export "<name>" as Template…*, fill the form, save.
|
||||||
|
- **Edit config post-install:** slider icon in the dashboard header.
|
||||||
|
- **Uninstall:** right-click the project in the sidebar → *Uninstall Template (remove installed files)…*, or click the uninstall icon in the dashboard header. The preview sheet lists every file, cron job, Keychain secret, and memory block that will be removed, plus every user-created file that will be preserved.
|
||||||
|
|
||||||
### Under the hood
|
### UX clarifications
|
||||||
|
|
||||||
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock, errors).
|
- **Remove from List vs. Uninstall Template.** Sidebar context-menu labels clarified so you can see at a glance whether a click is destructive. *Remove from List (keep files)…* is registry-only — nothing on disk is touched, cron jobs stay, Keychain secrets stay. A confirmation dialog spells this out before the click lands. *Uninstall Template (remove installed files)…* is the full, lock-driven cleanup.
|
||||||
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan atomically-enough (pre-flights conflicts, then writes); `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
|
- **Post-uninstall "folder kept" banner.** When the uninstaller preserves the project directory because the cron wrote a `status-log.md` (or the user dropped files in there), the success view now explicitly lists the preserved paths with a pointer to delete the folder from Finder if desired.
|
||||||
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
|
- **Run Now no longer blocks on agent runs.** The Cron sidebar's Run Now button used to show a "Run failed" toast whenever an agent job ran longer than 60 s — even when the job was finishing correctly in the background. Run Now now shows "Agent started — dashboard will update when it finishes" immediately and the dashboard watcher picks up the completed state when it lands (timeout bumped to 300 s for the catch-stuck-process case).
|
||||||
- Installer dispatches cron creation via `hermes cron create` (there's no direct Scarf write path for `cron/jobs.json`), then diffs before/after to pause the newly-registered jobs.
|
|
||||||
- New Swift Testing suites: `ProjectTemplateServiceTests`, `TemplateURLRouterTests`, `ProjectTemplateExportTests`.
|
|
||||||
|
|
||||||
### Uninstall
|
### Uninstall
|
||||||
|
|
||||||
- **One-click uninstall** driven by `template.lock.json`. Right-click any template-installed project in the sidebar → **Uninstall Template…**, or click the uninstall button in the dashboard header. A preview sheet lists every file, cron job, and memory block that will be removed, and every user-created file that will be preserved.
|
- **One-click uninstall** driven by `template.lock.json`. The preview sheet lists every file, cron job, Keychain ref, and memory block that will be removed, and every user-created file that will be preserved.
|
||||||
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
|
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
|
||||||
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
|
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. Every recorded Keychain ref is cleared via `SecItemDelete`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
|
||||||
- **No undo.** v1 uninstall is destructive — to reinstall, run the install flow again.
|
- **No undo.** Uninstall is destructive — to reinstall, run the install flow again.
|
||||||
|
|
||||||
### Not in this release (planned for v2.3)
|
### Under the hood
|
||||||
|
|
||||||
- In-app catalog browser backed by a GitHub Pages `templates.json`.
|
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock file v2) and `Core/Models/TemplateConfig.swift` (schema + typed values + Keychain ref model).
|
||||||
- EdDSA-signed bundles reusing the Sparkle key.
|
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan with preflight + fail-fast semantics; `ProjectTemplateUninstaller.swift` reverses an install driven by the lock file; `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
|
||||||
- Template updates (compare installed lock against a newer bundle's version, offer a diff).
|
- `Core/Services/ProjectConfigService.swift` owns load/save/validation of `<project>/.scarf/config.json` + secret resolution; `Core/Services/ProjectConfigKeychain.swift` is the thin `SecItemAdd`/`Copy`/`Delete` wrapper (the only Keychain consumer in Scarf today).
|
||||||
- Installing into remote `ServerContext`s (v1 is local-only).
|
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
|
||||||
|
- New Swift Testing suites covering 57 tests across the service / installer / uninstaller / exporter / config / Keychain / URL-router paths.
|
||||||
|
- New Python validator (`tools/build-catalog.py`) + test suite (`tools/test_build_catalog.py`, 24 tests) mirrors the Swift invariants for the CI gate and the site generator. Schema is Swift-primary — additions go to Swift first, Python mirrors.
|
||||||
|
- `scripts/catalog.sh` wraps the validator with `check / build / preview / serve / publish` subcommands that parallel the `scripts/release.sh` shape.
|
||||||
|
|
||||||
### Migrating from 2.1.x
|
### Migrating from 2.1.x
|
||||||
|
|
||||||
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive.
|
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive. If you had a v2.2.0-dev install of the earlier `project-templates` branch, uninstall and reinstall any previously-installed templates to pick up the schema-version-2 lock file.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
|
||||||
|
- [Catalog site](https://awizemann.github.io/scarf/templates/) — the public catalog with live dashboard previews.
|
||||||
|
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
|
||||||
|
- [Architecture notes in root `CLAUDE.md`](https://github.com/awizemann/scarf/blob/main/CLAUDE.md#project-templates) — service-layer map, Keychain scheme, schema-drift discipline.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
Thanks to everyone who tested drafts of the install flow, caught the "Run Now blocks on agent" bug, and pushed on the Remove-vs-Uninstall UX until it was clear. A 2.3 follow-up will extend the catalog validator to enforce per-field-type constraints at PR-time (currently enforced on install but not at submission).
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
struct ProjectDashboardService: Sendable {
|
struct ProjectDashboardService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
let transport: any ServerTransport
|
let transport: any ServerTransport
|
||||||
@@ -19,23 +21,28 @@ struct ProjectDashboardService: Sendable {
|
|||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveRegistry(_ registry: ProjectRegistry) {
|
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
||||||
|
///
|
||||||
|
/// **Throws** on every non-success path — the previous version of
|
||||||
|
/// this method silently swallowed `createDirectory` and `writeFile`
|
||||||
|
/// failures with `try?`, which meant the installer could return a
|
||||||
|
/// valid-looking `ProjectEntry` while the registry on disk never
|
||||||
|
/// received the new row (project would complete install, show a
|
||||||
|
/// success screen, then be invisible in the sidebar). Callers that
|
||||||
|
/// want fire-and-forget behaviour can still use `try?`, but the
|
||||||
|
/// choice is now theirs.
|
||||||
|
func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||||
let dir = context.paths.scarfDir
|
let dir = context.paths.scarfDir
|
||||||
if !transport.fileExists(dir) {
|
if !transport.fileExists(dir) {
|
||||||
do {
|
|
||||||
try transport.createDirectory(dir)
|
try transport.createDirectory(dir)
|
||||||
} catch {
|
|
||||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
let data = try JSONEncoder().encode(registry)
|
||||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
// Pretty-print for readability (agents may read this file).
|
||||||
// Pretty-print for readability (agents may read this file)
|
|
||||||
let writeData: Data
|
let writeData: Data
|
||||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||||
@@ -43,7 +50,7 @@ struct ProjectDashboardService: Sendable {
|
|||||||
} else {
|
} else {
|
||||||
writeData = data
|
writeData = data
|
||||||
}
|
}
|
||||||
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
|
|||||||
@@ -179,7 +179,17 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
}
|
}
|
||||||
args.append(job.schedule)
|
args.append(job.schedule)
|
||||||
if let prompt = job.prompt, !prompt.isEmpty {
|
if let prompt = job.prompt, !prompt.isEmpty {
|
||||||
args.append(prompt)
|
// Substitute template-author tokens with install-time
|
||||||
|
// values. Hermes doesn't set a CWD for cron runs — when
|
||||||
|
// the agent fires the prompt, any relative path
|
||||||
|
// (`.scarf/config.json`, `status-log.md`, etc.) resolves
|
||||||
|
// against the agent's own dir, not the project. Templates
|
||||||
|
// use `{{PROJECT_DIR}}` as a placeholder for the absolute
|
||||||
|
// path; we swap in the real project dir here so the
|
||||||
|
// registered cron job carries a fully-qualified prompt
|
||||||
|
// that works regardless of CWD.
|
||||||
|
let resolvedPrompt = Self.substituteCronTokens(prompt, plan: plan)
|
||||||
|
args.append(resolvedPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (output, exit) = context.runHermes(args)
|
let (output, exit) = context.runHermes(args)
|
||||||
@@ -211,10 +221,45 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
||||||
registry.projects.append(entry)
|
registry.projects.append(entry)
|
||||||
service.saveRegistry(registry)
|
// Must throw on failure — silent failure here used to make the
|
||||||
|
// installer return a valid entry while the registry on disk
|
||||||
|
// never got updated, producing the "install completed but the
|
||||||
|
// project doesn't show up in the sidebar" bug. If the registry
|
||||||
|
// write fails, the whole install is surfaced as failed so the
|
||||||
|
// user can see + address the underlying problem.
|
||||||
|
try service.saveRegistry(registry)
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Token substitution (install-time placeholder resolution)
|
||||||
|
|
||||||
|
/// Supported placeholders for template-author prompts. Keep the set
|
||||||
|
/// intentionally small — every token here becomes a load-bearing
|
||||||
|
/// part of the template format that we can't rename without
|
||||||
|
/// breaking existing bundles.
|
||||||
|
///
|
||||||
|
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
|
||||||
|
/// directory. Required for cron prompts because Hermes doesn't
|
||||||
|
/// establish a CWD when firing cron jobs; relative paths would
|
||||||
|
/// resolve against whatever dir Hermes happens to be in.
|
||||||
|
///
|
||||||
|
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
|
||||||
|
/// Less load-bearing; occasionally useful for tagging or
|
||||||
|
/// delivery targets that reference the template.
|
||||||
|
///
|
||||||
|
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
|
||||||
|
/// for the skills namespace and project dir name.
|
||||||
|
nonisolated static func substituteCronTokens(
|
||||||
|
_ prompt: String,
|
||||||
|
plan: TemplateInstallPlan
|
||||||
|
) -> String {
|
||||||
|
var out = prompt
|
||||||
|
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
|
||||||
|
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
|
||||||
|
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lock file
|
// MARK: - Lock file
|
||||||
|
|
||||||
nonisolated private func writeLockFile(
|
nonisolated private func writeLockFile(
|
||||||
|
|||||||
@@ -206,7 +206,17 @@ struct ProjectTemplateUninstaller: Sendable {
|
|||||||
let dashboardService = ProjectDashboardService(context: context)
|
let dashboardService = ProjectDashboardService(context: context)
|
||||||
var registry = dashboardService.loadRegistry()
|
var registry = dashboardService.loadRegistry()
|
||||||
registry.projects.removeAll { $0.path == plan.project.path }
|
registry.projects.removeAll { $0.path == plan.project.path }
|
||||||
dashboardService.saveRegistry(registry)
|
// saveRegistry throws now — log a write failure but don't abort
|
||||||
|
// the uninstall. Every earlier step already completed (files
|
||||||
|
// removed, skills removed, cron jobs removed, memory stripped,
|
||||||
|
// Keychain cleared); failing here leaves a stale registry row
|
||||||
|
// pointing at a deleted project — cosmetic and easy to fix
|
||||||
|
// from the sidebar.
|
||||||
|
do {
|
||||||
|
try dashboardService.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("uninstall couldn't rewrite projects registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,61 @@ final class CronViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runNow(_ job: HermesCronJob) {
|
func runNow(_ job: HermesCronJob) {
|
||||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
// `hermes cron run <id>` only marks the job as due on the next
|
||||||
|
// scheduler tick — it doesn't actually execute. If the Hermes
|
||||||
|
// gateway's scheduler isn't running (common during dev + right
|
||||||
|
// after install), the user's "Run now" click results in zero
|
||||||
|
// visible effect because the tick never comes. We follow up
|
||||||
|
// with `hermes cron tick` which runs all due jobs once and
|
||||||
|
// exits. Redundant-but-harmless when the gateway is running;
|
||||||
|
// the actual trigger when it isn't.
|
||||||
|
//
|
||||||
|
// Feedback model: show a "Agent started" toast as soon as
|
||||||
|
// `cron run` succeeds, WITHOUT waiting for `cron tick` to
|
||||||
|
// return. Agent jobs routinely run past a minute (network IO +
|
||||||
|
// an LLM call + a file rewrite), and earlier versions with a
|
||||||
|
// 60s tick timeout surfaced a misleading "Run failed" toast
|
||||||
|
// every time while the job kept running in the background.
|
||||||
|
// The app's HermesFileWatcher picks up the dashboard.json
|
||||||
|
// rewrite that the agent lands at the end — that's what the
|
||||||
|
// user actually watches for, not this toast.
|
||||||
|
let svc = fileService
|
||||||
|
let jobID = job.id
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if runResult.exitCode != 0 {
|
||||||
|
self.message = "Run failed to queue: \(runResult.output.prefix(200))"
|
||||||
|
self.logger.warning("cron run failed: \(runResult.output)")
|
||||||
|
self.load()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.message = nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.message = "Agent started — dashboard will update when it finishes"
|
||||||
|
self.load()
|
||||||
|
}
|
||||||
|
// `cron run` is queued; now force the tick. The 300s
|
||||||
|
// timeout catches truly stuck processes without killing
|
||||||
|
// the long-but-valid agent case that blew up the 60s
|
||||||
|
// version. A timeout here is survivable — the Hermes
|
||||||
|
// scheduler re-runs due jobs on its own cadence — so we
|
||||||
|
// log but don't surface it as a failure toast.
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 300)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if tickResult.exitCode != 0 {
|
||||||
|
self.logger.warning("cron tick exited non-zero (job may still complete via scheduler): \(tickResult.output)")
|
||||||
|
}
|
||||||
|
self.load()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.message = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteJob(_ job: HermesCronJob) {
|
func deleteJob(_ job: HermesCronJob) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ProjectsViewModel {
|
final class ProjectsViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let service: ProjectDashboardService
|
private let service: ProjectDashboardService
|
||||||
|
|
||||||
@@ -39,7 +41,19 @@ final class ProjectsViewModel {
|
|||||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||||
let entry = ProjectEntry(name: name, path: path)
|
let entry = ProjectEntry(name: name, path: path)
|
||||||
registry.projects.append(entry)
|
registry.projects.append(entry)
|
||||||
service.saveRegistry(registry)
|
// saveRegistry throws now. The VM doesn't currently have a
|
||||||
|
// surface for user-visible errors (there's no alert/toast in
|
||||||
|
// the Projects view), so log at error level to the unified
|
||||||
|
// log and keep the in-memory state consistent with whatever
|
||||||
|
// landed on disk. If the write fails, the added entry won't
|
||||||
|
// persist across launches — the user sees it appear + work
|
||||||
|
// this session, then it's gone at relaunch. Not ideal, but
|
||||||
|
// matches today's UX and flagged for a proper alert later.
|
||||||
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("addProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
selectProject(entry)
|
selectProject(entry)
|
||||||
}
|
}
|
||||||
@@ -47,7 +61,11 @@ final class ProjectsViewModel {
|
|||||||
func removeProject(_ project: ProjectEntry) {
|
func removeProject(_ project: ProjectEntry) {
|
||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
registry.projects.removeAll { $0.name == project.name }
|
registry.projects.removeAll { $0.name == project.name }
|
||||||
service.saveRegistry(registry)
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("removeProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
if selectedProject?.name == project.name {
|
if selectedProject?.name == project.name {
|
||||||
selectedProject = nil
|
selectedProject = nil
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ struct ProjectsView: View {
|
|||||||
@State private var showingInstallURLPrompt = false
|
@State private var showingInstallURLPrompt = false
|
||||||
@State private var installURLInput = ""
|
@State private var installURLInput = ""
|
||||||
@State private var showingUninstallSheet = false
|
@State private var showingUninstallSheet = false
|
||||||
|
@State private var configEditorProject: ProjectEntry?
|
||||||
|
/// Project queued for the "remove from list" confirmation dialog.
|
||||||
|
/// Non-nil while the dialog is up; the `confirmationDialog` binding
|
||||||
|
/// flips based on presence. We store the full entry (not just a
|
||||||
|
/// flag) so the dialog's action closure knows which project to
|
||||||
|
/// drop from the registry.
|
||||||
|
@State private var pendingRemoveFromList: ProjectEntry?
|
||||||
|
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -36,6 +43,14 @@ struct ProjectsView: View {
|
|||||||
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the given project has a cached manifest (i.e. was
|
||||||
|
/// installed from a schemaful template). Cheap — just a file
|
||||||
|
/// existence check via the transport.
|
||||||
|
private func isConfigurable(_ project: ProjectEntry) -> Bool {
|
||||||
|
let path = ProjectConfigService.manifestCachePath(for: project)
|
||||||
|
return serverContext.makeTransport().fileExists(path)
|
||||||
|
}
|
||||||
|
|
||||||
@State private var selectedTab: DashboardTab = .dashboard
|
@State private var selectedTab: DashboardTab = .dashboard
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -106,6 +121,50 @@ struct ProjectsView: View {
|
|||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(item: $configEditorProject) { project in
|
||||||
|
ConfigEditorSheet(
|
||||||
|
context: serverContext,
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Confirmation dialog for the sidebar's "Remove from List" action.
|
||||||
|
// The action is registry-only (doesn't touch disk), but the name
|
||||||
|
// historically confused users into thinking it was a full delete.
|
||||||
|
// A confirmation with explicit wording clarifies scope before the
|
||||||
|
// click is destructive-looking but actually harmless.
|
||||||
|
.confirmationDialog(
|
||||||
|
removeFromListDialogTitle,
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingRemoveFromList != nil },
|
||||||
|
set: { if !$0 { pendingRemoveFromList = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: pendingRemoveFromList
|
||||||
|
) { project in
|
||||||
|
Button("Remove from List") {
|
||||||
|
viewModel.removeProject(project)
|
||||||
|
if coordinator.selectedProjectName == project.name {
|
||||||
|
coordinator.selectedProjectName = nil
|
||||||
|
}
|
||||||
|
pendingRemoveFromList = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingRemoveFromList = nil
|
||||||
|
}
|
||||||
|
} message: { project in
|
||||||
|
Text(
|
||||||
|
"\(project.name) will be removed from Scarf's project list. " +
|
||||||
|
"Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. " +
|
||||||
|
"To actually remove installed files, use \"Uninstall Template…\" instead."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title string for the remove-from-list confirmation dialog. Kept
|
||||||
|
/// as a computed property so the dialog and any future reuse share
|
||||||
|
/// the exact same copy.
|
||||||
|
private var removeFromListDialogTitle: LocalizedStringKey {
|
||||||
|
"Remove from Scarf's project list?"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar
|
// MARK: - Toolbar
|
||||||
@@ -221,15 +280,29 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
.tag(project)
|
.tag(project)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
if isConfigurable(project) {
|
||||||
|
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||||
|
configEditorProject = project
|
||||||
|
}
|
||||||
|
}
|
||||||
if uninstaller.isTemplateInstalled(project: project) {
|
if uninstaller.isTemplateInstalled(project: project) {
|
||||||
Button("Uninstall Template…", systemImage: "trash") {
|
// "Uninstall Template…" only appears for projects
|
||||||
|
// installed from a `.scarftemplate`. Trailing
|
||||||
|
// ellipsis signals a confirmation sheet follows
|
||||||
|
// (macOS HIG convention); the sheet itself lists
|
||||||
|
// every file/cron/skill that will be removed.
|
||||||
|
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||||
uninstallerViewModel.begin(project: project)
|
uninstallerViewModel.begin(project: project)
|
||||||
showingUninstallSheet = true
|
showingUninstallSheet = true
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
Button("Remove from Scarf", systemImage: "minus.circle") {
|
// "Remove from List" used to be "Remove from Scarf",
|
||||||
viewModel.removeProject(project)
|
// which users read as a full delete. Clarified label +
|
||||||
|
// ellipsis + confirmation dialog all spell out that
|
||||||
|
// this is registry-only; nothing on disk is touched.
|
||||||
|
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||||
|
pendingRemoveFromList = project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,10 +316,16 @@ struct ProjectsView: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let selected = viewModel.selectedProject {
|
if let selected = viewModel.selectedProject {
|
||||||
Button(action: { viewModel.removeProject(selected) }) {
|
// Route through the same confirmation dialog as the
|
||||||
|
// context-menu "Remove from List" entry. The minus
|
||||||
|
// icon is a drive-by click target right next to "+" —
|
||||||
|
// confirming before mutating the registry stops the
|
||||||
|
// "I clicked by accident and my project's gone" case.
|
||||||
|
Button(action: { pendingRemoveFromList = selected }) {
|
||||||
Image(systemName: "minus")
|
Image(systemName: "minus")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
@@ -383,6 +462,15 @@ struct ProjectsView: View {
|
|||||||
Image(systemName: "folder")
|
Image(systemName: "folder")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
if isConfigurable(project) {
|
||||||
|
Button {
|
||||||
|
configEditorProject = project
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Edit configuration")
|
||||||
|
}
|
||||||
if uninstaller.isTemplateInstalled(project: project) {
|
if uninstaller.isTemplateInstalled(project: project) {
|
||||||
Button {
|
Button {
|
||||||
uninstallerViewModel.begin(project: project)
|
uninstallerViewModel.begin(project: project)
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the post-install "Configuration" button on the project
|
||||||
|
/// dashboard. Loads `<project>/.scarf/manifest.json` + `config.json`,
|
||||||
|
/// hands a `TemplateConfigViewModel` seeded with current values to the
|
||||||
|
/// sheet, then writes the edited values back on commit.
|
||||||
|
///
|
||||||
|
/// Smaller surface than `TemplateInstallerViewModel` — no unzipping,
|
||||||
|
/// no parent-dir picking, no cron CLI. Just: read → edit → save.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateConfigEditorViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
/// Manifest + config loaded; the sheet is displaying the form.
|
||||||
|
case editing
|
||||||
|
case saving
|
||||||
|
case succeeded
|
||||||
|
case failed(String)
|
||||||
|
/// Project wasn't installed from a schemaful template — no
|
||||||
|
/// manifest cache on disk. The dashboard button is hidden in
|
||||||
|
/// this case so we shouldn't hit this stage normally.
|
||||||
|
case notConfigurable
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let project: ProjectEntry
|
||||||
|
private let configService: ProjectConfigService
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
self.context = context
|
||||||
|
self.project = project
|
||||||
|
self.configService = ProjectConfigService(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
var manifest: ProjectTemplateManifest?
|
||||||
|
var currentValues: [String: TemplateConfigValue] = [:]
|
||||||
|
|
||||||
|
/// Non-nil while `.editing`; used to construct the sheet's VM.
|
||||||
|
var formViewModel: TemplateConfigViewModel?
|
||||||
|
|
||||||
|
/// Load the cached manifest + current config values, then move to
|
||||||
|
/// `.editing` so the sheet can render the form.
|
||||||
|
func begin() {
|
||||||
|
stage = .loading
|
||||||
|
let service = configService
|
||||||
|
let project = project
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
guard let cachedManifest = try service.loadCachedManifest(project: project),
|
||||||
|
let schema = cachedManifest.config,
|
||||||
|
!schema.isEmpty else {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .notConfigurable
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let configFile = try service.load(project: project)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.manifest = cachedManifest
|
||||||
|
self.currentValues = configFile?.values ?? [:]
|
||||||
|
self.formViewModel = TemplateConfigViewModel(
|
||||||
|
schema: schema,
|
||||||
|
templateId: cachedManifest.id,
|
||||||
|
templateSlug: cachedManifest.slug,
|
||||||
|
initialValues: self.currentValues,
|
||||||
|
mode: .edit(project: project)
|
||||||
|
)
|
||||||
|
self.stage = .editing
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the sheet's commit succeeded. Persists the edited
|
||||||
|
/// values to `<project>/.scarf/config.json`. Secrets are already
|
||||||
|
/// in the Keychain — the VM's commit step wrote them.
|
||||||
|
func save(values: [String: TemplateConfigValue]) {
|
||||||
|
guard let manifest else { return }
|
||||||
|
stage = .saving
|
||||||
|
let service = configService
|
||||||
|
let project = project
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try service.save(
|
||||||
|
project: project,
|
||||||
|
templateId: manifest.id,
|
||||||
|
values: values
|
||||||
|
)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .succeeded
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
stage = .idle
|
||||||
|
formViewModel = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the configure form for template install + post-install editing.
|
||||||
|
///
|
||||||
|
/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes
|
||||||
|
/// in-memory (`pendingSecrets`) until the user clicks the commit button.
|
||||||
|
/// Only then does `commit()` push each secret through
|
||||||
|
/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI.
|
||||||
|
/// This means cancelling the sheet never leaves an orphan Keychain
|
||||||
|
/// entry behind — the form is transactional from the user's POV.
|
||||||
|
///
|
||||||
|
/// **Validation.** Runs via `ProjectConfigService.validateValues` every
|
||||||
|
/// time the user attempts to commit. Per-field errors are tracked in
|
||||||
|
/// `errors` so the sheet can surface them inline with the offending field.
|
||||||
|
/// No live validation on every keystroke — that creates a messy
|
||||||
|
/// "error appears the moment you start typing" UX.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateConfigViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel")
|
||||||
|
|
||||||
|
enum Mode: Sendable {
|
||||||
|
/// User is filling in values for the first time as part of the
|
||||||
|
/// install flow. Secrets will be written to the Keychain when
|
||||||
|
/// `commit` succeeds.
|
||||||
|
case install
|
||||||
|
/// User is editing values for an already-installed project.
|
||||||
|
/// Existing keychain refs are preserved for fields the user
|
||||||
|
/// doesn't touch; only secrets the user actually changes get
|
||||||
|
/// re-written to the Keychain.
|
||||||
|
case edit(project: ProjectEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
let schema: TemplateConfigSchema
|
||||||
|
let templateId: String
|
||||||
|
let templateSlug: String
|
||||||
|
let mode: Mode
|
||||||
|
private let configService: ProjectConfigService
|
||||||
|
|
||||||
|
/// Current form values, keyed by field key. Non-secret values live
|
||||||
|
/// here directly; secret fields either hold a `.keychainRef(...)`
|
||||||
|
/// (existing, untouched in edit mode) or nothing at all (user
|
||||||
|
/// hasn't entered a secret yet, or they just cleared it).
|
||||||
|
var values: [String: TemplateConfigValue] = [:]
|
||||||
|
|
||||||
|
/// Raw secret bytes waiting to be written to the Keychain on
|
||||||
|
/// `commit()`. Indexed by field key. `values[key]` stays as its
|
||||||
|
/// current `.keychainRef(...)` (for edit mode) or missing (for
|
||||||
|
/// install mode) until commit swaps it for the freshly-written
|
||||||
|
/// ref URI.
|
||||||
|
var pendingSecrets: [String: Data] = [:]
|
||||||
|
|
||||||
|
/// One error per field with a problem. Populated by `commit()` on
|
||||||
|
/// validation failure; the sheet surfaces the message inline below
|
||||||
|
/// the offending control.
|
||||||
|
var errors: [String: String] = [:]
|
||||||
|
|
||||||
|
init(
|
||||||
|
schema: TemplateConfigSchema,
|
||||||
|
templateId: String,
|
||||||
|
templateSlug: String,
|
||||||
|
initialValues: [String: TemplateConfigValue] = [:],
|
||||||
|
mode: Mode,
|
||||||
|
configService: ProjectConfigService = ProjectConfigService()
|
||||||
|
) {
|
||||||
|
self.schema = schema
|
||||||
|
self.templateId = templateId
|
||||||
|
self.templateSlug = templateSlug
|
||||||
|
self.mode = mode
|
||||||
|
self.configService = configService
|
||||||
|
self.values = Self.applyDefaults(schema: schema, initial: initialValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field setters (the sheet calls these as controls change)
|
||||||
|
|
||||||
|
func setString(_ key: String, _ value: String) {
|
||||||
|
values[key] = .string(value)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNumber(_ key: String, _ value: Double) {
|
||||||
|
values[key] = .number(value)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBool(_ key: String, _ value: Bool) {
|
||||||
|
values[key] = .bool(value)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setList(_ key: String, _ items: [String]) {
|
||||||
|
values[key] = .list(items)
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage a new secret value. Doesn't hit the Keychain until
|
||||||
|
/// `commit()`. An empty `value` clears both the pending secret and
|
||||||
|
/// the field's stored keychainRef — only valid in edit mode, where
|
||||||
|
/// "empty" means "I want to remove this secret."
|
||||||
|
func setSecret(_ key: String, _ value: String) {
|
||||||
|
if value.isEmpty {
|
||||||
|
pendingSecrets.removeValue(forKey: key)
|
||||||
|
values.removeValue(forKey: key)
|
||||||
|
} else {
|
||||||
|
pendingSecrets[key] = Data(value.utf8)
|
||||||
|
// Keep any existing ref around; the sheet can display
|
||||||
|
// "(changed)" while the ref is still the old one. commit()
|
||||||
|
// overwrites on disk.
|
||||||
|
}
|
||||||
|
errors.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Commit
|
||||||
|
|
||||||
|
/// Validate, persist secrets to the Keychain, and hand back the
|
||||||
|
/// final values dictionary. On validation failure, `errors` is
|
||||||
|
/// populated and the method returns `nil` without touching the
|
||||||
|
/// Keychain — the form is transactional.
|
||||||
|
///
|
||||||
|
/// In install mode, `project` is required (secrets need a path
|
||||||
|
/// hash for their Keychain account). In edit mode it falls out of
|
||||||
|
/// the `.edit(project:)` associated value.
|
||||||
|
func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? {
|
||||||
|
// Build the value set we're about to validate. For secrets
|
||||||
|
// that have a pending update, we treat them as present (we'll
|
||||||
|
// write them in a moment); for secrets already stored as
|
||||||
|
// keychainRef, we treat them as present too. Only a completely
|
||||||
|
// empty secret field is "missing."
|
||||||
|
var candidate = values
|
||||||
|
for key in pendingSecrets.keys {
|
||||||
|
// The field is about to have a fresh keychainRef — for
|
||||||
|
// validation purposes, use a placeholder ref so the type
|
||||||
|
// check passes. The real ref replaces it below.
|
||||||
|
candidate[key] = .keychainRef("pending://\(key)")
|
||||||
|
}
|
||||||
|
let validationErrors = ProjectConfigService.validateValues(candidate, against: schema)
|
||||||
|
guard validationErrors.isEmpty else {
|
||||||
|
var byField: [String: String] = [:]
|
||||||
|
for err in validationErrors {
|
||||||
|
guard let key = err.fieldKey else { continue }
|
||||||
|
byField[key] = err.message
|
||||||
|
}
|
||||||
|
self.errors = byField
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation passed — write the pending secrets to the Keychain.
|
||||||
|
let targetProject: ProjectEntry
|
||||||
|
switch mode {
|
||||||
|
case .install:
|
||||||
|
guard let project else {
|
||||||
|
Self.logger.error("commit(project:) called in install mode without a project")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
targetProject = project
|
||||||
|
case .edit(let proj):
|
||||||
|
targetProject = proj
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, secret) in pendingSecrets {
|
||||||
|
do {
|
||||||
|
let ref = try configService.storeSecret(
|
||||||
|
templateSlug: templateSlug,
|
||||||
|
fieldKey: key,
|
||||||
|
project: targetProject,
|
||||||
|
secret: secret
|
||||||
|
)
|
||||||
|
values[key] = ref
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingSecrets.removeAll()
|
||||||
|
errors.removeAll()
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// Seed the form with any author-supplied defaults for fields that
|
||||||
|
/// don't already have an initial value (from a saved config.json).
|
||||||
|
nonisolated private static func applyDefaults(
|
||||||
|
schema: TemplateConfigSchema,
|
||||||
|
initial: [String: TemplateConfigValue]
|
||||||
|
) -> [String: TemplateConfigValue] {
|
||||||
|
var out = initial
|
||||||
|
for field in schema.fields where out[field.key] == nil {
|
||||||
|
if let def = field.defaultValue {
|
||||||
|
out[field.key] = def
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ final class TemplateInstallerViewModel {
|
|||||||
case fetching(sourceDescription: String)
|
case fetching(sourceDescription: String)
|
||||||
case inspecting
|
case inspecting
|
||||||
case awaitingParentDirectory
|
case awaitingParentDirectory
|
||||||
|
/// Template declared a non-empty config schema; the sheet
|
||||||
|
/// presents `TemplateConfigSheet` before continuing to the
|
||||||
|
/// preview. Schema-less templates skip this stage entirely.
|
||||||
|
case awaitingConfig
|
||||||
case planned
|
case planned
|
||||||
case installing
|
case installing
|
||||||
case succeeded(installed: ProjectEntry)
|
case succeeded(installed: ProjectEntry)
|
||||||
@@ -139,14 +143,20 @@ final class TemplateInstallerViewModel {
|
|||||||
guard let inspection else { return }
|
guard let inspection else { return }
|
||||||
chosenParentDirectory = parentDir
|
chosenParentDirectory = parentDir
|
||||||
let service = templateService
|
let service = templateService
|
||||||
let context = context
|
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
_ = context
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
self?.plan = plan
|
guard let self else { return }
|
||||||
self?.stage = .planned
|
self.plan = plan
|
||||||
|
// If the template declares a non-empty config
|
||||||
|
// schema, insert the configure step before the
|
||||||
|
// preview sheet. Otherwise go straight to .planned.
|
||||||
|
if let schema = plan.configSchema, !schema.isEmpty {
|
||||||
|
self.stage = .awaitingConfig
|
||||||
|
} else {
|
||||||
|
self.stage = .planned
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
@@ -156,6 +166,26 @@ final class TemplateInstallerViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called by `TemplateInstallSheet` once the user has filled in
|
||||||
|
/// the configure form and `TemplateConfigViewModel.commit()`
|
||||||
|
/// succeeded. Stashes the values in the plan and advances to the
|
||||||
|
/// preview stage (`.planned`). Secrets in `values` are already
|
||||||
|
/// `.keychainRef(...)` — the VM's commit step wrote them to the
|
||||||
|
/// Keychain.
|
||||||
|
func submitConfig(values: [String: TemplateConfigValue]) {
|
||||||
|
guard var plan else { return }
|
||||||
|
plan.configValues = values
|
||||||
|
self.plan = plan
|
||||||
|
stage = .planned
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when the user cancels out of the configure step without
|
||||||
|
/// committing. Returns to `.awaitingParentDirectory` so they can
|
||||||
|
/// try again (or dismiss the whole sheet).
|
||||||
|
func cancelConfig() {
|
||||||
|
stage = .awaitingParentDirectory
|
||||||
|
}
|
||||||
|
|
||||||
func confirmInstall() {
|
func confirmInstall() {
|
||||||
guard let plan else { return }
|
guard let plan else { return }
|
||||||
stage = .installing
|
stage = .installing
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ final class TemplateUninstallerViewModel {
|
|||||||
case failed(String)
|
case failed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot of "what survived the uninstall" — surfaced in the
|
||||||
|
/// success screen so the user understands why the project directory
|
||||||
|
/// is or isn't gone from disk. Computed from the plan right before
|
||||||
|
/// executing it (`plan` itself is nil'd on success, so we can't
|
||||||
|
/// reach back for this info after the fact).
|
||||||
|
struct PreservedOutcome: Sendable {
|
||||||
|
/// True when the uninstaller removed the project dir (nothing
|
||||||
|
/// user-owned was left inside). In this case `preservedPaths`
|
||||||
|
/// is empty and the success view skips the banner entirely.
|
||||||
|
let projectDirRemoved: Bool
|
||||||
|
/// Absolute paths of files the uninstaller refused to touch
|
||||||
|
/// because they weren't installed by the template (typically
|
||||||
|
/// `status-log.md` after the cron ran, or anything the user
|
||||||
|
/// dropped into the project dir manually).
|
||||||
|
let preservedPaths: [String]
|
||||||
|
/// Project dir — echoed back so the success view can show the
|
||||||
|
/// user where the orphan files now live.
|
||||||
|
let projectDir: String
|
||||||
|
}
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -27,11 +47,15 @@ final class TemplateUninstallerViewModel {
|
|||||||
|
|
||||||
var stage: Stage = .idle
|
var stage: Stage = .idle
|
||||||
var plan: TemplateUninstallPlan?
|
var plan: TemplateUninstallPlan?
|
||||||
|
/// Populated on transition to `.succeeded`. Nil whenever the user
|
||||||
|
/// re-enters the flow (cancel/begin both clear it).
|
||||||
|
var preservedOutcome: PreservedOutcome?
|
||||||
|
|
||||||
/// Load the `template.lock.json` for the given project and build a
|
/// Load the `template.lock.json` for the given project and build a
|
||||||
/// removal plan. Moves stage to `.planned` on success.
|
/// removal plan. Moves stage to `.planned` on success.
|
||||||
func begin(project: ProjectEntry) {
|
func begin(project: ProjectEntry) {
|
||||||
stage = .loading
|
stage = .loading
|
||||||
|
preservedOutcome = nil
|
||||||
let uninstaller = uninstaller
|
let uninstaller = uninstaller
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
@@ -53,11 +77,20 @@ final class TemplateUninstallerViewModel {
|
|||||||
guard let plan else { return }
|
guard let plan else { return }
|
||||||
stage = .uninstalling
|
stage = .uninstalling
|
||||||
let uninstaller = uninstaller
|
let uninstaller = uninstaller
|
||||||
|
// Capture the preservation shape before executing — the plan
|
||||||
|
// itself gets nil'd on success and we want the banner to show
|
||||||
|
// whatever was true at the moment of removal.
|
||||||
|
let outcome = PreservedOutcome(
|
||||||
|
projectDirRemoved: plan.projectDirBecomesEmpty,
|
||||||
|
preservedPaths: plan.extraProjectEntries,
|
||||||
|
projectDir: plan.project.path
|
||||||
|
)
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
try uninstaller.uninstall(plan: plan)
|
try uninstaller.uninstall(plan: plan)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.preservedOutcome = outcome
|
||||||
self.stage = .succeeded(removed: plan.project)
|
self.stage = .succeeded(removed: plan.project)
|
||||||
self.plan = nil
|
self.plan = nil
|
||||||
}
|
}
|
||||||
@@ -71,6 +104,7 @@ final class TemplateUninstallerViewModel {
|
|||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
plan = nil
|
plan = nil
|
||||||
|
preservedOutcome = nil
|
||||||
stage = .idle
|
stage = .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Post-install configuration editor. Thin wrapper around the same
|
||||||
|
/// `TemplateConfigSheet` the install flow uses — owns a
|
||||||
|
/// `TemplateConfigEditorViewModel` that loads the cached manifest +
|
||||||
|
/// current values from `<project>/.scarf/`, feeds them to the form,
|
||||||
|
/// and writes the edited values back to `config.json` on commit.
|
||||||
|
///
|
||||||
|
/// Entry points: right-click on the project list (when the project has
|
||||||
|
/// a cached manifest) and a button on the dashboard header (shown
|
||||||
|
/// only when `isConfigurable` is true).
|
||||||
|
struct ConfigEditorSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var viewModel: TemplateConfigEditorViewModel
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
_viewModel = State(
|
||||||
|
initialValue: TemplateConfigEditorViewModel(
|
||||||
|
context: context,
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle, .loading:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Loading configuration…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 320)
|
||||||
|
.padding()
|
||||||
|
case .editing:
|
||||||
|
if let form = viewModel.formViewModel,
|
||||||
|
let manifest = viewModel.manifest {
|
||||||
|
TemplateConfigSheet(
|
||||||
|
viewModel: form,
|
||||||
|
title: "Configure \(manifest.name)",
|
||||||
|
commitLabel: "Save",
|
||||||
|
project: nil, // edit mode; VM carries the project
|
||||||
|
onCommit: { values in
|
||||||
|
viewModel.save(values: values)
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
unexpectedState
|
||||||
|
}
|
||||||
|
case .saving:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Saving…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 320)
|
||||||
|
.padding()
|
||||||
|
case .succeeded:
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Configuration saved").font(.title2.bold())
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
case .failed(let message):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Couldn't save").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
case .notConfigurable:
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "slider.horizontal.3")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("No configuration")
|
||||||
|
.font(.title3.bold())
|
||||||
|
Text("This project wasn't installed from a schemaful template.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { viewModel.begin() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var unexpectedState: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Internal state inconsistency — please close and re-open.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.frame(minWidth: 560, minHeight: 280)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// The configure form rendered for template install + post-install
|
||||||
|
/// editing. One row per schema field; controls dispatch by field type.
|
||||||
|
/// Commit button returns the finalized values via `onCommit` — in
|
||||||
|
/// install mode the caller stashes them in the install plan; in edit
|
||||||
|
/// mode the caller writes them straight to `<project>/.scarf/config.json`.
|
||||||
|
struct TemplateConfigSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State var viewModel: TemplateConfigViewModel
|
||||||
|
let title: LocalizedStringKey
|
||||||
|
let commitLabel: LocalizedStringKey
|
||||||
|
/// In install mode the caller passes the planned `ProjectEntry`
|
||||||
|
/// (project dir path is the unique key for the Keychain secret).
|
||||||
|
/// In edit mode the VM already holds the project; pass `nil` here.
|
||||||
|
let project: ProjectEntry?
|
||||||
|
let onCommit: ([String: TemplateConfigValue]) -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
if viewModel.schema.fields.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No fields",
|
||||||
|
systemImage: "slider.horizontal.3",
|
||||||
|
description: Text("This template has no configuration fields.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 120)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.schema.fields) { field in
|
||||||
|
fieldRow(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let rec = viewModel.schema.modelRecommendation {
|
||||||
|
modelRecommendation(rec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
footer
|
||||||
|
}
|
||||||
|
.frame(minWidth: 560, minHeight: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header / footer
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title).font(.title2.bold())
|
||||||
|
Text(viewModel.templateId)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var footer: some View {
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
// Caller owns dismissal — this view is used both as a
|
||||||
|
// standalone sheet (ConfigEditorSheet, where the caller
|
||||||
|
// wants dismissal) AND inlined inside the install sheet
|
||||||
|
// (TemplateInstallSheet.configureView, where calling
|
||||||
|
// .dismiss here would tear down the OUTER install sheet
|
||||||
|
// and abort the flow before .planned is reached).
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button(commitLabel) {
|
||||||
|
if let finalized = viewModel.commit(project: project) {
|
||||||
|
onCommit(finalized)
|
||||||
|
}
|
||||||
|
// Same dismissal-is-caller's-responsibility rule as
|
||||||
|
// Cancel — inside the install sheet, onCommit transitions
|
||||||
|
// stage to .planned and the outer view re-renders to
|
||||||
|
// show the preview. In the edit sheet, onCommit
|
||||||
|
// transitions the editor VM and its state machine
|
||||||
|
// handles dismissal via the success view's Done button.
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field rows
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func fieldRow(_ field: TemplateConfigField) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
Text(field.label).font(.headline)
|
||||||
|
if field.required {
|
||||||
|
Text("*")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(field.type.rawValue)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let description = field.description, !description.isEmpty {
|
||||||
|
// Inline markdown so descriptions can include
|
||||||
|
// `[Create one](https://…)`-style links to token
|
||||||
|
// generation pages, **bold** emphasis on important
|
||||||
|
// prerequisites, etc.
|
||||||
|
TemplateMarkdown.inlineText(description)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
control(for: field)
|
||||||
|
if let err = viewModel.errors[field.key] {
|
||||||
|
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(.background.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func control(for field: TemplateConfigField) -> some View {
|
||||||
|
switch field.type {
|
||||||
|
case .string:
|
||||||
|
StringControl(
|
||||||
|
value: stringBinding(for: field),
|
||||||
|
placeholder: field.placeholder
|
||||||
|
)
|
||||||
|
case .text:
|
||||||
|
TextControl(value: stringBinding(for: field))
|
||||||
|
case .number:
|
||||||
|
NumberControl(value: numberBinding(for: field))
|
||||||
|
case .bool:
|
||||||
|
BoolControl(label: field.label, value: boolBinding(for: field))
|
||||||
|
case .enum:
|
||||||
|
EnumControl(
|
||||||
|
options: field.options ?? [],
|
||||||
|
value: stringBinding(for: field)
|
||||||
|
)
|
||||||
|
case .list:
|
||||||
|
ListControl(items: listBinding(for: field))
|
||||||
|
case .secret:
|
||||||
|
SecretControl(
|
||||||
|
fieldKey: field.key,
|
||||||
|
placeholder: field.placeholder,
|
||||||
|
viewModel: viewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Model recommendation panel
|
||||||
|
|
||||||
|
private func modelRecommendation(_ rec: TemplateModelRecommendation) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Label("Recommended model", systemImage: "lightbulb")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(rec.preferred).font(.body.monospaced())
|
||||||
|
if let rationale = rec.rationale, !rationale.isEmpty {
|
||||||
|
Text(rationale)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
if let alts = rec.alternatives, !alts.isEmpty {
|
||||||
|
Text("Also works: \(alts.joined(separator: ", "))")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("Scarf doesn't auto-switch your active model. Change it in Settings if you'd like.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.accentColor.opacity(0.08))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Binding helpers (threading the VM through typed lenses)
|
||||||
|
|
||||||
|
private func stringBinding(for field: TemplateConfigField) -> Binding<String> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .string(let s) = viewModel.values[field.key] { return s }
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
set: { viewModel.setString(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func numberBinding(for field: TemplateConfigField) -> Binding<Double> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .number(let n) = viewModel.values[field.key] { return n }
|
||||||
|
return 0
|
||||||
|
},
|
||||||
|
set: { viewModel.setNumber(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func boolBinding(for field: TemplateConfigField) -> Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .bool(let b) = viewModel.values[field.key] { return b }
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
set: { viewModel.setBool(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listBinding(for field: TemplateConfigField) -> Binding<[String]> {
|
||||||
|
Binding(
|
||||||
|
get: {
|
||||||
|
if case .list(let items) = viewModel.values[field.key] { return items }
|
||||||
|
return []
|
||||||
|
},
|
||||||
|
set: { viewModel.setList(field.key, $0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Field controls
|
||||||
|
|
||||||
|
private struct StringControl: View {
|
||||||
|
@Binding var value: String
|
||||||
|
let placeholder: String?
|
||||||
|
var body: some View {
|
||||||
|
TextField(placeholder ?? "", text: $value)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TextControl: View {
|
||||||
|
@Binding var value: String
|
||||||
|
var body: some View {
|
||||||
|
TextEditor(text: $value)
|
||||||
|
.font(.body.monospaced())
|
||||||
|
.frame(minHeight: 80, maxHeight: 160)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.stroke(.secondary.opacity(0.3))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct NumberControl: View {
|
||||||
|
@Binding var value: Double
|
||||||
|
var body: some View {
|
||||||
|
TextField("", value: $value, format: .number)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BoolControl: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var value: Bool
|
||||||
|
var body: some View {
|
||||||
|
Toggle(isOn: $value) {
|
||||||
|
Text(value ? "Enabled" : "Disabled")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct EnumControl: View {
|
||||||
|
let options: [TemplateConfigField.EnumOption]
|
||||||
|
@Binding var value: String
|
||||||
|
var body: some View {
|
||||||
|
// Segmented for ≤ 4 options, dropdown otherwise — fits Scarf's
|
||||||
|
// existing settings UI.
|
||||||
|
if options.count <= 4 {
|
||||||
|
Picker("", selection: $value) {
|
||||||
|
ForEach(options) { opt in
|
||||||
|
Text(opt.label).tag(opt.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.labelsHidden()
|
||||||
|
} else {
|
||||||
|
Picker("", selection: $value) {
|
||||||
|
ForEach(options) { opt in
|
||||||
|
Text(opt.label).tag(opt.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Variable-length list of string values. Each row is a text field
|
||||||
|
/// with an inline remove button; a + button adds a trailing row.
|
||||||
|
private struct ListControl: View {
|
||||||
|
@Binding var items: [String]
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(items.indices, id: \.self) { i in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
TextField("", text: Binding(
|
||||||
|
get: { i < items.count ? items[i] : "" },
|
||||||
|
set: { newValue in
|
||||||
|
guard i < items.count else { return }
|
||||||
|
items[i] = newValue
|
||||||
|
}
|
||||||
|
))
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
Button {
|
||||||
|
guard i < items.count else { return }
|
||||||
|
items.remove(at: i)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.disabled(items.count <= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
items.append("")
|
||||||
|
} label: {
|
||||||
|
Label("Add", systemImage: "plus.circle")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secret fields never echo the previously-stored value back. Instead
|
||||||
|
/// we render "(unchanged)" when a Keychain ref already exists and let
|
||||||
|
/// the user type over it if they want to replace. Empty input in edit
|
||||||
|
/// mode signals "remove this secret entirely."
|
||||||
|
private struct SecretControl: View {
|
||||||
|
let fieldKey: String
|
||||||
|
let placeholder: String?
|
||||||
|
@Bindable var viewModel: TemplateConfigViewModel
|
||||||
|
|
||||||
|
@State private var typedValue: String = ""
|
||||||
|
@State private var isRevealed: Bool = false
|
||||||
|
|
||||||
|
private var hasStoredRef: Bool {
|
||||||
|
if case .keychainRef = viewModel.values[fieldKey] { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Group {
|
||||||
|
if isRevealed {
|
||||||
|
TextField(placeholder ?? "", text: $typedValue)
|
||||||
|
} else {
|
||||||
|
SecureField(placeholder ?? "", text: $typedValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: typedValue) { _, new in
|
||||||
|
viewModel.setSecret(fieldKey, new)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
isRevealed.toggle()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isRevealed ? "eye.slash" : "eye")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(isRevealed ? "Hide" : "Show while typing")
|
||||||
|
}
|
||||||
|
if hasStoredRef && typedValue.isEmpty {
|
||||||
|
Text("Saved in Keychain — leave empty to keep the stored value.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else if !typedValue.isEmpty {
|
||||||
|
Text("Will be saved to the Keychain on commit.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ struct TemplateInstallSheet: View {
|
|||||||
progress("Inspecting template…")
|
progress("Inspecting template…")
|
||||||
case .awaitingParentDirectory:
|
case .awaitingParentDirectory:
|
||||||
pickParentView
|
pickParentView
|
||||||
|
case .awaitingConfig:
|
||||||
|
configureView
|
||||||
case .planned:
|
case .planned:
|
||||||
if let plan = viewModel.plan {
|
if let plan = viewModel.plan {
|
||||||
plannedView(plan: plan)
|
plannedView(plan: plan)
|
||||||
@@ -85,6 +87,39 @@ struct TemplateInstallSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure step for schemaful templates. Inlines
|
||||||
|
/// `TemplateConfigSheet` into the install flow rather than pushing
|
||||||
|
/// a second sheet on top — keeps the user in one window. The
|
||||||
|
/// nested VM is created freshly each time `.awaitingConfig` is
|
||||||
|
/// entered so a Cancel + retry doesn't carry stale form state.
|
||||||
|
@ViewBuilder
|
||||||
|
private var configureView: some View {
|
||||||
|
if let plan = viewModel.plan,
|
||||||
|
let schema = plan.configSchema,
|
||||||
|
let manifest = viewModel.inspection?.manifest {
|
||||||
|
TemplateConfigSheet(
|
||||||
|
viewModel: TemplateConfigViewModel(
|
||||||
|
schema: schema,
|
||||||
|
templateId: manifest.id,
|
||||||
|
templateSlug: manifest.slug,
|
||||||
|
initialValues: plan.configValues,
|
||||||
|
mode: .install
|
||||||
|
),
|
||||||
|
title: "Configure \(manifest.name)",
|
||||||
|
commitLabel: "Continue",
|
||||||
|
project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir),
|
||||||
|
onCommit: { values in
|
||||||
|
viewModel.submitConfig(values: values)
|
||||||
|
},
|
||||||
|
onCancel: {
|
||||||
|
viewModel.cancelConfig()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
progress("Preparing…")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func plannedView(plan: TemplateInstallPlan) -> some View {
|
private func plannedView(plan: TemplateInstallPlan) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
manifestHeader(plan.manifest)
|
manifestHeader(plan.manifest)
|
||||||
@@ -102,6 +137,9 @@ struct TemplateInstallSheet: View {
|
|||||||
if plan.memoryAppendix != nil {
|
if plan.memoryAppendix != nil {
|
||||||
memorySection(plan: plan)
|
memorySection(plan: plan)
|
||||||
}
|
}
|
||||||
|
if let schema = plan.configSchema, !schema.isEmpty {
|
||||||
|
configurationSection(plan: plan, schema: schema)
|
||||||
|
}
|
||||||
readmeSection
|
readmeSection
|
||||||
}
|
}
|
||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
@@ -137,7 +175,10 @@ struct TemplateInstallSheet: View {
|
|||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(manifest.description)
|
// Inline-only markdown — descriptions are a sentence or two;
|
||||||
|
// bold/italic/code/links are all that reasonable template
|
||||||
|
// authors use there.
|
||||||
|
TemplateMarkdown.inlineText(manifest.description)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let author = manifest.author {
|
if let author = manifest.author {
|
||||||
@@ -182,8 +223,9 @@ struct TemplateInstallSheet: View {
|
|||||||
|
|
||||||
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
||||||
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(plan.cronJobs, id: \.name) { job in
|
ForEach(plan.cronJobs, id: \.name) { job in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
Image(systemName: "clock.arrow.circlepath")
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -194,6 +236,29 @@ struct TemplateInstallSheet: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Prompt preview — disclosed in an expandable
|
||||||
|
// group so the preview stays compact when the
|
||||||
|
// user doesn't care to read it. Markdown-rendered
|
||||||
|
// so prompts that include `code`, **bold**, or
|
||||||
|
// enumerated steps look right. Tokens like
|
||||||
|
// {{PROJECT_DIR}} are still visible here — they
|
||||||
|
// get substituted when the installer calls
|
||||||
|
// `hermes cron create`.
|
||||||
|
if let prompt = job.prompt, !prompt.isEmpty {
|
||||||
|
DisclosureGroup("Prompt") {
|
||||||
|
ScrollView {
|
||||||
|
TemplateMarkdown.render(prompt)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 140)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.4))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.leading, 26)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,6 +278,50 @@ struct TemplateInstallSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configuration values the user entered in the configure step.
|
||||||
|
/// Secrets display masked so the preview never echoes a freshly
|
||||||
|
/// typed API key back on screen.
|
||||||
|
private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View {
|
||||||
|
section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(schema.fields) { field in
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Text(field.key)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(minWidth: 120, alignment: .leading)
|
||||||
|
Text(displayValue(for: field, in: plan.configValues))
|
||||||
|
.font(.caption)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line display form for a value in the preview. Secrets are
|
||||||
|
/// always masked; lists show a count + first entry; strings are
|
||||||
|
/// truncated by `.lineLimit(1)` at the view level.
|
||||||
|
private func displayValue(
|
||||||
|
for field: TemplateConfigField,
|
||||||
|
in values: [String: TemplateConfigValue]
|
||||||
|
) -> String {
|
||||||
|
switch field.type {
|
||||||
|
case .secret:
|
||||||
|
return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)"
|
||||||
|
case .list:
|
||||||
|
if case .list(let items) = values[field.key] {
|
||||||
|
if items.isEmpty { return "(none)" }
|
||||||
|
if items.count == 1 { return items[0] }
|
||||||
|
return "\(items[0]) + \(items.count - 1) more"
|
||||||
|
}
|
||||||
|
return "(none)"
|
||||||
|
default:
|
||||||
|
return values[field.key]?.displayString ?? "(not set)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var readmeSection: some View {
|
private var readmeSection: some View {
|
||||||
Group {
|
Group {
|
||||||
// The body is preloaded in the VM off MainActor when inspection
|
// The body is preloaded in the VM off MainActor when inspection
|
||||||
@@ -220,11 +329,10 @@ struct TemplateInstallSheet: View {
|
|||||||
if let readme = viewModel.readmeBody {
|
if let readme = viewModel.readmeBody {
|
||||||
section(title: "README", subtitle: nil) {
|
section(title: "README", subtitle: nil) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
Text(readme)
|
TemplateMarkdown.render(readme)
|
||||||
.font(.callout)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 260)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Minimal markdown renderer used by the template install/config UIs.
|
||||||
|
///
|
||||||
|
/// SwiftUI `Text` has built-in inline-markdown support via
|
||||||
|
/// `AttributedString(markdown:)` — bold, italic, inline code, links.
|
||||||
|
/// That's enough for field descriptions + template taglines. For
|
||||||
|
/// longer content (README preview, full doc blocks), this helper adds
|
||||||
|
/// block-level handling: lines starting with `#`/`##`/`###` render
|
||||||
|
/// as bigger bold text; lines starting with `-`/`*`/`1.` render as
|
||||||
|
/// list items with a hanging indent; fenced ``` ``` blocks render as
|
||||||
|
/// monospaced; blank lines become paragraph breaks.
|
||||||
|
///
|
||||||
|
/// Scope is intentionally small. This isn't a full CommonMark
|
||||||
|
/// renderer — it's "enough markdown to make template READMEs look
|
||||||
|
/// right in the install sheet without pulling in a dependency." If
|
||||||
|
/// the set of templates needs more over time, evolve this file or
|
||||||
|
/// graduate to a proper library.
|
||||||
|
enum TemplateMarkdown {
|
||||||
|
|
||||||
|
/// Render a markdown source string as a SwiftUI view. Preserves
|
||||||
|
/// reading order and approximate visual hierarchy. Safe with
|
||||||
|
/// untrusted input — we never execute HTML or scripts.
|
||||||
|
@ViewBuilder
|
||||||
|
static func render(_ source: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
let blocks = parse(source)
|
||||||
|
ForEach(blocks.indices, id: \.self) { i in
|
||||||
|
block(blocks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline-only markdown (bold/italic/code/links) as a single
|
||||||
|
/// `Text`. Use for short strings where block structure doesn't
|
||||||
|
/// apply — field labels, one-line descriptions.
|
||||||
|
static func inlineText(_ source: String) -> Text {
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return Text(attr)
|
||||||
|
}
|
||||||
|
return Text(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Block model
|
||||||
|
|
||||||
|
fileprivate enum Block {
|
||||||
|
case paragraph(AttributedString)
|
||||||
|
case heading(level: Int, text: AttributedString)
|
||||||
|
case bullet(AttributedString)
|
||||||
|
case numbered(index: Int, text: AttributedString)
|
||||||
|
case code(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parser
|
||||||
|
|
||||||
|
fileprivate static func parse(_ source: String) -> [Block] {
|
||||||
|
var blocks: [Block] = []
|
||||||
|
var lines = source.components(separatedBy: "\n")
|
||||||
|
var i = 0
|
||||||
|
while i < lines.count {
|
||||||
|
let line = lines[i]
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Fenced code block.
|
||||||
|
if trimmed.hasPrefix("```") {
|
||||||
|
var body: [String] = []
|
||||||
|
i += 1
|
||||||
|
while i < lines.count {
|
||||||
|
let inner = lines[i]
|
||||||
|
if inner.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body.append(inner)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
blocks.append(.code(body.joined(separator: "\n")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading.
|
||||||
|
if let headingMatch = trimmed.firstMatch(of: /^(#{1,6})\s+(.*)$/) {
|
||||||
|
let level = (headingMatch.1).count
|
||||||
|
let text = String(headingMatch.2)
|
||||||
|
blocks.append(.heading(level: level, text: renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet list.
|
||||||
|
if let bulletMatch = line.firstMatch(of: /^\s*[-*]\s+(.*)$/) {
|
||||||
|
let text = String(bulletMatch.1)
|
||||||
|
blocks.append(.bullet(renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbered list.
|
||||||
|
if let numMatch = line.firstMatch(of: /^\s*(\d+)\.\s+(.*)$/) {
|
||||||
|
let index = Int(String(numMatch.1)) ?? 1
|
||||||
|
let text = String(numMatch.2)
|
||||||
|
blocks.append(.numbered(index: index, text: renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank line — skip.
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph — collect contiguous non-blank lines that
|
||||||
|
// aren't headings/lists/fences into one paragraph block.
|
||||||
|
var paragraphLines: [String] = [line]
|
||||||
|
i += 1
|
||||||
|
while i < lines.count {
|
||||||
|
let next = lines[i]
|
||||||
|
let nextTrim = next.trimmingCharacters(in: .whitespaces)
|
||||||
|
if nextTrim.isEmpty { break }
|
||||||
|
if nextTrim.hasPrefix("```") { break }
|
||||||
|
if nextTrim.firstMatch(of: /^#{1,6}\s/) != nil { break }
|
||||||
|
if next.firstMatch(of: /^\s*[-*]\s+/) != nil { break }
|
||||||
|
if next.firstMatch(of: /^\s*\d+\.\s+/) != nil { break }
|
||||||
|
paragraphLines.append(next)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
let joined = paragraphLines.joined(separator: " ")
|
||||||
|
blocks.append(.paragraph(renderInline(joined)))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse inline markdown (bold, italic, inline code, links) into
|
||||||
|
/// an AttributedString. Falls back to plain text on parse failure.
|
||||||
|
fileprivate static func renderInline(_ source: String) -> AttributedString {
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
return AttributedString(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rendering
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
fileprivate static func block(_ b: Block) -> some View {
|
||||||
|
switch b {
|
||||||
|
case .paragraph(let text):
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
case .heading(let level, let text):
|
||||||
|
headingText(text: text, level: level)
|
||||||
|
case .bullet(let text):
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("•").font(.callout)
|
||||||
|
Text(text).font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
case .numbered(let index, let text):
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("\(index).").font(.callout.monospacedDigit())
|
||||||
|
Text(text).font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
case .code(let src):
|
||||||
|
Text(src)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
fileprivate static func headingText(text: AttributedString, level: Int) -> some View {
|
||||||
|
switch level {
|
||||||
|
case 1: Text(text).font(.title2.bold()).padding(.top, 8)
|
||||||
|
case 2: Text(text).font(.title3.bold()).padding(.top, 6)
|
||||||
|
case 3: Text(text).font(.headline).padding(.top, 4)
|
||||||
|
default: Text(text).font(.subheadline.bold()).padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,6 +277,19 @@ struct TemplateUninstallSheet: View {
|
|||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("Removed \(removed.name)")
|
Text("Removed \(removed.name)")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
// Preserved-files banner. Only renders when the project dir
|
||||||
|
// stayed and at least one file was left behind — that's the
|
||||||
|
// case the user keeps getting surprised by ("I uninstalled
|
||||||
|
// but my project folder is still there?"). Explicit
|
||||||
|
// explanation + file list makes it obvious the files the
|
||||||
|
// user (or the cron job) created are intentionally kept.
|
||||||
|
if let outcome = viewModel.preservedOutcome,
|
||||||
|
outcome.projectDirRemoved == false,
|
||||||
|
outcome.preservedPaths.isEmpty == false {
|
||||||
|
preservedFilesBanner(outcome: outcome)
|
||||||
|
}
|
||||||
|
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
onCompleted(removed)
|
onCompleted(removed)
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -285,6 +298,53 @@ struct TemplateUninstallSheet: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orange informational banner listing the files the uninstaller
|
||||||
|
/// left in the project directory. Caps the visible list at 8 rows
|
||||||
|
/// with a "+N more…" tail so a long log (many runs = many status
|
||||||
|
/// file entries) doesn't blow out the sheet height.
|
||||||
|
private func preservedFilesBanner(
|
||||||
|
outcome: TemplateUninstallerViewModel.PreservedOutcome
|
||||||
|
) -> some View {
|
||||||
|
let visible = Array(outcome.preservedPaths.prefix(8))
|
||||||
|
let overflow = outcome.preservedPaths.count - visible.count
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "folder.badge.questionmark")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Project folder kept")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
Text("These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(visible, id: \.self) { path in
|
||||||
|
Text(path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
}
|
||||||
|
if overflow > 0 {
|
||||||
|
Text("+ \(overflow) more…")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Delete \(outcome.projectDir) from Finder if you don't need these files anymore.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 520, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.orange.opacity(0.10))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func failureView(message: String) -> some View {
|
private func failureView(message: String) -> some View {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
},
|
},
|
||||||
"(%lld tokens)" : {
|
"(%lld tokens)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"*" : {
|
||||||
|
"comment" : "A required asterisk.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"/%@" : {
|
"/%@" : {
|
||||||
|
|
||||||
@@ -885,6 +889,10 @@
|
|||||||
},
|
},
|
||||||
"••••••••••" : {
|
"••••••••••" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"+ %lld more…" : {
|
||||||
|
"comment" : "A button that shows the number of files that were left behind by the template uninstaller.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"<%@>" : {
|
"<%@>" : {
|
||||||
|
|
||||||
@@ -2229,6 +2237,9 @@
|
|||||||
"already gone" : {
|
"already gone" : {
|
||||||
"comment" : "A tag for a file that is already gone (no longer in the template).",
|
"comment" : "A tag for a file that is already gone (no longer in the template).",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Also works: %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"API Key" : {
|
"API Key" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5024,6 +5035,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Configuration saved" : {
|
||||||
|
"comment" : "A title displayed when a configuration is saved.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Configuration…" : {
|
||||||
|
"comment" : "A contextual menu item that opens a configuration editor for a project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Configure" : {
|
"Configure" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5064,6 +5083,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Configure %@" : {
|
||||||
|
"comment" : "The title of the configuration sheet. The argument is the name of the template.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Connect timeout" : {
|
"Connect timeout" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5304,6 +5327,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Continue" : {
|
||||||
|
"comment" : "Button label for continuing with the template configuration.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Continue Last Session" : {
|
"Continue Last Session" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5584,6 +5611,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Couldn't save" : {
|
||||||
|
"comment" : "A title displayed when a configuration save fails.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create" : {
|
"Create" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -6637,6 +6668,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Delete %@ from Finder if you don't need these files anymore." : {
|
||||||
|
"comment" : "A note that lets the user delete",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Delete %@?" : {
|
"Delete %@?" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -7657,6 +7692,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Edit configuration" : {
|
||||||
|
"comment" : "A button that opens a configuration editor for a project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Edit User Profile" : {
|
"Edit User Profile" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -10548,6 +10587,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Internal state inconsistency — please close and re-open." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Invalid URL" : {
|
"Invalid URL" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11156,6 +11198,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Loading configuration…" : {
|
||||||
|
"comment" : "A message displayed while loading the configuration.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Loading session…" : {
|
"Loading session…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -12665,6 +12711,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"No configuration" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No credential pools configured" : {
|
"No credential pools configured" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -12910,6 +12959,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No fields" : {
|
||||||
|
"comment" : "A label that describes a template with no configuration fields.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No headers configured." : {
|
"No headers configured." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -14258,6 +14311,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Opens on launch" : {
|
||||||
|
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Optional" : {
|
"Optional" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -15398,6 +15455,9 @@
|
|||||||
},
|
},
|
||||||
"Project directory will also be removed (nothing user-owned left inside)." : {
|
"Project directory will also be removed (nothing user-owned left inside)." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Project folder kept" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Project Name" : {
|
"Project Name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -16127,6 +16187,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Recommended model" : {
|
||||||
|
"comment" : "A label that indicates a recommended model.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Reconnect" : {
|
"Reconnect" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -16458,6 +16522,10 @@
|
|||||||
"comment" : "A label that instructs the user to remove a project from Scarf's list of installed projects.",
|
"comment" : "A label that instructs the user to remove a project from Scarf's list of installed projects.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Remove %@ from Scarf's project list (files are kept on disk)" : {
|
||||||
|
"comment" : "A confirmation dialog that",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Remove %@?" : {
|
"Remove %@?" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -16538,8 +16606,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Remove from Scarf" : {
|
"Remove from List" : {
|
||||||
"comment" : "A context menu option to remove a project from Scarf.",
|
"comment" : "A confirmation dialog that asks whether a user is sure they want to remove a project from Scarf's list.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remove from List (keep files)…" : {
|
||||||
|
"comment" : "A button that removes a project from Scarf's list, but not from disk.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remove from Scarf's project list?" : {
|
||||||
|
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Remove the entire namespace dir recursively" : {
|
"Remove the entire namespace dir recursively" : {
|
||||||
@@ -18000,6 +18076,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Saved in Keychain — leave empty to keep the stored value." : {
|
||||||
|
"comment" : "A message that appears when a user has filled in a secret but has not yet saved it.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Saving…" : {
|
||||||
|
"comment" : "A label displayed while the configuration is being saved.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Scarf" : {
|
"Scarf" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -18043,6 +18127,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Scarf doesn't auto-switch your active model. Change it in Settings if you'd like." : {
|
||||||
|
"comment" : "A description of the warning about not switching models.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -19291,6 +19379,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Set as default — open this server when Scarf launches." : {
|
||||||
|
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Settings" : {
|
"Settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -19694,6 +19786,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Show while typing" : {
|
||||||
|
"comment" : "A hint for the user on how to show/hide the secret.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
|
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -21499,6 +21595,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself." : {
|
||||||
|
"comment" : "A description of the files Scarf left in place when uninstalling a template.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"These list fields must be edited directly in config.yaml." : {
|
"These list fields must be edited directly in config.yaml." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -21538,6 +21638,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"This Mac" : {
|
||||||
|
"comment" : "A description of the local machine.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"This project wasn't installed from a schemaful template." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"This provider has no catalogued models." : {
|
"This provider has no catalogued models." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -21739,6 +21846,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"This template has no configuration fields." : {
|
||||||
|
"comment" : "A description of a template with no configuration fields.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL." : {
|
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -22518,8 +22629,8 @@
|
|||||||
"comment" : "A button that uninstalls a template.",
|
"comment" : "A button that uninstalls a template.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Uninstall Template…" : {
|
"Uninstall Template (remove installed files)…" : {
|
||||||
"comment" : "A contextual menu item that uninstalls a template.",
|
"comment" : "A button that removes a project's files from the system.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Unknown: %@" : {
|
"Unknown: %@" : {
|
||||||
@@ -23786,6 +23897,10 @@
|
|||||||
},
|
},
|
||||||
"Where should this project live?" : {
|
"Where should this project live?" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Will be saved to the Keychain on commit." : {
|
||||||
|
"comment" : "A description of a secret field that will be saved to the Keychain on commit.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Working" : {
|
"Working" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -2,6 +2,42 @@ import Testing
|
|||||||
import Foundation
|
import Foundation
|
||||||
@testable import scarf
|
@testable import scarf
|
||||||
|
|
||||||
|
/// Cross-suite serialization lock for tests that touch the real
|
||||||
|
/// `~/.hermes/scarf/projects.json`. Swift Testing's `.serialized` trait
|
||||||
|
/// only serializes tests WITHIN a suite — multiple suites still run in
|
||||||
|
/// parallel. Three suites in this file write to the same file and
|
||||||
|
/// previously raced each other silently (saveRegistry used to swallow
|
||||||
|
/// write failures); now that saveRegistry throws, the race surfaces.
|
||||||
|
///
|
||||||
|
/// The lock is acquired by `acquireAndSnapshot()` at the top of each
|
||||||
|
/// registry-touching test and released by `restore(_:)` via the test's
|
||||||
|
/// `defer`. Asymmetric acquire-in-one-fn / release-in-another looks
|
||||||
|
/// unusual but the snapshot/restore pairing is so tight (every test
|
||||||
|
/// defers the restore) that it's reliable in practice.
|
||||||
|
final class TestRegistryLock: @unchecked Sendable {
|
||||||
|
static let shared = TestRegistryLock()
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
/// Acquire the cross-suite lock and snapshot the registry. Pair
|
||||||
|
/// every call with a `defer { TestRegistryLock.restore(snapshot) }`.
|
||||||
|
static func acquireAndSnapshot() -> Data? {
|
||||||
|
shared.lock.lock()
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the registry from snapshot and release the lock.
|
||||||
|
static func restore(_ snapshot: Data?) {
|
||||||
|
defer { shared.lock.unlock() }
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
if let snapshot {
|
||||||
|
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Exercises the service's ability to unpack, parse, and validate bundles.
|
/// Exercises the service's ability to unpack, parse, and validate bundles.
|
||||||
/// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so
|
/// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so
|
||||||
/// these don't need write access to ~/.hermes.
|
/// these don't need write access to ~/.hermes.
|
||||||
@@ -253,7 +289,7 @@ import Foundation
|
|||||||
/// are exhaustively tested; global-state side effects (skills namespace,
|
/// are exhaustively tested; global-state side effects (skills namespace,
|
||||||
/// cron CLI, memory append) are covered by manual verification per the
|
/// cron CLI, memory append) are covered by manual verification per the
|
||||||
/// plan's step 7.
|
/// plan's step 7.
|
||||||
@Suite struct ProjectTemplateInstallerTests {
|
@Suite(.serialized) struct ProjectTemplateInstallerTests {
|
||||||
|
|
||||||
@Test func installsMinimalBundleAndWritesLockFile() throws {
|
@Test func installsMinimalBundleAndWritesLockFile() throws {
|
||||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
@@ -346,23 +382,69 @@ import Foundation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cron prompt token substitution
|
||||||
|
|
||||||
|
@Test func substituteCronTokensResolvesProjectDir() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "Read {{PROJECT_DIR}}/.scarf/config.json"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
#expect(resolved == "Read \(plan.projectDir)/.scarf/config.json")
|
||||||
|
// Original placeholder must be fully replaced — a lingering
|
||||||
|
// {{PROJECT_DIR}} would leave the cron job trying to read a
|
||||||
|
// literal file named `{{PROJECT_DIR}}` which doesn't exist.
|
||||||
|
#expect(resolved.contains("{{PROJECT_DIR}}") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func substituteCronTokensResolvesIdAndSlug() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "Log as {{TEMPLATE_ID}} (slug {{TEMPLATE_SLUG}})"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
#expect(resolved.contains(plan.manifest.id))
|
||||||
|
#expect(resolved.contains(plan.manifest.slug))
|
||||||
|
#expect(resolved.contains("{{TEMPLATE_ID}}") == false)
|
||||||
|
#expect(resolved.contains("{{TEMPLATE_SLUG}}") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func substituteCronTokensLeavesUnknownTokensUntouched() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "{{PROJECT_DIR}} but keep {{UNSUPPORTED}} literal"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
#expect(resolved.contains(plan.projectDir))
|
||||||
|
// Unsupported placeholders pass through verbatim — template
|
||||||
|
// authors will notice in testing that their token didn't get
|
||||||
|
// replaced and either use a supported one or request a new one.
|
||||||
|
#expect(resolved.contains("{{UNSUPPORTED}}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func substituteCronTokensRepeatsWithinString() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "Read {{PROJECT_DIR}}/a and write {{PROJECT_DIR}}/b"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
// Both occurrences should be replaced — not just the first.
|
||||||
|
// A single-replace bug here would leave the second relative,
|
||||||
|
// causing the same CWD issue this whole feature was meant to
|
||||||
|
// fix.
|
||||||
|
let count = resolved.components(separatedBy: plan.projectDir).count - 1
|
||||||
|
#expect(count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Registry snapshot helpers
|
// MARK: - Registry snapshot helpers
|
||||||
|
|
||||||
/// Read the raw bytes of the current projects.json so we can restore
|
/// Read the raw bytes of the current projects.json so we can restore
|
||||||
/// it byte-for-byte after the test. `nil` means the file didn't exist
|
/// it byte-for-byte after the test. `nil` means the file didn't exist
|
||||||
/// — restore by deleting whatever got created.
|
/// — restore by deleting whatever got created.
|
||||||
|
// Delegates to TestRegistryLock so tests across this suite + the
|
||||||
|
// two other registry-touching suites share one lock. Every
|
||||||
|
// `snapshotRegistry()` call acquires; the paired
|
||||||
|
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||||
|
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||||
|
// the saveRegistry throw surfaces the collision as a test failure.
|
||||||
nonisolated private static func snapshotRegistry() -> Data? {
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.acquireAndSnapshot()
|
||||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.restore(snapshot)
|
||||||
if let snapshot {
|
|
||||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
|
||||||
} else {
|
|
||||||
try? FileManager.default.removeItem(atPath: path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,7 +452,7 @@ import Foundation
|
|||||||
/// it, verify every tracked file is gone, the registry is restored to its
|
/// it, verify every tracked file is gone, the registry is restored to its
|
||||||
/// pre-install state, and user-added files (if any) are preserved. Scoped
|
/// pre-install state, and user-added files (if any) are preserved. Scoped
|
||||||
/// to bundles with no skills/cron/memory so no global state is touched.
|
/// to bundles with no skills/cron/memory so no global state is touched.
|
||||||
@Suite struct ProjectTemplateUninstallerTests {
|
@Suite(.serialized) struct ProjectTemplateUninstallerTests {
|
||||||
|
|
||||||
@Test func roundTripsInstallThenUninstall() throws {
|
@Test func roundTripsInstallThenUninstall() throws {
|
||||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
@@ -476,18 +558,18 @@ import Foundation
|
|||||||
// ProjectTemplateInstallerTests — small helper, not worth a shared
|
// ProjectTemplateInstallerTests — small helper, not worth a shared
|
||||||
// fixture file for one more suite).
|
// fixture file for one more suite).
|
||||||
|
|
||||||
|
// Delegates to TestRegistryLock so tests across this suite + the
|
||||||
|
// two other registry-touching suites share one lock. Every
|
||||||
|
// `snapshotRegistry()` call acquires; the paired
|
||||||
|
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||||
|
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||||
|
// the saveRegistry throw surfaces the collision as a test failure.
|
||||||
nonisolated private static func snapshotRegistry() -> Data? {
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.acquireAndSnapshot()
|
||||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.restore(snapshot)
|
||||||
if let snapshot {
|
|
||||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
|
||||||
} else {
|
|
||||||
try? FileManager.default.removeItem(atPath: path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +578,7 @@ import Foundation
|
|||||||
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
||||||
/// service suffix so no leftover login-Keychain items remain after the
|
/// service suffix so no leftover login-Keychain items remain after the
|
||||||
/// test — every secret we write is deleted on teardown.
|
/// test — every secret we write is deleted on teardown.
|
||||||
@Suite struct ProjectTemplateConfigInstallTests {
|
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
|
||||||
|
|
||||||
/// Minimal schemaful manifest with one non-secret field + one
|
/// Minimal schemaful manifest with one non-secret field + one
|
||||||
/// secret field. Written into the synthesized `.scarftemplate`
|
/// secret field. Written into the synthesized `.scarftemplate`
|
||||||
@@ -753,18 +835,123 @@ import Foundation
|
|||||||
|
|
||||||
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
||||||
|
|
||||||
|
// Delegates to TestRegistryLock so tests across this suite + the
|
||||||
|
// two other registry-touching suites share one lock. Every
|
||||||
|
// `snapshotRegistry()` call acquires; the paired
|
||||||
|
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||||
|
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||||
|
// the saveRegistry throw surfaces the collision as a test failure.
|
||||||
nonisolated private static func snapshotRegistry() -> Data? {
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.acquireAndSnapshot()
|
||||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.restore(snapshot)
|
||||||
if let snapshot {
|
|
||||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
|
||||||
} else {
|
|
||||||
try? FileManager.default.removeItem(atPath: path)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State-machine tests for `TemplateInstallerViewModel`. The install
|
||||||
|
/// flow's configure step is driven entirely through the VM — the view
|
||||||
|
/// transitions `.awaitingParentDirectory → .awaitingConfig → .planned`
|
||||||
|
/// based on `submitConfig(values:)` / `cancelConfig()` calls. If those
|
||||||
|
/// transitions break, the user lands on the wrong sheet stage (or no
|
||||||
|
/// sheet at all, as in the v1.1.0 regression where the config sheet's
|
||||||
|
/// internal `dismiss()` tore down the outer install sheet before
|
||||||
|
/// submitConfig had a chance to fire).
|
||||||
|
@Suite(.serialized) @MainActor struct TemplateInstallerViewModelTests {
|
||||||
|
|
||||||
|
@Test func submitConfigStashesValuesAndTransitionsToPlanned() throws {
|
||||||
|
let vm = TemplateInstallerViewModel(context: .local)
|
||||||
|
// Seed the VM with an awaiting-config plan (schema-ful).
|
||||||
|
let plan = try Self.makePlanWithConfigSchema()
|
||||||
|
vm.plan = plan
|
||||||
|
vm.stage = .awaitingConfig
|
||||||
|
|
||||||
|
let values: [String: TemplateConfigValue] = [
|
||||||
|
"site_url": .string("https://example.com")
|
||||||
|
]
|
||||||
|
vm.submitConfig(values: values)
|
||||||
|
|
||||||
|
// Stage must advance past the configure step, values must land
|
||||||
|
// on the plan where install() will pick them up.
|
||||||
|
if case .planned = vm.stage {
|
||||||
|
// ok
|
||||||
|
} else {
|
||||||
|
Issue.record("expected .planned, got \(vm.stage)")
|
||||||
|
}
|
||||||
|
#expect(vm.plan?.configValues["site_url"] == .string("https://example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cancelConfigReturnsToAwaitingParentDirectory() throws {
|
||||||
|
let vm = TemplateInstallerViewModel(context: .local)
|
||||||
|
vm.plan = try Self.makePlanWithConfigSchema()
|
||||||
|
vm.stage = .awaitingConfig
|
||||||
|
|
||||||
|
vm.cancelConfig()
|
||||||
|
|
||||||
|
if case .awaitingParentDirectory = vm.stage {
|
||||||
|
// ok — user can re-pick the parent dir or fully cancel
|
||||||
|
} else {
|
||||||
|
Issue.record("expected .awaitingParentDirectory, got \(vm.stage)")
|
||||||
|
}
|
||||||
|
// Plan is preserved so re-entering the configure step doesn't
|
||||||
|
// re-run buildPlan.
|
||||||
|
#expect(vm.plan != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func submitConfigNoOpWhenPlanIsNil() {
|
||||||
|
let vm = TemplateInstallerViewModel(context: .local)
|
||||||
|
vm.plan = nil
|
||||||
|
vm.stage = .awaitingConfig
|
||||||
|
vm.submitConfig(values: ["k": .string("v")])
|
||||||
|
// With no plan, the call should be silent — no crash, stage
|
||||||
|
// stays where it was. (Defensive guard in submitConfig.)
|
||||||
|
if case .awaitingConfig = vm.stage {
|
||||||
|
// ok
|
||||||
|
} else {
|
||||||
|
Issue.record("expected stage to remain .awaitingConfig when plan is nil; got \(vm.stage)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fixture
|
||||||
|
|
||||||
|
/// Build a `TemplateInstallPlan` carrying a single-field config
|
||||||
|
/// schema. Exists as a local helper rather than a shared one
|
||||||
|
/// because no other suite needs it.
|
||||||
|
nonisolated static func makePlanWithConfigSchema() throws -> TemplateInstallPlan {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "site_url", type: .string, label: "Site URL",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let manifest = ProjectTemplateServiceTests.sampleManifest(
|
||||||
|
id: "tester/vm-transitions",
|
||||||
|
configSchema: schema
|
||||||
|
)
|
||||||
|
let tmp = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
// Not a real bundle dir — we never unzip or install from this
|
||||||
|
// plan, we only test state transitions that don't touch disk.
|
||||||
|
return TemplateInstallPlan(
|
||||||
|
manifest: manifest,
|
||||||
|
unpackedDir: tmp,
|
||||||
|
projectDir: tmp + "/project",
|
||||||
|
projectFiles: [],
|
||||||
|
skillsNamespaceDir: nil,
|
||||||
|
skillsFiles: [],
|
||||||
|
cronJobs: [],
|
||||||
|
memoryAppendix: nil,
|
||||||
|
memoryPath: ServerContext.local.paths.memoryMD,
|
||||||
|
projectRegistryName: "VM Transitions",
|
||||||
|
configSchema: schema,
|
||||||
|
configValues: [:],
|
||||||
|
manifestCachePath: tmp + "/project/.scarf/manifest.json"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,13 +968,31 @@ import Foundation
|
|||||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
|
||||||
#expect(inspection.manifest.id == "awizemann/site-status-checker")
|
#expect(inspection.manifest.id == "awizemann/site-status-checker")
|
||||||
|
#expect(inspection.manifest.schemaVersion == 2) // config-enabled
|
||||||
#expect(inspection.manifest.contents.dashboard)
|
#expect(inspection.manifest.contents.dashboard)
|
||||||
#expect(inspection.manifest.contents.agentsMd)
|
#expect(inspection.manifest.contents.agentsMd)
|
||||||
#expect(inspection.manifest.contents.cron == 1)
|
#expect(inspection.manifest.contents.cron == 1)
|
||||||
|
#expect(inspection.manifest.contents.config == 2)
|
||||||
#expect(inspection.cronJobs.count == 1)
|
#expect(inspection.cronJobs.count == 1)
|
||||||
#expect(inspection.cronJobs.first?.name == "Check site status")
|
#expect(inspection.cronJobs.first?.name == "Check site status")
|
||||||
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
|
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
|
||||||
|
|
||||||
|
// Schema assertions — the two fields we declared should survive
|
||||||
|
// unzip + parse + validate with their constraints intact.
|
||||||
|
let schema = try #require(inspection.manifest.config)
|
||||||
|
#expect(schema.fields.count == 2)
|
||||||
|
let sitesField = try #require(schema.field(for: "sites"))
|
||||||
|
#expect(sitesField.type == .list)
|
||||||
|
#expect(sitesField.itemType == "string")
|
||||||
|
#expect(sitesField.required == true)
|
||||||
|
#expect(sitesField.minItems == 1)
|
||||||
|
#expect(sitesField.maxItems == 25)
|
||||||
|
let timeoutField = try #require(schema.field(for: "timeout_seconds"))
|
||||||
|
#expect(timeoutField.type == .number)
|
||||||
|
#expect(timeoutField.minNumber == 1)
|
||||||
|
#expect(timeoutField.maxNumber == 60)
|
||||||
|
#expect(schema.modelRecommendation?.preferred == "claude-haiku-4")
|
||||||
|
|
||||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||||
@@ -795,6 +1000,12 @@ import Foundation
|
|||||||
#expect(plan.skillsFiles.isEmpty)
|
#expect(plan.skillsFiles.isEmpty)
|
||||||
#expect(plan.memoryAppendix == nil)
|
#expect(plan.memoryAppendix == nil)
|
||||||
#expect(plan.cronJobs.count == 1)
|
#expect(plan.cronJobs.count == 1)
|
||||||
|
#expect(plan.configSchema?.fields.count == 2)
|
||||||
|
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||||
|
// Plan queues both config.json + manifest.json in projectFiles.
|
||||||
|
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||||
|
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||||
|
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||||
// Cron job name gets prefixed with the template tag so users can
|
// Cron job name gets prefixed with the template tag so users can
|
||||||
// find + remove it later.
|
// find + remove it later.
|
||||||
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
|
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
|
||||||
@@ -808,7 +1019,9 @@ import Foundation
|
|||||||
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
||||||
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
||||||
#expect(dashboard.title == "Site Status")
|
#expect(dashboard.title == "Site Status")
|
||||||
#expect(dashboard.sections.count == 3)
|
// Four sections: Current Status (stats), Watched Sites (list),
|
||||||
|
// Live Site Preview (webview — drives the Site tab), How to Use (text).
|
||||||
|
#expect(dashboard.sections.count == 4)
|
||||||
|
|
||||||
// First section should have three stat widgets that the cron job
|
// First section should have three stat widgets that the cron job
|
||||||
// updates by value. Assert titles + types so the AGENTS.md contract
|
// updates by value. Assert titles + types so the AGENTS.md contract
|
||||||
@@ -820,12 +1033,34 @@ import Foundation
|
|||||||
#expect(statTitles.contains("Sites Down"))
|
#expect(statTitles.contains("Sites Down"))
|
||||||
#expect(statTitles.contains("Last Checked"))
|
#expect(statTitles.contains("Last Checked"))
|
||||||
|
|
||||||
// The cron prompt mentions sites.txt and dashboard.json — if it
|
// Live Site Preview section must contain exactly one webview
|
||||||
// ever stops doing that, the agent won't know what files to touch.
|
// widget. The presence of any webview widget is what makes Scarf
|
||||||
|
// expose the Site tab next to Dashboard, so losing this section
|
||||||
|
// would silently drop a user-visible feature. The cron job
|
||||||
|
// rewrites this widget's `url` to the first configured site on
|
||||||
|
// every run — AGENTS.md documents the contract.
|
||||||
|
let previewSection = dashboard.sections[2]
|
||||||
|
#expect(previewSection.title == "Live Site Preview")
|
||||||
|
let webviews = previewSection.widgets.filter { $0.type == "webview" }
|
||||||
|
#expect(webviews.count == 1)
|
||||||
|
#expect(webviews.first?.title == "First Watched Site")
|
||||||
|
#expect((webviews.first?.url ?? "").isEmpty == false)
|
||||||
|
|
||||||
|
// Cron prompt references .scarf/config.json (where values.sites
|
||||||
|
// + values.timeout_seconds live), the dashboard/log it writes,
|
||||||
|
// and the {{PROJECT_DIR}} placeholder the installer resolves
|
||||||
|
// at install time. If either stops being referenced, the cron
|
||||||
|
// wouldn't know which data to read or where to write results.
|
||||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||||
#expect(cronPrompt.contains("sites.txt"))
|
#expect(cronPrompt.contains("config.json"))
|
||||||
|
#expect(cronPrompt.contains("values.sites"))
|
||||||
#expect(cronPrompt.contains("dashboard.json"))
|
#expect(cronPrompt.contains("dashboard.json"))
|
||||||
#expect(cronPrompt.contains("status-log.md"))
|
#expect(cronPrompt.contains("status-log.md"))
|
||||||
|
// {{PROJECT_DIR}} must remain UNRESOLVED in the bundle — the
|
||||||
|
// installer substitutes it at install time. If someone
|
||||||
|
// accidentally baked an absolute path into the template, that
|
||||||
|
// path would follow every install to every user's machine.
|
||||||
|
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the example bundle path robustly. Unit-test working dirs
|
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||||||
|
|||||||
+100
@@ -233,6 +233,106 @@ h1, h2, h3 { line-height: 1.25; }
|
|||||||
padding: 24px;
|
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 preview ---------- */
|
||||||
|
|
||||||
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
|
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
|
||||||
|
|||||||
+20
-2
@@ -48,6 +48,10 @@
|
|||||||
<div id="dashboard-preview"></div>
|
<div id="dashboard-preview"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-config">
|
||||||
|
<div id="config-schema"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="detail-readme">
|
<section class="detail-readme">
|
||||||
<h2>README</h2>
|
<h2>README</h2>
|
||||||
<div id="readme-body"></div>
|
<div id="readme-body"></div>
|
||||||
@@ -63,11 +67,14 @@
|
|||||||
|
|
||||||
<script src="../widgets.js"></script>
|
<script src="../widgets.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Fetch + render dashboard + README at page load. Both files live
|
// Fetch + render dashboard + README + config schema at page load.
|
||||||
// alongside index.html in this template's detail dir.
|
// Dashboard + README live next to index.html in this template's
|
||||||
|
// detail dir; the config schema comes from the sibling manifest.json
|
||||||
|
// that the build-catalog renderer also copies in.
|
||||||
(async function () {
|
(async function () {
|
||||||
const dashboardEl = document.getElementById("dashboard-preview");
|
const dashboardEl = document.getElementById("dashboard-preview");
|
||||||
const readmeEl = document.getElementById("readme-body");
|
const readmeEl = document.getElementById("readme-body");
|
||||||
|
const configEl = document.getElementById("config-schema");
|
||||||
try {
|
try {
|
||||||
const d = await fetch("dashboard.json").then(r => r.json());
|
const d = await fetch("dashboard.json").then(r => r.json());
|
||||||
ScarfWidgets.renderDashboard(dashboardEl, d);
|
ScarfWidgets.renderDashboard(dashboardEl, d);
|
||||||
@@ -80,6 +87,17 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
readmeEl.textContent = "Could not load README.";
|
readmeEl.textContent = "Could not load README.";
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
// manifest.json may not exist for schema-less templates — that's
|
||||||
|
// fine, we just leave the config section empty.
|
||||||
|
const res = await fetch("manifest.json");
|
||||||
|
if (res.ok) {
|
||||||
|
const manifest = await res.json();
|
||||||
|
ScarfWidgets.renderConfigSchema(configEl, manifest.config);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Silent — config-schema display is optional.
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+105
-1
@@ -408,12 +408,116 @@
|
|||||||
.replace(/'/g, "'");
|
.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 <project>/.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
|
// Public API
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
global.ScarfWidgets = {
|
global.ScarfWidgets = {
|
||||||
renderDashboard,
|
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);
|
})(typeof window !== "undefined" ? window : this);
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ Optional:
|
|||||||
|
|
||||||
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
|
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
|
||||||
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
|
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
|
||||||
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`.
|
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`. The prompt may use these install-time placeholders — the installer substitutes them before registering the cron job with Hermes:
|
||||||
|
- `{{PROJECT_DIR}}` — absolute path of the newly-installed project dir. **Required for any cron prompt that reads or writes project files** — Hermes doesn't set a CWD when firing cron jobs, so relative paths (`.scarf/config.json`) won't resolve. Write `{{PROJECT_DIR}}/.scarf/config.json` instead.
|
||||||
|
- `{{TEMPLATE_ID}}` — the `owner/name` id from your manifest.
|
||||||
|
- `{{TEMPLATE_SLUG}}` — the sanitised slug used for the project dir name + skills namespace.
|
||||||
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
|
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
|
||||||
|
|
||||||
### 4. Build the bundle
|
### 4. Build the bundle
|
||||||
|
|||||||
Binary file not shown.
@@ -1,24 +1,30 @@
|
|||||||
# Site Status Checker — Agent Instructions
|
# Site Status Checker — Agent Instructions
|
||||||
|
|
||||||
This project maintains a daily uptime check for a short list of URLs. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
|
This project maintains a daily uptime check for a list of URLs the user configured during install. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
|
||||||
|
|
||||||
## Project layout
|
## Project layout
|
||||||
|
|
||||||
- `sites.txt` — one URL per line. Lines starting with `#` are comments. This is the source of truth for what to check. **Not shipped with the template** — created on first run (see below).
|
- `.scarf/config.json` — **the source of truth for what to check.** Written by Scarf's install/configure UI; holds a `values.sites` field (a JSON array of URL strings) and a `values.timeout_seconds` field (a number, default 10).
|
||||||
- `status-log.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Also created on first run.
|
- `.scarf/manifest.json` — cached copy of `template.json`, used by Scarf's Configuration editor to re-render the form. Don't modify.
|
||||||
|
- `status-log.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Created on the first run if it doesn't exist.
|
||||||
- `.scarf/dashboard.json` — Scarf dashboard. **Only the `value` fields of the three stat widgets and the `items` array of the "Watched Sites" list widget should be updated.** The section titles, widget types, and structure must stay intact.
|
- `.scarf/dashboard.json` — Scarf dashboard. **Only the `value` fields of the three stat widgets and the `items` array of the "Watched Sites" list widget should be updated.** The section titles, widget types, and structure must stay intact.
|
||||||
|
|
||||||
|
## How configuration works
|
||||||
|
|
||||||
|
The user configures this project through Scarf's UI — not by editing files directly. On install, a form asked them for the list of sites and a request timeout; those values landed in `.scarf/config.json`. They can edit those values any time via the **Configuration** button on the project dashboard header.
|
||||||
|
|
||||||
|
Read configuration like this (JSON, via whatever file-read tool you have):
|
||||||
|
|
||||||
|
```
|
||||||
|
cat .scarf/config.json
|
||||||
|
# → { "values": { "sites": ["https://foo.com", "https://bar.com"],
|
||||||
|
# "timeout_seconds": 10 }, ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never** edit `.scarf/config.json` yourself. If the user asks "add a site" in chat, tell them to open the Configuration button on the dashboard. (A future Scarf release may expose a tool for agents to write config programmatically; until then, configuration is a user action.)
|
||||||
|
|
||||||
## First-run bootstrap
|
## First-run bootstrap
|
||||||
|
|
||||||
If `sites.txt` doesn't exist in the project root, create it with this starter content and tell the user you did:
|
|
||||||
|
|
||||||
```
|
|
||||||
# One URL per line. Lines starting with # are comments.
|
|
||||||
# Replace these placeholders with the sites you want to watch.
|
|
||||||
https://example.com
|
|
||||||
https://example.org
|
|
||||||
```
|
|
||||||
|
|
||||||
If `status-log.md` doesn't exist, create it with a one-line header:
|
If `status-log.md` doesn't exist, create it with a one-line header:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -27,12 +33,14 @@ If `status-log.md` doesn't exist, create it with a one-line header:
|
|||||||
Newest run at the top. Each section is a single check.
|
Newest run at the top. Each section is a single check.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
No `sites.txt` anymore — sites come from `.scarf/config.json`.
|
||||||
|
|
||||||
## What to do when the cron job fires
|
## What to do when the cron job fires
|
||||||
|
|
||||||
The cron job runs this project's "Check site status" prompt. When invoked:
|
The cron prompt Scarf registers for this project carries **absolute paths** (the installer substitutes `{{PROJECT_DIR}}` at install time) — you don't need to figure out the project's location yourself. Use whatever absolute paths appear in the prompt you received; if you're working in the project's interactive chat instead, the paths below are relative to the project root.
|
||||||
|
|
||||||
1. Read `sites.txt` in the project root. Ignore empty lines and `#`-prefixed comments. Expect plain URLs; be tolerant of whitespace around them.
|
1. Read `.scarf/config.json`. Extract `values.sites` (array of URLs) and `values.timeout_seconds` (number). If `sites` is empty or missing, write a `status-log.md` entry noting "no sites configured — open Configuration to add some" and leave the dashboard untouched.
|
||||||
2. For each URL, make an HTTP GET request with a 10-second timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
|
2. For each URL in `sites`, make an HTTP GET request with the configured timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
|
||||||
3. Build a results table: URL, status (up/down), HTTP code (or error reason), response time in milliseconds.
|
3. Build a results table: URL, status (up/down), HTTP code (or error reason), response time in milliseconds.
|
||||||
4. Prepend a new section to `status-log.md`:
|
4. Prepend a new section to `status-log.md`:
|
||||||
```
|
```
|
||||||
@@ -48,19 +56,20 @@ The cron job runs this project's "Check site status" prompt. When invoked:
|
|||||||
- `Sites Down` stat widget: `value` = count of down results.
|
- `Sites Down` stat widget: `value` = count of down results.
|
||||||
- `Last Checked` stat widget: `value` = the ISO-8601 timestamp you just wrote.
|
- `Last Checked` stat widget: `value` = the ISO-8601 timestamp you just wrote.
|
||||||
- `Watched Sites` list widget `items`: one entry per URL with `text` = URL and `status` = `"up"` or `"down"` (lowercase).
|
- `Watched Sites` list widget `items`: one entry per URL with `text` = URL and `status` = `"up"` or `"down"` (lowercase).
|
||||||
|
- `First Watched Site` **webview widget** (in the "Live Site Preview" section): set its `url` field to the **first** URL from `values.sites`. This is what the user sees rendered in the Scarf **Site** tab. If `values.sites` is empty, leave the webview's existing `url` alone.
|
||||||
6. If the cron job has a `deliver` target set, emit a one-line summary (`3 up, 1 down — example.com timed out`) as the agent's final response so the delivery mechanism picks it up.
|
6. If the cron job has a `deliver` target set, emit a one-line summary (`3 up, 1 down — example.com timed out`) as the agent's final response so the delivery mechanism picks it up.
|
||||||
|
|
||||||
## What not to do
|
## What not to do
|
||||||
|
|
||||||
- Don't modify the structure of `dashboard.json` (section titles, widget types, widget titles, `columns`). Only the values listed above are writable.
|
- Don't modify the structure of `dashboard.json` (section titles, widget types, widget titles, `columns`). Only the values listed above are writable.
|
||||||
|
- Don't edit `.scarf/config.json` — that's the user's responsibility via the Configuration UI.
|
||||||
- Don't truncate `status-log.md` — it's the historical record. If it grows past 1 MB, add a one-line note at the top of the file asking the user to archive it.
|
- Don't truncate `status-log.md` — it's the historical record. If it grows past 1 MB, add a one-line note at the top of the file asking the user to archive it.
|
||||||
- Don't invent URLs. If `sites.txt` is empty or missing, leave the dashboard untouched and write a single `status-log.md` entry noting "no sites configured."
|
- Don't invent URLs or pull them from anywhere other than `values.sites`.
|
||||||
- Don't run browsers or headless Chrome. Plain HTTP GET is sufficient.
|
- Don't run browsers or headless Chrome. Plain HTTP GET is sufficient.
|
||||||
|
|
||||||
## When the user asks you things
|
## When the user asks you things
|
||||||
|
|
||||||
- "What's the status of my sites?" — read the top section of `status-log.md` and summarize.
|
- "What's the status of my sites?" — read the top section of `status-log.md` and summarize.
|
||||||
- "Add a site" — append the URL to `sites.txt` on its own line. Don't sort or reorder existing entries. Confirm back to the user which URL you added.
|
- "Add a site" / "Remove a site" — tell them: *"Click the Configuration button on the dashboard header (the slider icon, next to the folder). Add or remove the URL there and save. The next cron run will pick it up."* Don't try to edit config.json yourself.
|
||||||
- "Remove a site" — delete the matching line from `sites.txt`. If multiple match, ask before choosing.
|
|
||||||
- "Run the check now" — do everything in the cron flow above, then summarize the results in chat.
|
- "Run the check now" — do everything in the cron flow above, then summarize the results in chat.
|
||||||
- "Why is [site] down?" — read the last 3-5 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
|
- "Why is [site] down?" — read the last 3–5 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
|
||||||
|
|||||||
@@ -2,32 +2,38 @@
|
|||||||
|
|
||||||
A minimal uptime watchdog that pings a list of URLs once a day, records pass/fail results, and keeps a simple Scarf dashboard up to date.
|
A minimal uptime watchdog that pings a list of URLs once a day, records pass/fail results, and keeps a simple Scarf dashboard up to date.
|
||||||
|
|
||||||
|
**Requires Scarf 2.3+** — this template uses the configuration feature (a form during install, and a Configuration button on the dashboard for editing later).
|
||||||
|
|
||||||
## What you get
|
## What you get
|
||||||
|
|
||||||
- **`sites.txt`** — one URL per line. This is the source of truth for what the cron job checks. Edit it to add or remove sites.
|
- **Configurable site list** — you tell Scarf which URLs to watch during install, via a form. No file editing required. Edit the list later via the **Configuration** button on the project dashboard (slider icon next to the folder).
|
||||||
- **`status-log.md`** — the agent's append-only log of check results. New runs append a section at the top.
|
- **Configurable timeout** — how long to wait per URL before giving up, also set via the form.
|
||||||
|
- **`.scarf/config.json`** — where your configured values land. The agent reads this at run time; you never need to open it by hand.
|
||||||
|
- **`status-log.md`** — the agent's append-only log of check results. New runs append a section at the top. Created automatically on first run.
|
||||||
- **`.scarf/dashboard.json`** — Scarf dashboard with live stat widgets (sites up, sites down, last checked), the full list of watched sites with their last-known status, and a usage guide.
|
- **`.scarf/dashboard.json`** — Scarf dashboard with live stat widgets (sites up, sites down, last checked), the full list of watched sites with their last-known status, and a usage guide.
|
||||||
- **Cron job `Check site status`** — registered (paused) by the installer; tag `[tmpl:awizemann/site-status-checker]`. Runs daily at 9:00 AM when enabled. The prompt tells the agent to read `sites.txt`, check each URL, write results to `status-log.md`, and update the stat widgets in `dashboard.json`.
|
- **Cron job `Check site status`** — registered (paused) by the installer; tag `[tmpl:awizemann/site-status-checker]`. Runs daily at 9:00 AM when enabled. Reads your configured sites + timeout, hits each URL, writes results to `status-log.md`, and updates the dashboard.
|
||||||
|
|
||||||
## First steps
|
## First steps
|
||||||
|
|
||||||
1. Open the **Cron** sidebar and enable the `[tmpl:awizemann/site-status-checker] Check site status` job. It's paused on install so nothing runs without your explicit say-so.
|
1. During install, fill in the Configuration form: add the URLs you want to watch and (optionally) adjust the timeout. Hit Continue, then Install.
|
||||||
2. Edit `sites.txt` in your project root — replace the two placeholder URLs with the sites you actually want to watch.
|
2. After install, open the **Cron** sidebar and enable the `[tmpl:awizemann/site-status-checker] Check site status` job. It's paused on install so nothing runs without your explicit say-so.
|
||||||
3. From the project's dashboard, ask your agent to run the job now: "Run the site status check and update the dashboard."
|
3. From the project's dashboard, ask your agent to run the job now: *"Run the site status check and update the dashboard."*
|
||||||
4. Future runs happen automatically at 9 AM daily.
|
4. Future runs happen automatically at 9 AM daily.
|
||||||
|
|
||||||
|
## Changing sites or timeout later
|
||||||
|
|
||||||
|
Click the **Configuration** button (slider icon, dashboard toolbar) to re-open the form pre-filled with your current values. Add, remove, or edit URLs. Save. The next cron run picks up the changes.
|
||||||
|
|
||||||
## Customizing
|
## Customizing
|
||||||
|
|
||||||
- **Change the schedule.** Edit the cron job in the Cron sidebar — the schedule field accepts `30m`, `every 2h`, or standard cron expressions like `0 9 * * *`.
|
- **Change the schedule.** Edit the cron job in the Cron sidebar — the schedule field accepts `30m`, `every 2h`, or standard cron expressions like `0 9 * * *`.
|
||||||
- **Change what "down" means.** By default the agent treats any non-2xx HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
|
- **Change what "down" means.** By default the agent treats any non-2xx/3xx HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
|
||||||
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `status-log.md`.
|
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `status-log.md`.
|
||||||
|
|
||||||
|
## Recommended model
|
||||||
|
|
||||||
|
`claude-haiku-4` works well — this is a simple tool-use task (HTTP GETs + a short summary). Haiku keeps costs low when the cron runs daily. The recommendation appears in the Configuration form; Scarf doesn't auto-switch your active model, so adjust via Settings if you'd like.
|
||||||
|
|
||||||
## Uninstalling
|
## Uninstalling
|
||||||
|
|
||||||
Templates don't auto-uninstall in Scarf 2.2. To remove this one by hand:
|
Right-click the project in the sidebar → **Uninstall Template…** (or click the shippingbox icon on the dashboard header). Scarf walks you through exactly what's about to be removed: template-installed files in the project dir, the `[tmpl:…]` cron job, and the Configuration values you entered (`config.json` + Keychain items for any secrets — though this template has none). User-created files (like `status-log.md`) are preserved.
|
||||||
|
|
||||||
1. Delete this project directory (removes the dashboard, AGENTS.md, sites.txt, status-log.md).
|
|
||||||
2. Remove the project entry from the Scarf sidebar (click the `−` next to the project name).
|
|
||||||
3. Delete the `[tmpl:awizemann/site-status-checker] Check site status` cron job from the Cron sidebar.
|
|
||||||
|
|
||||||
No memory appendix or skills were installed, so nothing else needs cleanup.
|
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Check site status",
|
"name": "Check site status",
|
||||||
"schedule": "0 9 * * *",
|
"schedule": "0 9 * * *",
|
||||||
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read sites.txt, HTTP GET each URL, prepend a results section to status-log.md, and update the three stat widgets plus the Watched Sites list items in .scarf/dashboard.json. When done, reply with a one-line summary like '3 up, 1 down — example.com timed out'."
|
"prompt": "Run the site status check for the Scarf project at {{PROJECT_DIR}}. Read {{PROJECT_DIR}}/.scarf/config.json to get `values.sites` (the URL list) and `values.timeout_seconds` (the per-URL HTTP timeout). HTTP GET each URL with that timeout, following up to 3 redirects; treat 2xx/3xx as up and anything else (including timeouts and DNS failures) as down. Prepend a new timestamped results section to {{PROJECT_DIR}}/status-log.md — create the file with a one-line header if it doesn't exist yet. Update {{PROJECT_DIR}}/.scarf/dashboard.json: set the Sites Up / Sites Down / Last Checked stat widgets' `value` fields; replace the 'Watched Sites' list widget's `items` array with one entry per URL (text = URL, status = \"up\" or \"down\"); and if `values.sites` is non-empty, set the 'First Watched Site' webview widget's `url` field to the FIRST URL from `values.sites` (otherwise leave the webview's existing url alone). Preserve every other field in dashboard.json as-is. Reply with a one-line summary like '3 up, 1 down — example.com timed out'."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"title": "Site Status",
|
"title": "Site Status",
|
||||||
"description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.",
|
"description": "Daily uptime check for your watched URLs. The stat widgets, the sites list, and the Site tab's preview URL all update automatically when the cron job runs. Switch to the Site tab to see your first watched site live.",
|
||||||
"theme": { "accent": "green" },
|
"theme": { "accent": "green" },
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
@@ -40,14 +40,25 @@
|
|||||||
"widgets": [
|
"widgets": [
|
||||||
{
|
{
|
||||||
"type": "list",
|
"type": "list",
|
||||||
"title": "Configured Sites (from sites.txt)",
|
"title": "Watched Sites (populated after first run)",
|
||||||
"items": [
|
"items": [
|
||||||
{ "text": "https://example.com", "status": "unknown" },
|
{ "text": "Run the check once to populate — the agent reads your Configuration and fills this list with live status.", "status": "pending" }
|
||||||
{ "text": "https://example.org", "status": "unknown" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Live Site Preview",
|
||||||
|
"columns": 1,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "First Watched Site",
|
||||||
|
"url": "https://awizemann.github.io/scarf/",
|
||||||
|
"height": 420
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "How to Use",
|
"title": "How to Use",
|
||||||
"columns": 1,
|
"columns": 1,
|
||||||
@@ -56,7 +67,7 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"title": "Quick Start",
|
"title": "Quick Start",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"content": "**1.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**2.** Edit `sites.txt` in this project's folder to replace the placeholder URLs with the sites you actually want to watch.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The dashboard refreshes and a new entry appears at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
"content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. The sites you enter there are what the cron job will check.\n\n**2.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The Watched Sites list populates, the stat widgets update, the Site tab's URL switches to your first watched site, and a new entry lands at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSwitch to the **Site** tab (next to Dashboard, above) to see your first watched site rendered in a browser. Useful to eyeball a site when the status says up but something still looks off.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 2,
|
||||||
"id": "awizemann/site-status-checker",
|
"id": "awizemann/site-status-checker",
|
||||||
"name": "Site Status Checker",
|
"name": "Site Status Checker",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"minScarfVersion": "2.2.0",
|
"minScarfVersion": "2.3.0",
|
||||||
"minHermesVersion": "0.9.0",
|
"minHermesVersion": "0.9.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Alan Wizemann",
|
"name": "Alan Wizemann",
|
||||||
"url": "https://github.com/awizemann/scarf"
|
"url": "https://github.com/awizemann/scarf"
|
||||||
},
|
},
|
||||||
"description": "A daily uptime check for a short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
|
"description": "A daily uptime check for a list of URLs you configure on install. Writes status to status-log.md and updates the dashboard with current counts.",
|
||||||
"category": "monitoring",
|
"category": "monitoring",
|
||||||
"tags": ["monitoring", "uptime", "cron", "starter"],
|
"tags": ["monitoring", "uptime", "cron", "starter", "configurable"],
|
||||||
"contents": {
|
"contents": {
|
||||||
"dashboard": true,
|
"dashboard": true,
|
||||||
"agentsMd": true,
|
"agentsMd": true,
|
||||||
"cron": 1
|
"cron": 1,
|
||||||
|
"config": 2
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"key": "sites",
|
||||||
|
"type": "list",
|
||||||
|
"itemType": "string",
|
||||||
|
"label": "Sites to Watch",
|
||||||
|
"description": "One URL per item. HTTP or HTTPS. You can add and remove entries after install via the Configuration button on the dashboard.",
|
||||||
|
"required": true,
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 25,
|
||||||
|
"default": ["https://example.com", "https://example.org"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "timeout_seconds",
|
||||||
|
"type": "number",
|
||||||
|
"label": "Request Timeout (seconds)",
|
||||||
|
"description": "How long to wait for each URL before giving up.",
|
||||||
|
"required": false,
|
||||||
|
"min": 1,
|
||||||
|
"max": 60,
|
||||||
|
"default": 10
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"modelRecommendation": {
|
||||||
|
"preferred": "claude-haiku-4",
|
||||||
|
"rationale": "Simple tool-use task — HTTP GETs + a short summary. Haiku is plenty and keeps cost low when the cron runs daily."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-6
@@ -7,28 +7,62 @@
|
|||||||
"name": "Alan Wizemann",
|
"name": "Alan Wizemann",
|
||||||
"url": "https://github.com/awizemann/scarf"
|
"url": "https://github.com/awizemann/scarf"
|
||||||
},
|
},
|
||||||
"bundleSha256": "32b8c12706de8596be63dcdda32d46fc5bf478d5b9f7c1fc4c6d96ced251186a",
|
"bundleSha256": "0a20802a8830a7cfdd1afa2888e42e113c9a17a37439384a3037d32ad1f24c1f",
|
||||||
"bundleSize": 5410,
|
"bundleSize": 7569,
|
||||||
"category": "monitoring",
|
"category": "monitoring",
|
||||||
|
"config": {
|
||||||
|
"modelRecommendation": {
|
||||||
|
"preferred": "claude-haiku-4",
|
||||||
|
"rationale": "Simple tool-use task \u2014 HTTP GETs + a short summary. Haiku is plenty and keeps cost low when the cron runs daily."
|
||||||
|
},
|
||||||
|
"schema": [
|
||||||
|
{
|
||||||
|
"default": [
|
||||||
|
"https://example.com",
|
||||||
|
"https://example.org"
|
||||||
|
],
|
||||||
|
"description": "One URL per item. HTTP or HTTPS. You can add and remove entries after install via the Configuration button on the dashboard.",
|
||||||
|
"itemType": "string",
|
||||||
|
"key": "sites",
|
||||||
|
"label": "Sites to Watch",
|
||||||
|
"maxItems": 25,
|
||||||
|
"minItems": 1,
|
||||||
|
"required": true,
|
||||||
|
"type": "list"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"default": 10,
|
||||||
|
"description": "How long to wait for each URL before giving up.",
|
||||||
|
"key": "timeout_seconds",
|
||||||
|
"label": "Request Timeout (seconds)",
|
||||||
|
"max": 60,
|
||||||
|
"min": 1,
|
||||||
|
"required": false,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"contents": {
|
"contents": {
|
||||||
"agentsMd": true,
|
"agentsMd": true,
|
||||||
|
"config": 2,
|
||||||
"cron": 1,
|
"cron": 1,
|
||||||
"dashboard": true
|
"dashboard": true
|
||||||
},
|
},
|
||||||
"description": "A daily uptime check for a short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
|
"description": "A daily uptime check for a list of URLs you configure on install. Writes status to status-log.md and updates the dashboard with current counts.",
|
||||||
"detailSlug": "awizemann-site-status-checker",
|
"detailSlug": "awizemann-site-status-checker",
|
||||||
"id": "awizemann/site-status-checker",
|
"id": "awizemann/site-status-checker",
|
||||||
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
|
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
|
||||||
"minHermesVersion": "0.9.0",
|
"minHermesVersion": "0.9.0",
|
||||||
"minScarfVersion": "2.2.0",
|
"minScarfVersion": "2.3.0",
|
||||||
"name": "Site Status Checker",
|
"name": "Site Status Checker",
|
||||||
"tags": [
|
"tags": [
|
||||||
"monitoring",
|
"monitoring",
|
||||||
"uptime",
|
"uptime",
|
||||||
"cron",
|
"cron",
|
||||||
"starter"
|
"starter",
|
||||||
|
"configurable"
|
||||||
],
|
],
|
||||||
"version": "1.0.0"
|
"version": "1.1.0"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-7
@@ -45,11 +45,18 @@ from typing import Iterable
|
|||||||
# Schema + invariants
|
# 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
|
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")
|
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
|
||||||
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
|
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
|
# Common secret patterns — keep in sync with `scripts/wiki.sh` and reuse a
|
||||||
# conservative subset. The validator rejects hard matches; the site's
|
# conservative subset. The validator rejects hard matches; the site's
|
||||||
# CONTRIBUTING guide covers the rest.
|
# CONTRIBUTING guide covers the rest.
|
||||||
@@ -100,7 +107,9 @@ class TemplateRecord:
|
|||||||
|
|
||||||
def to_catalog_entry(self) -> dict:
|
def to_catalog_entry(self) -> dict:
|
||||||
"""Subset suitable for catalog.json. Keep fields stable — the
|
"""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
|
m = self.manifest
|
||||||
return {
|
return {
|
||||||
"id": m["id"],
|
"id": m["id"],
|
||||||
@@ -111,6 +120,7 @@ class TemplateRecord:
|
|||||||
"category": m.get("category"),
|
"category": m.get("category"),
|
||||||
"tags": m.get("tags") or [],
|
"tags": m.get("tags") or [],
|
||||||
"contents": m["contents"],
|
"contents": m["contents"],
|
||||||
|
"config": m.get("config"), # None for schema-less
|
||||||
"installUrl": self.install_url,
|
"installUrl": self.install_url,
|
||||||
"detailSlug": self.detail_slug,
|
"detailSlug": self.detail_slug,
|
||||||
"bundleSha256": self.bundle_sha256,
|
"bundleSha256": self.bundle_sha256,
|
||||||
@@ -154,8 +164,12 @@ def _validate_manifest(manifest: dict, template_dir: Path, errors: list[Validati
|
|||||||
for field in required:
|
for field in required:
|
||||||
if field not in manifest:
|
if field not in manifest:
|
||||||
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
|
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
|
||||||
if manifest.get("schemaVersion") != SCHEMA_VERSION:
|
if manifest.get("schemaVersion") not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
errors.append(ValidationError(template_dir, f"unsupported schemaVersion: {manifest.get('schemaVersion')}"))
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"unsupported schemaVersion: {manifest.get('schemaVersion')} "
|
||||||
|
f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})"
|
||||||
|
))
|
||||||
# Manifest id must match the directory layout.
|
# Manifest id must match the directory layout.
|
||||||
mid = manifest.get("id", "")
|
mid = manifest.get("id", "")
|
||||||
if "/" not in mid:
|
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}"
|
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:
|
def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||||
"""Decode dashboard.json against the widget-type vocabulary the Swift
|
"""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
|
return None, errors
|
||||||
|
|
||||||
_validate_manifest(manifest, template_dir, errors)
|
_validate_manifest(manifest, template_dir, errors)
|
||||||
|
_validate_config_schema(manifest, template_dir, errors)
|
||||||
cron_count = _parse_cron_jobs(zf, template_dir, errors)
|
cron_count = _parse_cron_jobs(zf, template_dir, errors)
|
||||||
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
|
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
|
||||||
_validate_dashboard(zf, 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:
|
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
|
||||||
catalog = {
|
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
|
"generated": True, # human reminder; a timestamp would churn the diff every run
|
||||||
"templates": [r.to_catalog_entry() for r in records],
|
"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),
|
render_detail(template_tmpl, r),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
# Copy the unpacked dashboard.json so widgets.js can fetch it
|
# Copy the unpacked dashboard.json, README.md, and template.json
|
||||||
# without cross-directory relative paths.
|
# (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:
|
with zipfile.ZipFile(r.bundle_path, "r") as zf:
|
||||||
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
|
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
|
||||||
if "README.md" in zf.namelist():
|
if "README.md" in zf.namelist():
|
||||||
(detail_dir / "README.md").write_bytes(zf.read("README.md"))
|
(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
|
# The aggregate catalog.json is copied in so the frontend can fetch
|
||||||
# /templates/catalog.json without reaching back into the repo.
|
# /templates/catalog.json without reaching back into the repo.
|
||||||
|
|||||||
@@ -335,6 +335,194 @@ class ValidationTests(unittest.TestCase):
|
|||||||
return records, errors
|
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):
|
class CatalogJsonTests(unittest.TestCase):
|
||||||
"""Shape of the emitted catalog.json must stay stable — the site's
|
"""Shape of the emitted catalog.json must stay stable — the site's
|
||||||
widgets.js reads these fields by name."""
|
widgets.js reads these fields by name."""
|
||||||
|
|||||||
Reference in New Issue
Block a user