mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55229a2f91 | |||
| 99859c06fd | |||
| 9f3600ae01 | |||
| b34f432f00 | |||
| b289a83944 | |||
| 64b7d3beaf | |||
| 385c3a2e4d | |||
| e76fbf9937 | |||
| c9b8da9ec5 | |||
| 6175bee27d | |||
| 11732baa3c | |||
| d8a0a89db2 | |||
| 38c075d61d | |||
| c800b93804 | |||
| 7311320bfd | |||
| 4663697942 | |||
| 41635955b0 | |||
| 1989feee22 | |||
| 8773254d11 |
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
Use this template when submitting a new Scarf project template or updating
|
||||||
|
an existing one. For regular code/docs PRs, delete this template and write
|
||||||
|
your own summary.
|
||||||
|
|
||||||
|
Switch to this template by adding `?template=template-submission.md` to the
|
||||||
|
compare URL, or let GitHub pick it up automatically when you touch files
|
||||||
|
under templates/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What's in this PR
|
||||||
|
|
||||||
|
- [ ] New template: `templates/<your-handle>/<your-template-name>/`
|
||||||
|
- [ ] Update to existing template: `templates/<author>/<name>/` (which one and why)
|
||||||
|
|
||||||
|
## One-line pitch
|
||||||
|
|
||||||
|
_What does this template do for its installers? Two sentences max._
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I wrote this template, or have the author's explicit permission to submit it.
|
||||||
|
- [ ] `AGENTS.md` is present and tells any cross-agent what the project does and how to run it.
|
||||||
|
- [ ] `README.md` includes install, customize, and uninstall instructions.
|
||||||
|
- [ ] The bundle's `template.json` `contents` claim matches what's actually in the zip.
|
||||||
|
- [ ] Cron jobs (if any) ship paused and use self-contained prompts.
|
||||||
|
- [ ] No secrets in any file (API keys, tokens, hostnames, IPs, credentials).
|
||||||
|
- [ ] No writes to `config.yaml`, `auth.json`, or credential paths — v1 installer will refuse.
|
||||||
|
- [ ] `python3 tools/build-catalog.py --check` passes locally.
|
||||||
|
- [ ] I installed + uninstalled this template on my machine and verified the `AGENTS.md` contract works end-to-end.
|
||||||
|
- [ ] I did **not** edit `templates/catalog.json` — the maintainer regenerates it post-merge.
|
||||||
|
|
||||||
|
## Testing notes
|
||||||
|
|
||||||
|
_What did you run, what did you see? Paste the log output of the cron job
|
||||||
|
firing once, or the chat transcript of asking the agent to do the main
|
||||||
|
thing. Reviewers don't have your machine — show, don't tell._
|
||||||
|
|
||||||
|
## Screenshots (optional)
|
||||||
|
|
||||||
|
_Drop screenshots of the installed dashboard, or the catalog detail page
|
||||||
|
rendered locally (`./scripts/catalog.sh preview && open /tmp/scarf-catalog-preview/templates/<slug>/index.html`)._
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Validates `.scarftemplate` bundles on PRs that touch templates/.
|
||||||
|
#
|
||||||
|
# Mirrors the invariants `ProjectTemplateService.verifyClaims` enforces at
|
||||||
|
# install time. Runs the same Python script the maintainer uses locally
|
||||||
|
# (tools/build-catalog.py --check) so a bundle can't reach main unless the
|
||||||
|
# validator is happy.
|
||||||
|
#
|
||||||
|
# Also runs tools/test_build_catalog.py so drift between the validator and
|
||||||
|
# its own test suite is caught on the same PR.
|
||||||
|
|
||||||
|
name: Validate template submissions
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'templates/**'
|
||||||
|
- 'tools/build-catalog.py'
|
||||||
|
- 'tools/test_build_catalog.py'
|
||||||
|
- '.github/workflows/validate-template-pr.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full clone so we can diff against the PR base and scope
|
||||||
|
# --only to just the changed templates if we want to later.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# The validator is stdlib-only and tested against 3.9+ (the
|
||||||
|
# system Python on current macOS, what most maintainers run
|
||||||
|
# locally). CI uses 3.11 for faster cold-cache times on
|
||||||
|
# GitHub Actions runners — same stdlib APIs, same code paths.
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Run validator unit tests
|
||||||
|
run: python3 tools/test_build_catalog.py -v
|
||||||
|
|
||||||
|
- name: Validate every template
|
||||||
|
id: validate
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
python3 tools/build-catalog.py --check 2>&1 | tee /tmp/validator.log
|
||||||
|
|
||||||
|
- name: Post failure comment
|
||||||
|
if: failure() && steps.validate.outcome == 'failure'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
let body = '## Template validation failed\n\n';
|
||||||
|
try {
|
||||||
|
const log = fs.readFileSync('/tmp/validator.log', 'utf8');
|
||||||
|
body += '```\n' + log.slice(-3000) + '\n```\n';
|
||||||
|
} catch (e) {
|
||||||
|
body += 'See the failed job log for details.\n';
|
||||||
|
}
|
||||||
|
body += '\nFix the issues above and push again — the check reruns automatically.\n';
|
||||||
|
body += '\nLocal reproduction: `python3 tools/build-catalog.py --check`\n';
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
@@ -85,3 +85,82 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
|
|||||||
## Hermes Version
|
## Hermes Version
|
||||||
|
|
||||||
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||||
|
|
||||||
|
## Project Templates
|
||||||
|
|
||||||
|
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
|
||||||
|
|
||||||
|
- `template.json` — manifest (id, name, version, `contents` claim)
|
||||||
|
- `README.md` — shown in the install preview sheet
|
||||||
|
- `AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
|
||||||
|
- `dashboard.json` — copied to `<project>/.scarf/dashboard.json`
|
||||||
|
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||||
|
- `skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
|
||||||
|
- `cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
|
||||||
|
- `memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
|
||||||
|
|
||||||
|
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
|
||||||
|
|
||||||
|
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
- **Validator + regenerator:** [tools/build-catalog.py](tools/build-catalog.py) is stdlib-only Python (3.9+). It walks `templates/*/*/`, validates every `.scarftemplate` against its manifest claim (mirrors the Swift `ProjectTemplateService.verifyClaims` invariants), enforces a 5 MB bundle-size cap, scans for high-confidence secret patterns, checks `staging/` matches the built bundle byte-for-byte, and emits `templates/catalog.json`. Tested by [tools/test_build_catalog.py](tools/test_build_catalog.py) — 16 tests covering every validation path.
|
||||||
|
- **Wrapper:** [scripts/catalog.sh](scripts/catalog.sh) mirrors the `scripts/wiki.sh` shape with `check / build / preview / serve / publish` subcommands. `publish` runs a second-pass secret-scan against the rendered site before committing + pushing `gh-pages`.
|
||||||
|
- **Site source:** `site/index.html.tmpl` + `site/template.html.tmpl` are `{{TOKEN}}`-substitution templates. `site/widgets.js` (~300 lines of vanilla JS) is the dogfood — renders a `ProjectDashboard` JSON into HTML using the same widget vocabulary the Swift app uses, so each template's detail page shows a live preview of its post-install dashboard.
|
||||||
|
- **Install-URL hosting:** raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template Releases ceremony.
|
||||||
|
- **CI gate:** [.github/workflows/validate-template-pr.yml](.github/workflows/validate-template-pr.yml) runs the Python validator + its own test suite on every PR that touches `templates/`, the validator, or its tests. Failures post a comment on the PR with the last 3 KB of the validator log.
|
||||||
|
|
||||||
|
Maintainer workflow on merge to main:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/catalog.sh build # regenerate templates/catalog.json + .gh-pages-worktree/templates/
|
||||||
|
./scripts/catalog.sh publish # secret-scan rendered output + commit + push gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Same cadence as `scripts/release.sh` (manual, auditable, no auto-deploy). Runs stay isolated: release.sh only touches `appcast.xml` on gh-pages; catalog.sh only touches `templates/` on gh-pages. Never push catalog output on a release cadence or vice versa.
|
||||||
|
|
||||||
|
**Schema is Swift-primary.** When `ProjectDashboardWidget.type` gains a new case or `ProjectTemplateManifest` adds a field, update Swift first, then mirror into `tools/build-catalog.py` (`SUPPORTED_WIDGET_TYPES`, `_validate_manifest`, `_validate_contents_claim`) so the web validator stays honest. The Python test suite's real-bundle test catches drift on the example template but not on the full widget vocabulary — add a synthetic fixture to `test_build_catalog.py` for any new widget type.
|
||||||
|
|||||||
@@ -19,7 +19,15 @@
|
|||||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## What's New in 2.1
|
## 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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0).
|
||||||
|
|
||||||
|
### Previously, in 2.1
|
||||||
|
|
||||||
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
|
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
|
||||||
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
|
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
|
||||||
@@ -387,6 +395,16 @@ Signing prerequisites (one-time):
|
|||||||
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
||||||
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
||||||
|
|
||||||
|
## Template Catalog
|
||||||
|
|
||||||
|
Community-contributed Scarf project templates live under [`templates/`](templates/) in this repo and are browsable at **[awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/)** with live dashboard previews and one-click `scarf://install?url=…` links.
|
||||||
|
|
||||||
|
- **Install from the web** — click "Install with Scarf" on any template's detail page; the app takes over from there.
|
||||||
|
- **Install from a local file** — Scarf → Projects → Templates → Install from File…, or double-click any `.scarftemplate` in Finder.
|
||||||
|
- **Author a template** — see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) for the full walkthrough. Fork, drop a template under `templates/<your-github-handle>/<your-name>/`, open a PR; CI validates the bundle automatically.
|
||||||
|
|
||||||
|
The catalog's site is a static HTML + vanilla JS build generated by [`tools/build-catalog.py`](tools/build-catalog.py) and driven by [`scripts/catalog.sh`](scripts/catalog.sh) (check / build / preview / publish). Appcast and main landing page are independent — updating the catalog never disturbs Sparkle.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||||
@@ -397,6 +415,8 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
|
|||||||
4. Push to the branch (`git push origin feature/my-feature`)
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
For template submissions, see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) — same flow, with a catalog-specific checklist + automated CI validation.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you find Scarf useful, consider buying me a coffee.
|
If you find Scarf useful, consider buying me a coffee.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
## 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.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- **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.
|
||||||
|
- **`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.
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Using templates
|
||||||
|
|
||||||
|
- **Install from file:** Projects → Templates → *Install from File…*, pick a `.scarftemplate` from disk.
|
||||||
|
- **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.
|
||||||
|
- **Export:** select a project → Projects → Templates → *Export "<name>" as Template…*, fill the form, save.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock, errors).
|
||||||
|
- `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.
|
||||||
|
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
- **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.
|
||||||
|
- **No undo.** v1 uninstall is destructive — to reinstall, run the install flow again.
|
||||||
|
|
||||||
|
### Not in this release (planned for v2.3)
|
||||||
|
|
||||||
|
- In-app catalog browser backed by a GitHub Pages `templates.json`.
|
||||||
|
- EdDSA-signed bundles reusing the Sparkle key.
|
||||||
|
- Template updates (compare installed lock against a newer bundle's version, offer a diff).
|
||||||
|
- Installing into remote `ServerContext`s (v1 is local-only).
|
||||||
|
|
||||||
|
### Migrating from 2.1.x
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"appearances" : [
|
||||||
|
{
|
||||||
|
"appearance" : "luminosity",
|
||||||
|
"value" : "tinted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
//
|
||||||
|
// ContentView.swift
|
||||||
|
// Scarf iOS
|
||||||
|
//
|
||||||
|
// Created by Alan Wizemann on 4/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Query private var items: [Item]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
List {
|
||||||
|
ForEach(items) { item in
|
||||||
|
NavigationLink {
|
||||||
|
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||||
|
} label: {
|
||||||
|
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDelete(perform: deleteItems)
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
EditButton()
|
||||||
|
}
|
||||||
|
ToolbarItem {
|
||||||
|
Button(action: addItem) {
|
||||||
|
Label("Add Item", systemImage: "plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} detail: {
|
||||||
|
Text("Select an item")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func addItem() {
|
||||||
|
withAnimation {
|
||||||
|
let newItem = Item(timestamp: Date())
|
||||||
|
modelContext.insert(newItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteItems(offsets: IndexSet) {
|
||||||
|
withAnimation {
|
||||||
|
for index in offsets {
|
||||||
|
modelContext.delete(items[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ContentView()
|
||||||
|
.modelContainer(for: Item.self, inMemory: true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>remote-notification</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//
|
||||||
|
// Item.swift
|
||||||
|
// Scarf iOS
|
||||||
|
//
|
||||||
|
// Created by Alan Wizemann on 4/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@Model
|
||||||
|
final class Item {
|
||||||
|
var timestamp: Date
|
||||||
|
|
||||||
|
init(timestamp: Date) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||||
|
<array/>
|
||||||
|
<key>com.apple.developer.icloud-services</key>
|
||||||
|
<array>
|
||||||
|
<string>CloudKit</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//
|
||||||
|
// Scarf_iOSApp.swift
|
||||||
|
// Scarf iOS
|
||||||
|
//
|
||||||
|
// Created by Alan Wizemann on 4/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct Scarf_iOSApp: App {
|
||||||
|
var sharedModelContainer: ModelContainer = {
|
||||||
|
let schema = Schema([
|
||||||
|
Item.self,
|
||||||
|
])
|
||||||
|
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||||
|
} catch {
|
||||||
|
fatalError("Could not create ModelContainer: \(error)")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
.modelContainer(sharedModelContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// Scarf_iOSTests.swift
|
||||||
|
// Scarf iOSTests
|
||||||
|
//
|
||||||
|
// Created by Alan Wizemann on 4/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
@testable import Scarf_iOS
|
||||||
|
|
||||||
|
struct Scarf_iOSTests {
|
||||||
|
|
||||||
|
@Test func example() async throws {
|
||||||
|
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//
|
||||||
|
// Scarf_iOSUITests.swift
|
||||||
|
// Scarf iOSUITests
|
||||||
|
//
|
||||||
|
// Created by Alan Wizemann on 4/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class Scarf_iOSUITests: XCTestCase {
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||||
|
|
||||||
|
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||||
|
continueAfterFailure = false
|
||||||
|
|
||||||
|
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||||
|
}
|
||||||
|
|
||||||
|
override func tearDownWithError() throws {
|
||||||
|
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testExample() throws {
|
||||||
|
// UI tests must launch the application that they test.
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunchPerformance() throws {
|
||||||
|
// This measures how long it takes to launch your application.
|
||||||
|
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||||
|
XCUIApplication().launch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
//
|
||||||
|
// Scarf_iOSUITestsLaunchTests.swift
|
||||||
|
// Scarf iOSUITests
|
||||||
|
//
|
||||||
|
// Created by Alan Wizemann on 4/23/26.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class Scarf_iOSUITestsLaunchTests: XCTestCase {
|
||||||
|
|
||||||
|
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testLaunch() throws {
|
||||||
|
let app = XCUIApplication()
|
||||||
|
app.launch()
|
||||||
|
|
||||||
|
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||||
|
// such as logging into a test account or navigating somewhere in the app
|
||||||
|
|
||||||
|
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||||
|
attachment.name = "Launch Screen"
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,20 @@
|
|||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
4EAC233A2F99930100654F42 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 4EAC23282F99930000654F42;
|
||||||
|
remoteInfo = "Scarf iOS";
|
||||||
|
};
|
||||||
|
4EAC23442F99930100654F42 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = 4EAC23282F99930000654F42;
|
||||||
|
remoteInfo = "Scarf iOS";
|
||||||
|
};
|
||||||
534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
534959502F7B83B700BD31AD /* PBXContainerItemProxy */ = {
|
||||||
isa = PBXContainerItemProxy;
|
isa = PBXContainerItemProxy;
|
||||||
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
|
||||||
@@ -29,12 +43,22 @@
|
|||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
4EAC23292F99930000654F42 /* scarf mobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "scarf mobile.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Scarf iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Scarf iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
534959402F7B83B600BD31AD /* scarf.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = scarf.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
5349594F2F7B83B700BD31AD /* scarfTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
4EAC234B2F99930100654F42 /* Exceptions for "Scarf iOS" folder in "scarf mobile" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = 4EAC23282F99930000654F42 /* scarf mobile */;
|
||||||
|
};
|
||||||
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
@@ -45,6 +69,24 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
4EAC232A2F99930000654F42 /* Scarf iOS */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
4EAC234B2F99930100654F42 /* Exceptions for "Scarf iOS" folder in "scarf mobile" target */,
|
||||||
|
);
|
||||||
|
path = "Scarf iOS";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4EAC233C2F99930100654F42 /* Scarf iOSTests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = "Scarf iOSTests";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4EAC23462F99930100654F42 /* Scarf iOSUITests */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = "Scarf iOSUITests";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
534959422F7B83B600BD31AD /* scarf */ = {
|
534959422F7B83B600BD31AD /* scarf */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
exceptions = (
|
exceptions = (
|
||||||
@@ -66,6 +108,27 @@
|
|||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
4EAC23262F99930000654F42 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4EAC23362F99930100654F42 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4EAC23402F99930100654F42 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
5349593D2F7B83B600BD31AD /* Frameworks */ = {
|
5349593D2F7B83B600BD31AD /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -98,6 +161,9 @@
|
|||||||
534959422F7B83B600BD31AD /* scarf */,
|
534959422F7B83B600BD31AD /* scarf */,
|
||||||
534959522F7B83B700BD31AD /* scarfTests */,
|
534959522F7B83B700BD31AD /* scarfTests */,
|
||||||
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
5349595C2F7B83B700BD31AD /* scarfUITests */,
|
||||||
|
4EAC232A2F99930000654F42 /* Scarf iOS */,
|
||||||
|
4EAC233C2F99930100654F42 /* Scarf iOSTests */,
|
||||||
|
4EAC23462F99930100654F42 /* Scarf iOSUITests */,
|
||||||
534959412F7B83B600BD31AD /* Products */,
|
534959412F7B83B600BD31AD /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -108,6 +174,9 @@
|
|||||||
534959402F7B83B600BD31AD /* scarf.app */,
|
534959402F7B83B600BD31AD /* scarf.app */,
|
||||||
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
|
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
|
||||||
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
|
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
|
||||||
|
4EAC23292F99930000654F42 /* scarf mobile.app */,
|
||||||
|
4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */,
|
||||||
|
4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -115,6 +184,74 @@
|
|||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
|
4EAC23282F99930000654F42 /* scarf mobile */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 4EAC234C2F99930100654F42 /* Build configuration list for PBXNativeTarget "scarf mobile" */;
|
||||||
|
buildPhases = (
|
||||||
|
4EAC23252F99930000654F42 /* Sources */,
|
||||||
|
4EAC23262F99930000654F42 /* Frameworks */,
|
||||||
|
4EAC23272F99930000654F42 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
4EAC232A2F99930000654F42 /* Scarf iOS */,
|
||||||
|
);
|
||||||
|
name = "scarf mobile";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "Scarf iOS";
|
||||||
|
productReference = 4EAC23292F99930000654F42 /* scarf mobile.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
4EAC23382F99930100654F42 /* Scarf iOSTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 4EAC234F2F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSTests" */;
|
||||||
|
buildPhases = (
|
||||||
|
4EAC23352F99930100654F42 /* Sources */,
|
||||||
|
4EAC23362F99930100654F42 /* Frameworks */,
|
||||||
|
4EAC23372F99930100654F42 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
4EAC233B2F99930100654F42 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
4EAC233C2F99930100654F42 /* Scarf iOSTests */,
|
||||||
|
);
|
||||||
|
name = "Scarf iOSTests";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "Scarf iOSTests";
|
||||||
|
productReference = 4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
4EAC23422F99930100654F42 /* Scarf iOSUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 4EAC23522F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
4EAC233F2F99930100654F42 /* Sources */,
|
||||||
|
4EAC23402F99930100654F42 /* Frameworks */,
|
||||||
|
4EAC23412F99930100654F42 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
4EAC23452F99930100654F42 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
fileSystemSynchronizedGroups = (
|
||||||
|
4EAC23462F99930100654F42 /* Scarf iOSUITests */,
|
||||||
|
);
|
||||||
|
name = "Scarf iOSUITests";
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = "Scarf iOSUITests";
|
||||||
|
productReference = 4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
5349593F2F7B83B600BD31AD /* scarf */ = {
|
5349593F2F7B83B600BD31AD /* scarf */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
|
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
|
||||||
@@ -192,9 +329,20 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = 1;
|
BuildIndependentTargetsInParallel = 1;
|
||||||
LastSwiftUpdateCheck = 2630;
|
LastSwiftUpdateCheck = 2620;
|
||||||
LastUpgradeCheck = 2630;
|
LastUpgradeCheck = 2630;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
|
4EAC23282F99930000654F42 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
};
|
||||||
|
4EAC23382F99930100654F42 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
TestTargetID = 4EAC23282F99930000654F42;
|
||||||
|
};
|
||||||
|
4EAC23422F99930100654F42 = {
|
||||||
|
CreatedOnToolsVersion = 26.2;
|
||||||
|
TestTargetID = 4EAC23282F99930000654F42;
|
||||||
|
};
|
||||||
5349593F2F7B83B600BD31AD = {
|
5349593F2F7B83B600BD31AD = {
|
||||||
CreatedOnToolsVersion = 26.3;
|
CreatedOnToolsVersion = 26.3;
|
||||||
};
|
};
|
||||||
@@ -235,11 +383,35 @@
|
|||||||
5349593F2F7B83B600BD31AD /* scarf */,
|
5349593F2F7B83B600BD31AD /* scarf */,
|
||||||
5349594E2F7B83B700BD31AD /* scarfTests */,
|
5349594E2F7B83B700BD31AD /* scarfTests */,
|
||||||
534959582F7B83B700BD31AD /* scarfUITests */,
|
534959582F7B83B700BD31AD /* scarfUITests */,
|
||||||
|
4EAC23282F99930000654F42 /* scarf mobile */,
|
||||||
|
4EAC23382F99930100654F42 /* Scarf iOSTests */,
|
||||||
|
4EAC23422F99930100654F42 /* Scarf iOSUITests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
4EAC23272F99930000654F42 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4EAC23372F99930100654F42 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4EAC23412F99930100654F42 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
5349593E2F7B83B600BD31AD /* Resources */ = {
|
5349593E2F7B83B600BD31AD /* Resources */ = {
|
||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -264,6 +436,27 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
4EAC23252F99930000654F42 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4EAC23352F99930100654F42 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
4EAC233F2F99930100654F42 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
5349593C2F7B83B600BD31AD /* Sources */ = {
|
5349593C2F7B83B600BD31AD /* Sources */ = {
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -288,6 +481,16 @@
|
|||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
|
4EAC233B2F99930100654F42 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 4EAC23282F99930000654F42 /* scarf mobile */;
|
||||||
|
targetProxy = 4EAC233A2F99930100654F42 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
4EAC23452F99930100654F42 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = 4EAC23282F99930000654F42 /* scarf mobile */;
|
||||||
|
targetProxy = 4EAC23442F99930100654F42 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
534959512F7B83B700BD31AD /* PBXTargetDependency */ = {
|
534959512F7B83B700BD31AD /* PBXTargetDependency */ = {
|
||||||
isa = PBXTargetDependency;
|
isa = PBXTargetDependency;
|
||||||
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
target = 5349593F2F7B83B600BD31AD /* scarf */;
|
||||||
@@ -301,11 +504,181 @@
|
|||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
|
4EAC234D2F99930100654F42 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
4EAC234E2F99930100654F42 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
|
ENABLE_PREVIEWS = YES;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "Scarf iOS/Info.plist";
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "Scarf Mobile";
|
||||||
|
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
|
||||||
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
4EAC23502F99930100654F42 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSTests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Scarf iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Scarf iOS";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
4EAC23512F99930100654F42 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSTests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Scarf iOS.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Scarf iOS";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
4EAC23532F99930100654F42 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSUITests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = "Scarf iOS";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
4EAC23542F99930100654F42 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||||
|
MARKETING_VERSION = 1.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "alanwizemann.Scarf-iOSUITests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_TARGET_NAME = "Scarf iOS";
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
534959612F7B83B700BD31AD /* Debug */ = {
|
534959612F7B83B700BD31AD /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -335,6 +708,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
@@ -360,6 +734,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
};
|
};
|
||||||
@@ -370,6 +745,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||||
@@ -399,6 +775,7 @@
|
|||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
@@ -417,6 +794,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
SDKROOT = macosx;
|
SDKROOT = macosx;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -431,12 +809,14 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = scarf/Info.plist;
|
INFOPLIST_FILE = scarf/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
@@ -465,12 +845,14 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
ENABLE_APP_SANDBOX = NO;
|
ENABLE_APP_SANDBOX = NO;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = scarf/Info.plist;
|
INFOPLIST_FILE = scarf/Info.plist;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
@@ -495,6 +877,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
@@ -516,6 +899,7 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||||
@@ -536,6 +920,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 2.1.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
@@ -555,6 +940,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 22;
|
CURRENT_PROJECT_VERSION = 22;
|
||||||
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 2.1.0;
|
MARKETING_VERSION = 2.1.0;
|
||||||
@@ -572,6 +958,33 @@
|
|||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
|
4EAC234C2F99930100654F42 /* Build configuration list for PBXNativeTarget "scarf mobile" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
4EAC234D2F99930100654F42 /* Debug */,
|
||||||
|
4EAC234E2F99930100654F42 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
4EAC234F2F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSTests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
4EAC23502F99930100654F42 /* Debug */,
|
||||||
|
4EAC23512F99930100654F42 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
4EAC23522F99930100654F42 /* Build configuration list for PBXNativeTarget "Scarf iOSUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
4EAC23532F99930100654F42 /* Debug */,
|
||||||
|
4EAC23542F99930100654F42 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = {
|
5349593B2F7B83B600BD31AD /* Build configuration list for PBXProject "scarf" */ = {
|
||||||
isa = XCConfigurationList;
|
isa = XCConfigurationList;
|
||||||
buildConfigurations = (
|
buildConfigurations = (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1700"
|
LastUpgradeVersion = "2620"
|
||||||
version = "1.7">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct ContentView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView()
|
SidebarView()
|
||||||
|
.navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 360)
|
||||||
} detail: {
|
} detail: {
|
||||||
detailView
|
detailView
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Manifest (what lives inside the .scarftemplate zip)
|
||||||
|
|
||||||
|
/// On-disk manifest for a Scarf project template. Shipped as `template.json`
|
||||||
|
/// at the root of a `.scarftemplate` (zip) bundle.
|
||||||
|
///
|
||||||
|
/// The `contents` block is a claim the author makes about what the bundle
|
||||||
|
/// ships; the installer verifies the claim against the actual unpacked files
|
||||||
|
/// before showing the preview sheet so a malicious bundle can't hide extra
|
||||||
|
/// files from the user.
|
||||||
|
struct ProjectTemplateManifest: Codable, Sendable, Equatable {
|
||||||
|
let schemaVersion: Int
|
||||||
|
let id: String
|
||||||
|
let name: String
|
||||||
|
let version: String
|
||||||
|
let minScarfVersion: String?
|
||||||
|
let minHermesVersion: String?
|
||||||
|
let author: TemplateAuthor?
|
||||||
|
let description: String
|
||||||
|
let category: String?
|
||||||
|
let tags: [String]?
|
||||||
|
let icon: String?
|
||||||
|
let screenshots: [String]?
|
||||||
|
let contents: TemplateContents
|
||||||
|
/// Optional configuration schema (added in manifest schemaVersion 2).
|
||||||
|
/// When present, the installer presents a form during install and
|
||||||
|
/// writes values to `<project>/.scarf/config.json` + the Keychain.
|
||||||
|
/// Schema-v1 manifests omit this field entirely — Codable's
|
||||||
|
/// optional-field decoding keeps them working unchanged.
|
||||||
|
let config: TemplateConfigSchema?
|
||||||
|
|
||||||
|
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
||||||
|
/// Used for the install directory name, skills namespace, and cron-job tag.
|
||||||
|
nonisolated var slug: String {
|
||||||
|
let ascii = id.unicodeScalars.map { scalar -> Character in
|
||||||
|
let c = Character(scalar)
|
||||||
|
if c.isLetter || c.isNumber || c == "-" || c == "_" { return c }
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
let collapsed = String(ascii)
|
||||||
|
.split(separator: "-", omittingEmptySubsequences: true)
|
||||||
|
.joined(separator: "-")
|
||||||
|
return collapsed.isEmpty ? "template" : collapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateAuthor: Codable, Sendable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let url: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateContents: Codable, Sendable, Equatable {
|
||||||
|
let dashboard: Bool
|
||||||
|
let agentsMd: Bool
|
||||||
|
let instructions: [String]?
|
||||||
|
let skills: [String]?
|
||||||
|
let cron: Int?
|
||||||
|
let memory: TemplateMemoryClaim?
|
||||||
|
/// Number of configuration fields the template ships (schemaVersion 2+).
|
||||||
|
/// Cross-checked against `manifest.config?.fields.count` by the
|
||||||
|
/// validator so a bundle can't hide a schema from the preview.
|
||||||
|
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||||
|
let config: Int?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||||
|
let append: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspection (what we learn by unpacking the zip)
|
||||||
|
|
||||||
|
/// Result of unpacking a `.scarftemplate` into a temp directory and validating
|
||||||
|
/// it. Callers hand this to `buildInstallPlan` to produce the concrete
|
||||||
|
/// filesystem plan.
|
||||||
|
struct TemplateInspection: Sendable {
|
||||||
|
let manifest: ProjectTemplateManifest
|
||||||
|
/// Absolute path to the temp directory holding the unpacked bundle. The
|
||||||
|
/// installer reads files from here; the caller is responsible for
|
||||||
|
/// cleaning it up after install (or cancel).
|
||||||
|
let unpackedDir: String
|
||||||
|
/// Every file found in the unpacked dir, as paths relative to
|
||||||
|
/// `unpackedDir`. Verified against the manifest's `contents` claim.
|
||||||
|
let files: [String]
|
||||||
|
/// Parsed cron jobs (may be empty even if the manifest claims some —
|
||||||
|
/// verification catches that mismatch).
|
||||||
|
let cronJobs: [TemplateCronJobSpec]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subset of a Hermes cron job that a template can ship. Only the fields
|
||||||
|
/// the `hermes cron create` CLI accepts are included; runtime state
|
||||||
|
/// (`enabled`, `state`, `next_run_at`, …) is deliberately omitted so a
|
||||||
|
/// template can't arrive already-running.
|
||||||
|
struct TemplateCronJobSpec: Codable, Sendable, Equatable {
|
||||||
|
let name: String
|
||||||
|
let schedule: String
|
||||||
|
let prompt: String?
|
||||||
|
let deliver: String?
|
||||||
|
let skills: [String]?
|
||||||
|
let repeatCount: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case name, schedule, prompt, deliver, skills
|
||||||
|
case repeatCount = "repeat"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Install Plan (the preview sheet reads this)
|
||||||
|
|
||||||
|
/// Concrete, reviewed-before-apply filesystem operations the installer will
|
||||||
|
/// perform. Every side effect the installer can cause is represented here so
|
||||||
|
/// the preview sheet is an honest accounting of what's about to happen.
|
||||||
|
struct TemplateInstallPlan: Sendable {
|
||||||
|
let manifest: ProjectTemplateManifest
|
||||||
|
let unpackedDir: String
|
||||||
|
|
||||||
|
/// Absolute path of the new project directory. Installer refuses if this
|
||||||
|
/// already exists.
|
||||||
|
let projectDir: String
|
||||||
|
/// Files that will be created under `projectDir`, keyed by relative path.
|
||||||
|
let projectFiles: [TemplateFileCopy]
|
||||||
|
|
||||||
|
/// Absolute path of the skills namespace dir
|
||||||
|
/// (`~/.hermes/skills/templates/<slug>/`). Created if skills are present.
|
||||||
|
let skillsNamespaceDir: String?
|
||||||
|
/// Files that will be created under the skills namespace dir.
|
||||||
|
let skillsFiles: [TemplateFileCopy]
|
||||||
|
|
||||||
|
/// Cron job definitions to register via `hermes cron create`. Each job's
|
||||||
|
/// name is already prefixed with the template tag. All will be paused
|
||||||
|
/// immediately after creation.
|
||||||
|
let cronJobs: [TemplateCronJobSpec]
|
||||||
|
|
||||||
|
/// Memory appendix text (already wrapped in begin/end markers). `nil`
|
||||||
|
/// means no memory write happens.
|
||||||
|
let memoryAppendix: String?
|
||||||
|
/// Target memory path (`~/.hermes/memories/MEMORY.md`). Only used when
|
||||||
|
/// `memoryAppendix` is non-nil.
|
||||||
|
let memoryPath: String
|
||||||
|
|
||||||
|
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||||
|
let projectRegistryName: String
|
||||||
|
|
||||||
|
/// Configuration schema declared by the template (manifest schemaVersion 2).
|
||||||
|
/// `nil` means the template is schema-less — the installer skips the
|
||||||
|
/// config sheet and writes no `.scarf/config.json` or manifest cache.
|
||||||
|
let configSchema: TemplateConfigSchema?
|
||||||
|
|
||||||
|
/// Values the user entered in the configure sheet. Populated by the
|
||||||
|
/// VM just before `install()` runs; empty when `configSchema` is nil.
|
||||||
|
/// Secrets appear here as `.keychainRef(...)` — the bytes themselves
|
||||||
|
/// were routed straight from the form field into the Keychain and
|
||||||
|
/// never held in memory past that point.
|
||||||
|
var configValues: [String: TemplateConfigValue]
|
||||||
|
|
||||||
|
/// Path at which the installer will stash a copy of `template.json`
|
||||||
|
/// so the post-install Configuration editor can render the form
|
||||||
|
/// offline. `nil` when `configSchema` is nil.
|
||||||
|
let manifestCachePath: String?
|
||||||
|
|
||||||
|
/// Convenience: total number of writes (files + cron jobs + optional
|
||||||
|
/// memory append + registry append + optional config.json + one
|
||||||
|
/// entry per secret written to the Keychain). Displayed in the
|
||||||
|
/// preview sheet.
|
||||||
|
nonisolated var totalWriteCount: Int {
|
||||||
|
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
|
||||||
|
let secretCount = configValues.values.filter {
|
||||||
|
if case .keychainRef = $0 { return true } else { return false }
|
||||||
|
}.count
|
||||||
|
return projectFiles.count
|
||||||
|
+ skillsFiles.count
|
||||||
|
+ cronJobs.count
|
||||||
|
+ (memoryAppendix == nil ? 0 : 1)
|
||||||
|
+ 1 // registry entry
|
||||||
|
+ configFileCount
|
||||||
|
+ secretCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single file to copy from the unpacked bundle into a target directory.
|
||||||
|
struct TemplateFileCopy: Sendable, Equatable {
|
||||||
|
/// Path inside `unpackedDir`, e.g. `"AGENTS.md"` or
|
||||||
|
/// `"skills/timer/SKILL.md"`.
|
||||||
|
let sourceRelativePath: String
|
||||||
|
/// Absolute path where the file should land.
|
||||||
|
let destinationPath: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock file (uninstall manifest, dropped into <project>/.scarf/)
|
||||||
|
|
||||||
|
/// Dropped at `<project>/.scarf/template.lock.json` after a successful
|
||||||
|
/// install. Records exactly what was written so a future "Uninstall Template"
|
||||||
|
/// action can reverse it without guessing.
|
||||||
|
struct TemplateLock: Codable, Sendable {
|
||||||
|
let templateId: String
|
||||||
|
let templateVersion: String
|
||||||
|
let templateName: String
|
||||||
|
let installedAt: String
|
||||||
|
let projectFiles: [String]
|
||||||
|
let skillsNamespaceDir: String?
|
||||||
|
let skillsFiles: [String]
|
||||||
|
let cronJobNames: [String]
|
||||||
|
let memoryBlockId: String?
|
||||||
|
/// Every `keychain://service/account` URI the installer stored in
|
||||||
|
/// the Keychain for this project's secret fields. Empty/nil for
|
||||||
|
/// schema-less (v1-style) installs. The uninstaller iterates this
|
||||||
|
/// list and calls `SecItemDelete` for each entry; absent on older
|
||||||
|
/// lock files so Codable's optional decoding keeps pre-2.3 installs
|
||||||
|
/// uninstallable.
|
||||||
|
let configKeychainItems: [String]?
|
||||||
|
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
|
||||||
|
/// Informational — the actual removal of config.json rides on
|
||||||
|
/// `projectFiles`. Optional for back-compat.
|
||||||
|
let configFields: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case templateId = "template_id"
|
||||||
|
case templateVersion = "template_version"
|
||||||
|
case templateName = "template_name"
|
||||||
|
case installedAt = "installed_at"
|
||||||
|
case projectFiles = "project_files"
|
||||||
|
case skillsNamespaceDir = "skills_namespace_dir"
|
||||||
|
case skillsFiles = "skills_files"
|
||||||
|
case cronJobNames = "cron_job_names"
|
||||||
|
case memoryBlockId = "memory_block_id"
|
||||||
|
case configKeychainItems = "config_keychain_items"
|
||||||
|
case configFields = "config_fields"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Uninstall Plan (the uninstall-preview sheet reads this)
|
||||||
|
|
||||||
|
/// Symmetric with `TemplateInstallPlan` but for removal. Built from the
|
||||||
|
/// `<project>/.scarf/template.lock.json` the installer wrote. The preview
|
||||||
|
/// sheet lists every path the uninstall would touch; the uninstaller
|
||||||
|
/// executes the listed ops and nothing else.
|
||||||
|
struct TemplateUninstallPlan: Sendable {
|
||||||
|
/// The parsed lock file that seeded this plan. Kept so the sheet can
|
||||||
|
/// display the template id, version, and install timestamp.
|
||||||
|
let lock: TemplateLock
|
||||||
|
/// The registry entry that will be removed on success.
|
||||||
|
let project: ProjectEntry
|
||||||
|
|
||||||
|
/// Lock-tracked files still present on disk that will be removed.
|
||||||
|
let projectFilesToRemove: [String]
|
||||||
|
/// Lock-tracked files that were already missing (e.g. user deleted them
|
||||||
|
/// after install). Shown in the sheet so the user isn't surprised that
|
||||||
|
/// a file isn't removed; uninstaller skips these.
|
||||||
|
let projectFilesAlreadyGone: [String]
|
||||||
|
/// User-added files/dirs in the project dir that are NOT in the lock.
|
||||||
|
/// These are preserved — the sheet lists them so the user knows the
|
||||||
|
/// project dir stays if any exist.
|
||||||
|
let extraProjectEntries: [String]
|
||||||
|
/// If `true`, the project dir ends up empty after removal and will be
|
||||||
|
/// removed along with its files. `false` means user content lives in
|
||||||
|
/// the dir and we leave it.
|
||||||
|
let projectDirBecomesEmpty: Bool
|
||||||
|
|
||||||
|
/// Lock-recorded skills namespace dir. `nil` means the template never
|
||||||
|
/// installed skills. Uninstaller removes the entire dir recursively.
|
||||||
|
let skillsNamespaceDir: String?
|
||||||
|
|
||||||
|
/// Cron jobs that will be removed, as (id, name) pairs. Ids were looked
|
||||||
|
/// up at plan time by matching lock names against the live cron list.
|
||||||
|
let cronJobsToRemove: [(id: String, name: String)]
|
||||||
|
/// Names recorded in the lock that we couldn't find in the current cron
|
||||||
|
/// list (user-deleted, renamed, etc.). Shown in the sheet; skipped on
|
||||||
|
/// uninstall.
|
||||||
|
let cronJobsAlreadyGone: [String]
|
||||||
|
|
||||||
|
/// `true` if MEMORY.md still contains the template's begin/end markers
|
||||||
|
/// and those bytes will be stripped on uninstall. `false` means no
|
||||||
|
/// memory block was ever installed OR the user removed it by hand.
|
||||||
|
let memoryBlockPresent: Bool
|
||||||
|
/// Hermes-side path to MEMORY.md. Only touched when
|
||||||
|
/// `memoryBlockPresent` is true.
|
||||||
|
let memoryPath: String
|
||||||
|
|
||||||
|
nonisolated var totalRemoveCount: Int {
|
||||||
|
projectFilesToRemove.count
|
||||||
|
+ (skillsNamespaceDir == nil ? 0 : 1)
|
||||||
|
+ cronJobsToRemove.count
|
||||||
|
+ (memoryBlockPresent ? 1 : 0)
|
||||||
|
+ 1 // registry entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum ProjectTemplateError: LocalizedError, Sendable {
|
||||||
|
case unzipFailed(String)
|
||||||
|
case manifestMissing
|
||||||
|
case manifestParseFailed(String)
|
||||||
|
case unsupportedSchemaVersion(Int)
|
||||||
|
case requiredFileMissing(String)
|
||||||
|
case contentClaimMismatch(String)
|
||||||
|
case projectDirExists(String)
|
||||||
|
case conflictingFile(String)
|
||||||
|
case memoryBlockAlreadyExists(String)
|
||||||
|
case cronCreateFailed(job: String, output: String)
|
||||||
|
case unsafeZipEntry(String)
|
||||||
|
case lockFileMissing(String)
|
||||||
|
case lockFileParseFailed(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unzipFailed(let s):
|
||||||
|
return "Couldn't unpack template archive: \(s)"
|
||||||
|
case .manifestMissing:
|
||||||
|
return "Template is missing template.json at the archive root."
|
||||||
|
case .manifestParseFailed(let s):
|
||||||
|
return "Template manifest couldn't be parsed: \(s)"
|
||||||
|
case .unsupportedSchemaVersion(let v):
|
||||||
|
return "Template uses schemaVersion \(v), which this version of Scarf doesn't understand."
|
||||||
|
case .requiredFileMissing(let f):
|
||||||
|
return "Template is missing a required file: \(f)"
|
||||||
|
case .contentClaimMismatch(let s):
|
||||||
|
return "Template manifest doesn't match its contents: \(s)"
|
||||||
|
case .projectDirExists(let p):
|
||||||
|
return "A directory already exists at \(p). Refusing to overwrite — choose a different parent folder."
|
||||||
|
case .conflictingFile(let p):
|
||||||
|
return "An existing file would be overwritten at \(p). Refusing to clobber."
|
||||||
|
case .memoryBlockAlreadyExists(let id):
|
||||||
|
return "A memory block for template '\(id)' already exists in MEMORY.md. Remove it first or install a fresh copy."
|
||||||
|
case .cronCreateFailed(let job, let output):
|
||||||
|
return "Failed to register cron job '\(job)': \(output)"
|
||||||
|
case .unsafeZipEntry(let p):
|
||||||
|
return "Template archive contains an unsafe entry: \(p)"
|
||||||
|
case .lockFileMissing(let path):
|
||||||
|
return "No template.lock.json found at \(path). This project wasn't installed by Scarf's template system — remove it by hand."
|
||||||
|
case .lockFileParseFailed(let s):
|
||||||
|
return "Couldn't read template.lock.json: \(s)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Schema (ships inside template.json as manifest.config)
|
||||||
|
|
||||||
|
/// Author-declared configuration schema for a template. Published as the
|
||||||
|
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
|
||||||
|
/// in values at install time via `TemplateConfigSheet`; values land in
|
||||||
|
/// `<project>/.scarf/config.json` with secrets resolved through the
|
||||||
|
/// macOS Keychain.
|
||||||
|
struct TemplateConfigSchema: Codable, Sendable, Equatable {
|
||||||
|
let fields: [TemplateConfigField]
|
||||||
|
let modelRecommendation: TemplateModelRecommendation?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case fields = "schema"
|
||||||
|
case modelRecommendation
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated var isEmpty: Bool { fields.isEmpty }
|
||||||
|
|
||||||
|
/// Fast lookup by key. Validators guarantee keys are unique within a
|
||||||
|
/// schema at manifest-parse time, so this is safe.
|
||||||
|
nonisolated func field(for key: String) -> TemplateConfigField? {
|
||||||
|
fields.first { $0.key == key }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One configurable field the user fills in. Discriminated by `type`.
|
||||||
|
/// We keep one flat struct rather than an enum-associated-value encoding
|
||||||
|
/// so JSON reads cleanly as a record and authors can hand-edit manifests
|
||||||
|
/// without fighting Swift's `"case"` discriminator syntax.
|
||||||
|
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
nonisolated var id: String { key }
|
||||||
|
|
||||||
|
let key: String
|
||||||
|
let type: FieldType
|
||||||
|
let label: String
|
||||||
|
let description: String?
|
||||||
|
let required: Bool
|
||||||
|
let placeholder: String?
|
||||||
|
|
||||||
|
// Type-specific constraints — all optional. The validator enforces
|
||||||
|
// only the ones that apply to `type`; extras are ignored.
|
||||||
|
let defaultValue: TemplateConfigValue?
|
||||||
|
let options: [EnumOption]? // type == .enum
|
||||||
|
let minLength: Int? // type == .string / .text
|
||||||
|
let maxLength: Int?
|
||||||
|
let pattern: String? // type == .string (regex)
|
||||||
|
let minNumber: Double? // type == .number
|
||||||
|
let maxNumber: Double?
|
||||||
|
let step: Double?
|
||||||
|
let itemType: String? // type == .list — only "string" supported in v1
|
||||||
|
let minItems: Int?
|
||||||
|
let maxItems: Int?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case key, type, label, description, required, placeholder
|
||||||
|
case defaultValue = "default"
|
||||||
|
case options
|
||||||
|
case minLength, maxLength, pattern
|
||||||
|
case minNumber = "min"
|
||||||
|
case maxNumber = "max"
|
||||||
|
case step
|
||||||
|
case itemType, minItems, maxItems
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FieldType: String, Codable, Sendable, Equatable {
|
||||||
|
case string
|
||||||
|
case text
|
||||||
|
case number
|
||||||
|
case bool
|
||||||
|
case `enum`
|
||||||
|
case list
|
||||||
|
case secret
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One option of an `enum` field. `value` is what ends up in
|
||||||
|
/// `config.json`; `label` is the human-readable text shown in the UI.
|
||||||
|
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
|
||||||
|
nonisolated var id: String { value }
|
||||||
|
let value: String
|
||||||
|
let label: String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Author's model-of-choice hint, shown in the install preview + on the
|
||||||
|
/// catalog detail page. Purely advisory — Scarf never auto-switches the
|
||||||
|
/// active model. Individual cron jobs can override via
|
||||||
|
/// `HermesCronJob.model` if the author wants enforcement.
|
||||||
|
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
|
||||||
|
let preferred: String
|
||||||
|
let rationale: String?
|
||||||
|
let alternatives: [String]?
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Values (what lands in config.json and the Keychain)
|
||||||
|
|
||||||
|
/// One configured value. Secrets don't carry their raw bytes — only a
|
||||||
|
/// Keychain reference of the form `"keychain://<service>/<account>"` so
|
||||||
|
/// serialising config.json to disk never leaks the secret into git or
|
||||||
|
/// into backups.
|
||||||
|
enum TemplateConfigValue: Codable, Sendable, Equatable {
|
||||||
|
case string(String)
|
||||||
|
case number(Double)
|
||||||
|
case bool(Bool)
|
||||||
|
case list([String])
|
||||||
|
case keychainRef(String)
|
||||||
|
|
||||||
|
/// Convenience: the string representation suitable for display or
|
||||||
|
/// for writing into a placeholder that the agent reads. Keychain
|
||||||
|
/// refs return the ref string, not the resolved secret — callers
|
||||||
|
/// resolve through `ProjectConfigKeychain` explicitly when they
|
||||||
|
/// actually need the plaintext.
|
||||||
|
nonisolated var displayString: String {
|
||||||
|
switch self {
|
||||||
|
case .string(let s): return s
|
||||||
|
case .number(let n):
|
||||||
|
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||||
|
? String(Int(n))
|
||||||
|
: String(n)
|
||||||
|
case .bool(let b): return b ? "true" : "false"
|
||||||
|
case .list(let items): return items.joined(separator: ", ")
|
||||||
|
case .keychainRef(let ref): return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if let s = try? container.decode(String.self) {
|
||||||
|
// Preserve the keychain:// scheme so secrets round-trip as
|
||||||
|
// references, not as plaintext.
|
||||||
|
if s.hasPrefix("keychain://") {
|
||||||
|
self = .keychainRef(s)
|
||||||
|
} else {
|
||||||
|
self = .string(s)
|
||||||
|
}
|
||||||
|
} else if let b = try? container.decode(Bool.self) {
|
||||||
|
self = .bool(b)
|
||||||
|
} else if let n = try? container.decode(Double.self) {
|
||||||
|
self = .number(n)
|
||||||
|
} else if let arr = try? container.decode([String].self) {
|
||||||
|
self = .list(arr)
|
||||||
|
} else {
|
||||||
|
throw DecodingError.typeMismatch(
|
||||||
|
TemplateConfigValue.self,
|
||||||
|
.init(codingPath: decoder.codingPath,
|
||||||
|
debugDescription: "Expected String, Bool, Number, or [String]")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch self {
|
||||||
|
case .string(let s): try container.encode(s)
|
||||||
|
case .number(let n): try container.encode(n)
|
||||||
|
case .bool(let b): try container.encode(b)
|
||||||
|
case .list(let items): try container.encode(items)
|
||||||
|
case .keychainRef(let ref): try container.encode(ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
|
||||||
|
|
||||||
|
/// The JSON file the installer writes + the editor reads. Non-secret
|
||||||
|
/// values appear inline; secrets are `"keychain://<service>/<account>"`
|
||||||
|
/// references that `ProjectConfigService` resolves through the Keychain
|
||||||
|
/// on demand.
|
||||||
|
struct ProjectConfigFile: Codable, Sendable {
|
||||||
|
let schemaVersion: Int
|
||||||
|
let templateId: String
|
||||||
|
var values: [String: TemplateConfigValue]
|
||||||
|
let updatedAt: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case schemaVersion
|
||||||
|
case templateId
|
||||||
|
case values
|
||||||
|
case updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain reference helpers
|
||||||
|
|
||||||
|
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
|
||||||
|
/// (service + account) from the template slug + project-path hash so two
|
||||||
|
/// installs of the same template in different dirs don't collide in the
|
||||||
|
/// login Keychain.
|
||||||
|
struct TemplateKeychainRef: Sendable, Equatable {
|
||||||
|
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
|
||||||
|
let service: String
|
||||||
|
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
|
||||||
|
/// guarantees uniqueness across multiple installs of the same template.
|
||||||
|
let account: String
|
||||||
|
|
||||||
|
/// `"keychain://<service>/<account>"` — what lands in `config.json`.
|
||||||
|
nonisolated var uri: String { "keychain://\(service)/\(account)" }
|
||||||
|
|
||||||
|
/// Parse a `keychain://…` URI back into a ref. Returns `nil` when the
|
||||||
|
/// input isn't well-formed so callers can distinguish a missing ref
|
||||||
|
/// from a malformed one.
|
||||||
|
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
|
||||||
|
guard uri.hasPrefix("keychain://") else { return nil }
|
||||||
|
let rest = String(uri.dropFirst("keychain://".count))
|
||||||
|
guard let slash = rest.firstIndex(of: "/") else { return nil }
|
||||||
|
let service = String(rest[..<slash])
|
||||||
|
let account = String(rest[rest.index(after: slash)...])
|
||||||
|
guard !service.isEmpty, !account.isEmpty else { return nil }
|
||||||
|
return TemplateKeychainRef(service: service, account: account)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a ref from a template slug + field key + project path.
|
||||||
|
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
|
||||||
|
/// of the absolute project path. Stable across launches, different
|
||||||
|
/// between `/Users/a/proj1` and `/Users/a/proj2`.
|
||||||
|
nonisolated static func make(
|
||||||
|
templateSlug: String,
|
||||||
|
fieldKey: String,
|
||||||
|
projectPath: String
|
||||||
|
) -> TemplateKeychainRef {
|
||||||
|
TemplateKeychainRef(
|
||||||
|
service: "com.scarf.template.\(templateSlug)",
|
||||||
|
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func shortHash(of string: String) -> String {
|
||||||
|
// 8 hex chars is 32 bits of uniqueness — plenty for
|
||||||
|
// distinguishing a handful of project dirs per template install.
|
||||||
|
let data = Data(string.utf8)
|
||||||
|
var hash: UInt32 = 0x811c9dc5
|
||||||
|
for byte in data {
|
||||||
|
hash ^= UInt32(byte)
|
||||||
|
hash &*= 0x01000193
|
||||||
|
}
|
||||||
|
return String(format: "%08x", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Validation
|
||||||
|
|
||||||
|
/// One schema- or value-validation problem. Carries `fieldKey` so the
|
||||||
|
/// UI can surface the error inline with the field rather than at the
|
||||||
|
/// top of the form.
|
||||||
|
struct TemplateConfigValidationError: Error, Sendable, Equatable {
|
||||||
|
let fieldKey: String?
|
||||||
|
let message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TemplateConfigSchemaError: LocalizedError, Sendable {
|
||||||
|
case duplicateKey(String)
|
||||||
|
case unsupportedType(String)
|
||||||
|
case emptyEnumOptions(String)
|
||||||
|
case duplicateEnumValue(key: String, value: String)
|
||||||
|
case unsupportedListItemType(key: String, itemType: String)
|
||||||
|
case secretFieldHasDefault(String)
|
||||||
|
case emptyModelPreferred
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .duplicateKey(let k):
|
||||||
|
return "Config schema has duplicate key: \(k)"
|
||||||
|
case .unsupportedType(let t):
|
||||||
|
return "Config schema uses unsupported field type: \(t)"
|
||||||
|
case .emptyEnumOptions(let k):
|
||||||
|
return "Enum field '\(k)' must declare at least one option"
|
||||||
|
case .duplicateEnumValue(let k, let v):
|
||||||
|
return "Enum field '\(k)' has duplicate option value: \(v)"
|
||||||
|
case .unsupportedListItemType(let k, let t):
|
||||||
|
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
|
||||||
|
case .secretFieldHasDefault(let k):
|
||||||
|
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
|
||||||
|
case .emptyModelPreferred:
|
||||||
|
return "modelRecommendation.preferred must be a non-empty model id."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
|
|||||||
var id: ServerID
|
var id: ServerID
|
||||||
var displayName: String
|
var displayName: String
|
||||||
var kind: ServerKind
|
var kind: ServerKind
|
||||||
/// User preference: open this server in a window on launch. Phase 3
|
/// User preference: this server is the one Scarf opens into when a
|
||||||
/// multi-window uses this; Phase 2 ignores it.
|
/// fresh window has no prior binding (first launch or File → New).
|
||||||
|
/// At most one entry should have this set — `ServerRegistry` enforces
|
||||||
|
/// mutual exclusivity. If none do, Local is the implicit default.
|
||||||
var openOnLaunch: Bool = false
|
var openOnLaunch: Bool = false
|
||||||
|
|
||||||
var context: ServerContext {
|
var context: ServerContext {
|
||||||
@@ -69,6 +71,32 @@ final class ServerRegistry {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server a fresh window should open into. Returns the ID of the
|
||||||
|
/// remote entry flagged `openOnLaunch`, or Local's ID if none is
|
||||||
|
/// flagged (or if the flagged entry was removed out from under us).
|
||||||
|
/// Consumed by the `WindowGroup`'s `defaultValue` closure.
|
||||||
|
var defaultServerID: ServerID {
|
||||||
|
entries.first(where: { $0.openOnLaunch })?.id ?? ServerContext.local.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flip the default server to `id`. Passing `ServerContext.local.id`
|
||||||
|
/// clears the flag on every remote entry, making Local the implicit
|
||||||
|
/// default. Passing an unknown ID is a no-op. Persisted on return.
|
||||||
|
func setDefaultServer(_ id: ServerID) {
|
||||||
|
var changed = false
|
||||||
|
for idx in entries.indices {
|
||||||
|
let shouldBeDefault = (entries[idx].id == id)
|
||||||
|
if entries[idx].openOnLaunch != shouldBeDefault {
|
||||||
|
entries[idx].openOnLaunch = shouldBeDefault
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
save()
|
||||||
|
onEntriesChanged?()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Mutations
|
// MARK: - Mutations
|
||||||
|
|
||||||
/// Optional callback fired whenever `entries` changes. The app wires
|
/// Optional callback fired whenever `entries` changes. The app wires
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Thin wrapper around the macOS Keychain for template-config secrets.
|
||||||
|
/// Scarf doesn't have other Keychain users yet so this file is the one
|
||||||
|
/// place that touches the `Security` framework; keep it small and
|
||||||
|
/// auditable so a reader can tell at a glance what we store, under what
|
||||||
|
/// identifiers, and when items are removed.
|
||||||
|
///
|
||||||
|
/// **What we store.** Generic passwords (kSecClassGenericPassword) in
|
||||||
|
/// the login Keychain. Each item is identified by a (service, account)
|
||||||
|
/// pair derived from the template slug + field key + project-path hash
|
||||||
|
/// — see `TemplateKeychainRef.make`. The stored Data is the user's
|
||||||
|
/// raw secret bytes; we never transform or encode them.
|
||||||
|
///
|
||||||
|
/// **When items are written.** By `ProjectTemplateInstaller` after the
|
||||||
|
/// install preview is confirmed and the user has filled in the
|
||||||
|
/// configure sheet. By `TemplateConfigSheet` when the user edits a
|
||||||
|
/// secret field post-install.
|
||||||
|
///
|
||||||
|
/// **When items are removed.** By `ProjectTemplateUninstaller`,
|
||||||
|
/// iterating the lock file's `configKeychainItems` list. The login
|
||||||
|
/// Keychain is never swept for stray entries — if the lock is out of
|
||||||
|
/// sync we log + skip rather than guess which items are ours.
|
||||||
|
///
|
||||||
|
/// **What shows to the user.** macOS prompts "Scarf wants to access
|
||||||
|
/// the Keychain" the first time we read a secret in a given session.
|
||||||
|
/// User approves; subsequent reads in that session are silent. We
|
||||||
|
/// never bypass this — the prompt is the user's trust boundary.
|
||||||
|
struct ProjectConfigKeychain: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigKeychain")
|
||||||
|
|
||||||
|
/// Which Keychain to target. The default is the login Keychain
|
||||||
|
/// (`nil` uses the user's default chain). Tests pass an explicit
|
||||||
|
/// namespace suffix via `testServiceSuffix` — see `TemplateConfigTests` —
|
||||||
|
/// so integration tests can roundtrip without polluting real
|
||||||
|
/// user state.
|
||||||
|
let testServiceSuffix: String?
|
||||||
|
|
||||||
|
nonisolated init(testServiceSuffix: String? = nil) {
|
||||||
|
self.testServiceSuffix = testServiceSuffix
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write or overwrite the secret for (service, account). Tests
|
||||||
|
/// route their items through a distinct service prefix via
|
||||||
|
/// `testServiceSuffix` so they can't leak into the user's real
|
||||||
|
/// Keychain.
|
||||||
|
nonisolated func set(service: String, account: String, secret: Data) throws {
|
||||||
|
let svc = resolved(service: service)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: svc,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
// Try update first — cheaper than delete-then-add and doesn't
|
||||||
|
// trip macOS's "item already exists" if another thread raced us.
|
||||||
|
let update: [String: Any] = [
|
||||||
|
kSecValueData as String: secret,
|
||||||
|
]
|
||||||
|
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||||
|
if updateStatus == errSecSuccess { return }
|
||||||
|
if updateStatus != errSecItemNotFound {
|
||||||
|
throw Self.error(status: updateStatus, op: "update")
|
||||||
|
}
|
||||||
|
var insert = query
|
||||||
|
insert[kSecValueData as String] = secret
|
||||||
|
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — stays in
|
||||||
|
// this device's Keychain, not synced via iCloud, usable after
|
||||||
|
// first unlock (so background cron triggers can read).
|
||||||
|
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||||
|
if addStatus != errSecSuccess {
|
||||||
|
throw Self.error(status: addStatus, op: "add")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the secret for (service, account). Returns `nil` when
|
||||||
|
/// the item simply doesn't exist (user never set it, or an
|
||||||
|
/// uninstall already removed it). Throws on every other Keychain
|
||||||
|
/// error so callers don't silently treat "access denied" or
|
||||||
|
/// "corrupt keychain" as "no value."
|
||||||
|
nonisolated func get(service: String, account: String) throws -> Data? {
|
||||||
|
let svc = resolved(service: service)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: svc,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
kSecReturnData as String: true,
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
var result: CFTypeRef?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecItemNotFound { return nil }
|
||||||
|
if status != errSecSuccess {
|
||||||
|
throw Self.error(status: status, op: "get")
|
||||||
|
}
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the secret for (service, account). Absent item is a
|
||||||
|
/// no-op; any other failure throws. Called by
|
||||||
|
/// `ProjectTemplateUninstaller` for every item in
|
||||||
|
/// `TemplateLock.configKeychainItems`.
|
||||||
|
nonisolated func delete(service: String, account: String) throws {
|
||||||
|
let svc = resolved(service: service)
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: svc,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
|
if status == errSecItemNotFound || status == errSecSuccess { return }
|
||||||
|
throw Self.error(status: status, op: "delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: apply the test suffix when in test mode.
|
||||||
|
nonisolated private func resolved(service: String) -> String {
|
||||||
|
guard let suffix = testServiceSuffix, !suffix.isEmpty else { return service }
|
||||||
|
return "\(service).\(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a useful NSError from a Keychain OSStatus. Logs at warning
|
||||||
|
/// — callers decide whether the failure is fatal.
|
||||||
|
nonisolated private static func error(status: OSStatus, op: String) -> NSError {
|
||||||
|
let description = (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error"
|
||||||
|
logger.warning("Keychain \(op, privacy: .public) failed: \(status) \(description, privacy: .public)")
|
||||||
|
return NSError(
|
||||||
|
domain: "com.scarf.keychain",
|
||||||
|
code: Int(status),
|
||||||
|
userInfo: [
|
||||||
|
NSLocalizedDescriptionKey: "Keychain \(op) failed (\(status)): \(description)"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Ref-shaped convenience layer
|
||||||
|
|
||||||
|
extension ProjectConfigKeychain {
|
||||||
|
/// Set a secret using a pre-built `TemplateKeychainRef`. Mirrors the
|
||||||
|
/// service/account plumbing every caller would otherwise repeat.
|
||||||
|
nonisolated func set(ref: TemplateKeychainRef, secret: Data) throws {
|
||||||
|
try set(service: ref.service, account: ref.account, secret: secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func get(ref: TemplateKeychainRef) throws -> Data? {
|
||||||
|
try get(service: ref.service, account: ref.account)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func delete(ref: TemplateKeychainRef) throws {
|
||||||
|
try delete(service: ref.service, account: ref.account)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Per-project configuration I/O: reads `<project>/.scarf/config.json`
|
||||||
|
/// into typed values, writes them back, resolves Keychain-backed secrets
|
||||||
|
/// on demand, and validates user-entered values against the schema.
|
||||||
|
///
|
||||||
|
/// Separation of concerns:
|
||||||
|
///
|
||||||
|
/// - **Schema authority.** `TemplateConfigSchema` lives in the bundle's
|
||||||
|
/// `template.json` and a copy is stashed at `<project>/.scarf/manifest.json`
|
||||||
|
/// at install time so the post-install editor works offline. This
|
||||||
|
/// service treats the schema as read-only input; `validateSchema`
|
||||||
|
/// checks structural invariants and is called by
|
||||||
|
/// `ProjectTemplateService` during install-plan building.
|
||||||
|
/// - **Value storage.** Non-secret values live inline in `config.json`;
|
||||||
|
/// secret values are Keychain references of the form
|
||||||
|
/// `"keychain://<service>/<account>"`. The service owns both halves
|
||||||
|
/// of that storage — callers never open `config.json` or touch the
|
||||||
|
/// Keychain directly.
|
||||||
|
/// - **Remote readiness.** All file I/O goes through
|
||||||
|
/// `ServerContext.makeTransport()` so when `ProjectTemplateInstaller`
|
||||||
|
/// eventually supports remote contexts, the config store comes along
|
||||||
|
/// for the ride. Keychain access stays local (it's a macOS-side thing
|
||||||
|
/// by definition — agents on remote Hermes installs would fetch
|
||||||
|
/// values via Scarf's channel, same as today).
|
||||||
|
struct ProjectConfigService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigService")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let keychain: ProjectConfigKeychain
|
||||||
|
|
||||||
|
nonisolated init(
|
||||||
|
context: ServerContext = .local,
|
||||||
|
keychain: ProjectConfigKeychain = ProjectConfigKeychain()
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.keychain = keychain
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paths
|
||||||
|
|
||||||
|
nonisolated static func configPath(for project: ProjectEntry) -> String {
|
||||||
|
project.path + "/.scarf/config.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func manifestCachePath(for project: ProjectEntry) -> String {
|
||||||
|
project.path + "/.scarf/manifest.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load / save on-disk config
|
||||||
|
|
||||||
|
/// Read + decode `<project>/.scarf/config.json`. Returns `nil`
|
||||||
|
/// cleanly when the file is absent (e.g. a project installed from
|
||||||
|
/// a schema-less template, or a hand-added project). Throws on
|
||||||
|
/// malformed JSON so the caller can surface a concrete error
|
||||||
|
/// rather than silently treating a corrupt file as missing.
|
||||||
|
nonisolated func load(project: ProjectEntry) throws -> ProjectConfigFile? {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = Self.configPath(for: project)
|
||||||
|
guard transport.fileExists(path) else { return nil }
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode(ProjectConfigFile.self, from: data)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("couldn't decode config.json at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `<project>/.scarf/config.json`. Secrets should already be
|
||||||
|
/// represented as `TemplateConfigValue.keychainRef` references here
|
||||||
|
/// — this service never inspects their plaintext.
|
||||||
|
nonisolated func save(
|
||||||
|
project: ProjectEntry,
|
||||||
|
templateId: String,
|
||||||
|
values: [String: TemplateConfigValue]
|
||||||
|
) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: templateId,
|
||||||
|
values: values,
|
||||||
|
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(file)
|
||||||
|
let parent = (Self.configPath(for: project) as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(Self.configPath(for: project), data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Manifest cache (schema used by post-install editor)
|
||||||
|
|
||||||
|
/// Copy a template's `template.json` into `<project>/.scarf/manifest.json`
|
||||||
|
/// so the post-install "Configuration" button can render the form
|
||||||
|
/// offline. Called once by the installer after unpack + validate.
|
||||||
|
nonisolated func cacheManifest(project: ProjectEntry, manifestData: Data) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = Self.manifestCachePath(for: project)
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(path, data: manifestData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load the cached manifest into a `ProjectTemplateManifest` so the
|
||||||
|
/// editor can look up field types + labels. Returns `nil` when the
|
||||||
|
/// project wasn't installed from a schemaful template.
|
||||||
|
nonisolated func loadCachedManifest(project: ProjectEntry) throws -> ProjectTemplateManifest? {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = Self.manifestCachePath(for: project)
|
||||||
|
guard transport.fileExists(path) else { return nil }
|
||||||
|
let data = try transport.readFile(path)
|
||||||
|
return try JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Secrets
|
||||||
|
|
||||||
|
/// Resolve a `keychainRef` value into the actual secret bytes.
|
||||||
|
/// Returns `nil` if the Keychain entry has been removed (e.g.
|
||||||
|
/// external user cleanup, a previous uninstall that didn't finish).
|
||||||
|
nonisolated func resolveSecret(ref value: TemplateConfigValue) throws -> Data? {
|
||||||
|
guard case .keychainRef(let uri) = value,
|
||||||
|
let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try keychain.get(ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store a freshly-entered secret. Returns the `keychainRef` value
|
||||||
|
/// suitable for writing into `config.json`.
|
||||||
|
nonisolated func storeSecret(
|
||||||
|
templateSlug: String,
|
||||||
|
fieldKey: String,
|
||||||
|
project: ProjectEntry,
|
||||||
|
secret: Data
|
||||||
|
) throws -> TemplateConfigValue {
|
||||||
|
let ref = TemplateKeychainRef.make(
|
||||||
|
templateSlug: templateSlug,
|
||||||
|
fieldKey: fieldKey,
|
||||||
|
projectPath: project.path
|
||||||
|
)
|
||||||
|
try keychain.set(ref: ref, secret: secret)
|
||||||
|
return .keychainRef(ref.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete every Keychain item tracked in `refs`. Absent items are
|
||||||
|
/// fine (uninstall may run after the user manually cleaned an
|
||||||
|
/// entry). Any other failure is logged and re-thrown so the
|
||||||
|
/// uninstaller can surface it.
|
||||||
|
nonisolated func deleteSecrets(refs: [TemplateKeychainRef]) throws {
|
||||||
|
for ref in refs {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Schema validation (author-facing; called at bundle inspect time)
|
||||||
|
|
||||||
|
/// Verify structural invariants on a schema: unique keys, known
|
||||||
|
/// types, enum options, secret-without-default rule, model
|
||||||
|
/// recommendation non-empty when present. Called by
|
||||||
|
/// `ProjectTemplateService.inspect` before buildPlan runs.
|
||||||
|
nonisolated static func validateSchema(_ schema: TemplateConfigSchema) throws {
|
||||||
|
var seen = Set<String>()
|
||||||
|
for field in schema.fields {
|
||||||
|
if !seen.insert(field.key).inserted {
|
||||||
|
throw TemplateConfigSchemaError.duplicateKey(field.key)
|
||||||
|
}
|
||||||
|
switch field.type {
|
||||||
|
case .enum:
|
||||||
|
let opts = field.options ?? []
|
||||||
|
guard !opts.isEmpty else {
|
||||||
|
throw TemplateConfigSchemaError.emptyEnumOptions(field.key)
|
||||||
|
}
|
||||||
|
var seenValues = Set<String>()
|
||||||
|
for opt in opts {
|
||||||
|
if !seenValues.insert(opt.value).inserted {
|
||||||
|
throw TemplateConfigSchemaError.duplicateEnumValue(key: field.key, value: opt.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .list:
|
||||||
|
let item = field.itemType ?? "string"
|
||||||
|
if item != "string" {
|
||||||
|
throw TemplateConfigSchemaError.unsupportedListItemType(key: field.key, itemType: item)
|
||||||
|
}
|
||||||
|
case .secret:
|
||||||
|
if field.defaultValue != nil {
|
||||||
|
throw TemplateConfigSchemaError.secretFieldHasDefault(field.key)
|
||||||
|
}
|
||||||
|
case .string, .text, .number, .bool:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let rec = schema.modelRecommendation {
|
||||||
|
if rec.preferred.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
throw TemplateConfigSchemaError.emptyModelPreferred
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Value validation (runs on user input in the configure sheet)
|
||||||
|
|
||||||
|
/// Validate user-entered values against the schema. Returns one
|
||||||
|
/// `TemplateConfigValidationError` per problem. Empty array means
|
||||||
|
/// the form is submittable.
|
||||||
|
nonisolated static func validateValues(
|
||||||
|
_ values: [String: TemplateConfigValue],
|
||||||
|
against schema: TemplateConfigSchema
|
||||||
|
) -> [TemplateConfigValidationError] {
|
||||||
|
var errors: [TemplateConfigValidationError] = []
|
||||||
|
for field in schema.fields {
|
||||||
|
let value = values[field.key]
|
||||||
|
if field.required && !Self.hasMeaningfulValue(value, type: field.type) {
|
||||||
|
errors.append(.init(fieldKey: field.key, message: "\(field.label) is required."))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let value else { continue }
|
||||||
|
switch field.type {
|
||||||
|
case .string, .text:
|
||||||
|
if case .string(let s) = value {
|
||||||
|
if let min = field.minLength, s.count < min {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be at least \(min) characters."))
|
||||||
|
}
|
||||||
|
if let max = field.maxLength, s.count > max {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be at most \(max) characters."))
|
||||||
|
}
|
||||||
|
if let pattern = field.pattern,
|
||||||
|
s.range(of: pattern, options: .regularExpression) == nil {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) doesn't match the expected format."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be a string."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .number:
|
||||||
|
if case .number(let n) = value {
|
||||||
|
if let min = field.minNumber, n < min {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be ≥ \(min)."))
|
||||||
|
}
|
||||||
|
if let max = field.maxNumber, n > max {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be ≤ \(max)."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be a number."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .bool:
|
||||||
|
if case .bool = value { /* ok */ } else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be true or false."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .enum:
|
||||||
|
if case .string(let s) = value {
|
||||||
|
let options = (field.options ?? []).map(\.value)
|
||||||
|
if !options.contains(s) {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be one of \(options.joined(separator: ", "))."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be one of the predefined options."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .list:
|
||||||
|
if case .list(let items) = value {
|
||||||
|
if let min = field.minItems, items.count < min {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) needs at least \(min) item(s)."))
|
||||||
|
}
|
||||||
|
if let max = field.maxItems, items.count > max {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) accepts at most \(max) item(s)."))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be a list."))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .secret:
|
||||||
|
if case .keychainRef = value { /* opaque — trust it */ } else {
|
||||||
|
errors.append(.init(fieldKey: field.key,
|
||||||
|
message: "\(field.label) must be supplied (Keychain entry missing)."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func hasMeaningfulValue(
|
||||||
|
_ value: TemplateConfigValue?,
|
||||||
|
type: TemplateConfigField.FieldType
|
||||||
|
) -> Bool {
|
||||||
|
guard let value else { return false }
|
||||||
|
switch (type, value) {
|
||||||
|
case (.string, .string(let s)), (.text, .string(let s)), (.enum, .string(let s)):
|
||||||
|
return !s.isEmpty
|
||||||
|
case (.number, .number):
|
||||||
|
return true
|
||||||
|
case (.bool, .bool):
|
||||||
|
return true
|
||||||
|
case (.list, .list(let arr)):
|
||||||
|
return !arr.isEmpty
|
||||||
|
case (.secret, .keychainRef):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Builds a `.scarftemplate` bundle from an existing Scarf project plus the
|
||||||
|
/// caller's selection of skills and cron jobs. Symmetric with the
|
||||||
|
/// `ProjectTemplateService` + `ProjectTemplateInstaller` pair — the output
|
||||||
|
/// of this exporter can be fed straight back to `inspect()` + `install()`.
|
||||||
|
struct ProjectTemplateExporter: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateExporter")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known filenames in the project root that map to specific agents. When
|
||||||
|
/// the author opts to include them, each is copied verbatim into
|
||||||
|
/// `instructions/` in the bundle.
|
||||||
|
nonisolated static let knownInstructionFiles: [String] = [
|
||||||
|
"CLAUDE.md",
|
||||||
|
"GEMINI.md",
|
||||||
|
".cursorrules",
|
||||||
|
".github/copilot-instructions.md"
|
||||||
|
]
|
||||||
|
|
||||||
|
/// Author-facing description of what `export` will do with the given
|
||||||
|
/// selections. Shown in the export sheet so the user knows exactly
|
||||||
|
/// what's about to go into the bundle before saving.
|
||||||
|
struct ExportPlan: Sendable {
|
||||||
|
let templateId: String
|
||||||
|
let templateName: String
|
||||||
|
let templateVersion: String
|
||||||
|
let projectDir: String
|
||||||
|
let dashboardPresent: Bool
|
||||||
|
let agentsMdPresent: Bool
|
||||||
|
let readmePresent: Bool
|
||||||
|
let instructionFiles: [String]
|
||||||
|
let skillIds: [String]
|
||||||
|
let cronJobs: [HermesCronJob]
|
||||||
|
let memoryAppendix: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inputs collected by the export sheet.
|
||||||
|
struct ExportInputs: Sendable {
|
||||||
|
let project: ProjectEntry
|
||||||
|
let templateId: String
|
||||||
|
let templateName: String
|
||||||
|
let templateVersion: String
|
||||||
|
let description: String
|
||||||
|
let authorName: String?
|
||||||
|
let authorUrl: String?
|
||||||
|
let category: String?
|
||||||
|
let tags: [String]
|
||||||
|
let includeSkillIds: [String]
|
||||||
|
let includeCronJobIds: [String]
|
||||||
|
/// Raw markdown the author wants appended to installers' MEMORY.md.
|
||||||
|
/// `nil` to skip.
|
||||||
|
let memoryAppendix: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the project dir and report what a fresh export would include
|
||||||
|
/// given the caller's inputs. Does not write anything.
|
||||||
|
///
|
||||||
|
/// Existence checks go through the context's transport — the project
|
||||||
|
/// path comes from the registry on the active server and may be on a
|
||||||
|
/// remote filesystem (future remote-install support), where
|
||||||
|
/// `FileManager.default.fileExists` would silently return `false`.
|
||||||
|
nonisolated func previewPlan(for inputs: ExportInputs) -> ExportPlan {
|
||||||
|
let dir = inputs.project.path
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let dashboard = transport.fileExists(dir + "/.scarf/dashboard.json")
|
||||||
|
let readme = transport.fileExists(dir + "/README.md")
|
||||||
|
let agents = transport.fileExists(dir + "/AGENTS.md")
|
||||||
|
let instructions = Self.knownInstructionFiles.filter {
|
||||||
|
transport.fileExists(dir + "/" + $0)
|
||||||
|
}
|
||||||
|
let allJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
|
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
|
||||||
|
return ExportPlan(
|
||||||
|
templateId: inputs.templateId,
|
||||||
|
templateName: inputs.templateName,
|
||||||
|
templateVersion: inputs.templateVersion,
|
||||||
|
projectDir: dir,
|
||||||
|
dashboardPresent: dashboard,
|
||||||
|
agentsMdPresent: agents,
|
||||||
|
readmePresent: readme,
|
||||||
|
instructionFiles: instructions,
|
||||||
|
skillIds: inputs.includeSkillIds,
|
||||||
|
cronJobs: picked,
|
||||||
|
memoryAppendix: inputs.memoryAppendix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the bundle and write it to `outputZipPath`. Throws if any
|
||||||
|
/// required file is missing or the zip step fails.
|
||||||
|
nonisolated func export(
|
||||||
|
inputs: ExportInputs,
|
||||||
|
outputZipPath: String
|
||||||
|
) throws {
|
||||||
|
let stagingDir = NSTemporaryDirectory() + "scarf-template-export-" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(atPath: stagingDir, withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: stagingDir) }
|
||||||
|
|
||||||
|
let plan = previewPlan(for: inputs)
|
||||||
|
|
||||||
|
guard plan.dashboardPresent else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("dashboard.json (expected at \(plan.projectDir)/.scarf/dashboard.json)")
|
||||||
|
}
|
||||||
|
guard plan.readmePresent else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("README.md (expected at \(plan.projectDir)/README.md)")
|
||||||
|
}
|
||||||
|
guard plan.agentsMdPresent else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("AGENTS.md (expected at \(plan.projectDir)/AGENTS.md)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required files. All source reads go through the context's
|
||||||
|
// transport — project paths come from the registry on the active
|
||||||
|
// server and may be on a remote filesystem. Destinations are in
|
||||||
|
// the local staging dir so Foundation writes are correct.
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try copyFromHermes(plan.projectDir + "/.scarf/dashboard.json", to: stagingDir + "/dashboard.json", transport: transport)
|
||||||
|
try copyFromHermes(plan.projectDir + "/README.md", to: stagingDir + "/README.md", transport: transport)
|
||||||
|
try copyFromHermes(plan.projectDir + "/AGENTS.md", to: stagingDir + "/AGENTS.md", transport: transport)
|
||||||
|
|
||||||
|
// Optional per-agent instruction shims
|
||||||
|
for relative in plan.instructionFiles {
|
||||||
|
let source = plan.projectDir + "/" + relative
|
||||||
|
let destination = stagingDir + "/instructions/" + relative
|
||||||
|
try createParent(of: destination)
|
||||||
|
try copyFromHermes(source, to: destination, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills (copied from the global skills dir)
|
||||||
|
if !plan.skillIds.isEmpty {
|
||||||
|
let skillsRoot = stagingDir + "/skills"
|
||||||
|
try FileManager.default.createDirectory(atPath: skillsRoot, withIntermediateDirectories: true)
|
||||||
|
let allSkills = HermesFileService(context: context).loadSkills()
|
||||||
|
.flatMap(\.skills)
|
||||||
|
for skillId in plan.skillIds {
|
||||||
|
guard let skill = allSkills.first(where: { $0.id == skillId }) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("skills/" + skillId)
|
||||||
|
}
|
||||||
|
// The bundle uses a flat `skills/<name>/` layout (no
|
||||||
|
// category), matching what the installer expects. If two
|
||||||
|
// categories ship skills with the same `name`, the second
|
||||||
|
// collides — warn by refusing rather than silently
|
||||||
|
// overwriting.
|
||||||
|
let targetDir = skillsRoot + "/" + skill.name
|
||||||
|
if FileManager.default.fileExists(atPath: targetDir) {
|
||||||
|
throw ProjectTemplateError.conflictingFile(targetDir)
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(atPath: targetDir, withIntermediateDirectories: true)
|
||||||
|
for file in skill.files {
|
||||||
|
try copyFromHermes(skill.path + "/" + file, to: targetDir + "/" + file, transport: transport)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cron jobs (stripped to the create-CLI-shaped spec)
|
||||||
|
if !plan.cronJobs.isEmpty {
|
||||||
|
let specs = plan.cronJobs.map { Self.strip($0) }
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(specs)
|
||||||
|
let cronDir = stagingDir + "/cron"
|
||||||
|
try FileManager.default.createDirectory(atPath: cronDir, withIntermediateDirectories: true)
|
||||||
|
try data.write(to: URL(fileURLWithPath: cronDir + "/jobs.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory appendix. A write failure here would silently produce a
|
||||||
|
// bundle whose manifest claims `memory.append = true` but ships an
|
||||||
|
// empty/missing file — installers would then fail on
|
||||||
|
// contentClaimMismatch with no breadcrumb pointing back at the
|
||||||
|
// export step. Let the error propagate.
|
||||||
|
if let appendix = plan.memoryAppendix, !appendix.isEmpty {
|
||||||
|
let memDir = stagingDir + "/memory"
|
||||||
|
try FileManager.default.createDirectory(atPath: memDir, withIntermediateDirectories: true)
|
||||||
|
guard let data = appendix.data(using: .utf8) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
|
||||||
|
}
|
||||||
|
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the source project was itself installed from a schemaful
|
||||||
|
// template, its `.scarf/manifest.json` carries the schema we
|
||||||
|
// want to forward to the exported bundle. We carry only the
|
||||||
|
// SCHEMA — never user values. Exporting must be safe on a
|
||||||
|
// project with live config: the schema is author-supplied
|
||||||
|
// metadata; the values in `config.json` are the current user's
|
||||||
|
// secrets or personal settings.
|
||||||
|
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
|
||||||
|
from: plan.projectDir
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bump schemaVersion to 2 when a schema is carried through;
|
||||||
|
// remain on 1 otherwise so schema-less exports stay
|
||||||
|
// byte-compatible with existing v2.2 catalog validators.
|
||||||
|
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
||||||
|
|
||||||
|
// Manifest — claims exactly what we just wrote
|
||||||
|
let manifest = ProjectTemplateManifest(
|
||||||
|
schemaVersion: schemaVersion,
|
||||||
|
id: inputs.templateId,
|
||||||
|
name: inputs.templateName,
|
||||||
|
version: inputs.templateVersion,
|
||||||
|
minScarfVersion: nil,
|
||||||
|
minHermesVersion: nil,
|
||||||
|
author: inputs.authorName.map {
|
||||||
|
TemplateAuthor(name: $0, url: inputs.authorUrl)
|
||||||
|
},
|
||||||
|
description: inputs.description,
|
||||||
|
category: inputs.category,
|
||||||
|
tags: inputs.tags.isEmpty ? nil : inputs.tags,
|
||||||
|
icon: nil,
|
||||||
|
screenshots: nil,
|
||||||
|
contents: TemplateContents(
|
||||||
|
dashboard: true,
|
||||||
|
agentsMd: true,
|
||||||
|
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||||
|
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||||
|
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||||
|
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
||||||
|
config: forwardedSchema?.fields.count
|
||||||
|
),
|
||||||
|
config: forwardedSchema
|
||||||
|
)
|
||||||
|
let manifestEncoder = JSONEncoder()
|
||||||
|
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let manifestData = try manifestEncoder.encode(manifest)
|
||||||
|
try manifestData.write(to: URL(fileURLWithPath: stagingDir + "/template.json"))
|
||||||
|
|
||||||
|
try zip(stagingDir: stagingDir, outputPath: outputZipPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
/// Copy a file whose source lives on the Hermes side (possibly remote)
|
||||||
|
/// into a local destination path under the staging dir. Using the
|
||||||
|
/// transport for the read keeps the exporter remote-ready; the write
|
||||||
|
/// goes through Foundation because the staging dir is always local to
|
||||||
|
/// the Mac running Scarf.
|
||||||
|
nonisolated private func copyFromHermes(
|
||||||
|
_ source: String,
|
||||||
|
to destination: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
let data = try transport.readFile(source)
|
||||||
|
try createParent(of: destination)
|
||||||
|
try data.write(to: URL(fileURLWithPath: destination))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private func createParent(of path: String) throws {
|
||||||
|
let parent = (path as NSString).deletingLastPathComponent
|
||||||
|
if !FileManager.default.fileExists(atPath: parent) {
|
||||||
|
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
|
||||||
|
/// present) and pull out just the config schema. Values in
|
||||||
|
/// `.scarf/config.json` are intentionally ignored — an exported
|
||||||
|
/// bundle carries the schema's shape, never the current user's
|
||||||
|
/// configured values.
|
||||||
|
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
|
||||||
|
let manifestPath = projectDir + "/.scarf/manifest.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||||
|
// Use a bespoke decode rather than ProjectTemplateManifest so
|
||||||
|
// this helper stays resilient if the manifest shape evolves
|
||||||
|
// incompatibly in a future release.
|
||||||
|
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
|
||||||
|
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
|
||||||
|
return onlyConfig.config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a live cron job (with runtime state) into the spec the
|
||||||
|
/// installer will feed back to `hermes cron create`. Only preserves
|
||||||
|
/// fields the CLI accepts.
|
||||||
|
nonisolated private static func strip(_ job: HermesCronJob) -> TemplateCronJobSpec {
|
||||||
|
let schedule: String = {
|
||||||
|
if let expr = job.schedule.expression, !expr.isEmpty { return expr }
|
||||||
|
if let runAt = job.schedule.runAt, !runAt.isEmpty { return runAt }
|
||||||
|
return job.schedule.display ?? ""
|
||||||
|
}()
|
||||||
|
return TemplateCronJobSpec(
|
||||||
|
name: job.name,
|
||||||
|
schedule: schedule,
|
||||||
|
prompt: job.prompt.isEmpty ? nil : job.prompt,
|
||||||
|
deliver: job.deliver?.isEmpty == false ? job.deliver : nil,
|
||||||
|
skills: (job.skills?.isEmpty == false) ? job.skills : nil,
|
||||||
|
repeatCount: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell out to `/usr/bin/zip -r` so the file ordering is deterministic
|
||||||
|
/// and the archive is standard — Apple-provided tools (and the system
|
||||||
|
/// `unzip` the installer uses) will read it without trouble.
|
||||||
|
nonisolated private func zip(stagingDir: String, outputPath: String) throws {
|
||||||
|
// `zip` writes relative paths based on the cwd it's invoked in. Chdir
|
||||||
|
// via Process.currentDirectoryURL so entries are `template.json`,
|
||||||
|
// `AGENTS.md`, etc., not absolute paths.
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||||
|
process.currentDirectoryURL = URL(fileURLWithPath: stagingDir)
|
||||||
|
process.arguments = ["-qq", "-r", outputPath, "."]
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
process.standardOutput = outPipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
|
||||||
|
// Close both ends of each Pipe so we don't leak 4 fds per zip call.
|
||||||
|
func closePipes() {
|
||||||
|
try? outPipe.fileHandleForReading.close()
|
||||||
|
try? outPipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForReading.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
closePipes()
|
||||||
|
throw ProjectTemplateError.unzipFailed("zip failed to launch: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
|
let errData = try? errPipe.fileHandleForReading.readToEnd()
|
||||||
|
closePipes()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Executes a `TemplateInstallPlan`. All writes happen in one pass with
|
||||||
|
/// early-fail semantics: if any step throws, later steps don't run (but
|
||||||
|
/// earlier ones aren't reversed — v1 doesn't ship an atomic rollback). The
|
||||||
|
/// plan has already verified `projectDir` doesn't exist and no conflicting
|
||||||
|
/// file exists at target paths, so by the time we start writing, the
|
||||||
|
/// expected-error surface is small (mostly I/O failures).
|
||||||
|
struct ProjectTemplateInstaller: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateInstaller")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the plan. On success, returns the `ProjectEntry` that was added
|
||||||
|
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
|
||||||
|
@discardableResult
|
||||||
|
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||||
|
try preflight(plan: plan)
|
||||||
|
try createProjectFiles(plan: plan)
|
||||||
|
try createSkillsFiles(plan: plan)
|
||||||
|
try appendMemoryIfNeeded(plan: plan)
|
||||||
|
let cronJobNames = try createCronJobs(plan: plan)
|
||||||
|
let entry = try registerProject(plan: plan)
|
||||||
|
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
|
||||||
|
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preflight
|
||||||
|
|
||||||
|
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
|
||||||
|
// Plan was built on a recent snapshot of the filesystem; re-check the
|
||||||
|
// invariants at install time so concurrent activity between
|
||||||
|
// preview-and-confirm can't slip past us.
|
||||||
|
//
|
||||||
|
// All existence and read checks for paths that come from
|
||||||
|
// `context.paths` go through the transport — not `FileManager` —
|
||||||
|
// so this code works identically against a future remote
|
||||||
|
// `ServerContext`. See the warning on `ServerContext.readText`:
|
||||||
|
// "Foundation file APIs are LOCAL ONLY — using them with a remote
|
||||||
|
// path silently returns nil because the remote path doesn't exist
|
||||||
|
// on this Mac."
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
if transport.fileExists(plan.projectDir) {
|
||||||
|
throw ProjectTemplateError.projectDirExists(plan.projectDir)
|
||||||
|
}
|
||||||
|
for copy in plan.projectFiles where transport.fileExists(copy.destinationPath) {
|
||||||
|
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
|
||||||
|
}
|
||||||
|
for copy in plan.skillsFiles where transport.fileExists(copy.destinationPath) {
|
||||||
|
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
|
||||||
|
}
|
||||||
|
// Memory appendix collision: re-scan MEMORY.md for an existing block
|
||||||
|
// with the same template id so two installs of v1.0.0 can't
|
||||||
|
// double-append. A missing MEMORY.md is fine (treated as empty),
|
||||||
|
// but any *other* read failure (permissions, bad file type) gets
|
||||||
|
// logged + surfaced so we don't silently pretend MEMORY.md is empty
|
||||||
|
// and append over a broken file.
|
||||||
|
if plan.memoryAppendix != nil {
|
||||||
|
let existing: String
|
||||||
|
if transport.fileExists(plan.memoryPath) {
|
||||||
|
do {
|
||||||
|
let data = try transport.readFile(plan.memoryPath)
|
||||||
|
existing = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to read MEMORY.md at \(plan.memoryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existing = ""
|
||||||
|
}
|
||||||
|
let marker = ProjectTemplateService.memoryBlockBeginMarker(templateId: plan.manifest.id)
|
||||||
|
if existing.contains(marker) {
|
||||||
|
throw ProjectTemplateError.memoryBlockAlreadyExists(plan.manifest.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Project files
|
||||||
|
|
||||||
|
nonisolated private func createProjectFiles(plan: TemplateInstallPlan) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try transport.createDirectory(plan.projectDir)
|
||||||
|
for copy in plan.projectFiles {
|
||||||
|
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
|
||||||
|
// Empty `sourceRelativePath` is the "synthesized content"
|
||||||
|
// sentinel used by `buildPlan` for `.scarf/config.json`.
|
||||||
|
// The installer materialises config.json from
|
||||||
|
// `plan.configValues` here rather than copying a bundle
|
||||||
|
// file that doesn't exist.
|
||||||
|
if copy.sourceRelativePath.isEmpty {
|
||||||
|
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
|
||||||
|
let data = try encodeConfigFile(plan: plan)
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(
|
||||||
|
"synthesized file with unknown destination: \(copy.destinationPath)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
|
||||||
|
/// shape. Secrets appear as `keychainRef` URIs — the raw bytes were
|
||||||
|
/// routed into the Keychain by the VM before `install()` was called.
|
||||||
|
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: plan.manifest.id,
|
||||||
|
values: plan.configValues,
|
||||||
|
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
return try encoder.encode(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Skills
|
||||||
|
|
||||||
|
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||||
|
guard let namespaceDir = plan.skillsNamespaceDir else { return }
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
try transport.createDirectory(namespaceDir)
|
||||||
|
for copy in plan.skillsFiles {
|
||||||
|
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||||
|
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||||
|
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(copy.destinationPath, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory
|
||||||
|
|
||||||
|
nonisolated private func appendMemoryIfNeeded(plan: TemplateInstallPlan) throws {
|
||||||
|
guard let appendix = plan.memoryAppendix else { return }
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let existing = (try? transport.readFile(plan.memoryPath)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
let combined = existing + appendix
|
||||||
|
guard let data = combined.data(using: .utf8) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
|
||||||
|
}
|
||||||
|
let parent = (plan.memoryPath as NSString).deletingLastPathComponent
|
||||||
|
try transport.createDirectory(parent)
|
||||||
|
try transport.writeFile(plan.memoryPath, data: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cron
|
||||||
|
|
||||||
|
/// Create each cron job via `hermes cron create`, then immediately pause
|
||||||
|
/// it (Hermes creates jobs enabled). Returns the list of resolved job
|
||||||
|
/// names, which is what the lock file records — we don't know the job
|
||||||
|
/// ids without parsing the create output, but the name is enough to
|
||||||
|
/// find + remove them later.
|
||||||
|
nonisolated private func createCronJobs(plan: TemplateInstallPlan) throws -> [String] {
|
||||||
|
guard !plan.cronJobs.isEmpty else { return [] }
|
||||||
|
|
||||||
|
let existingBefore = Set(HermesFileService(context: context).loadCronJobs().map(\.id))
|
||||||
|
var createdNames: [String] = []
|
||||||
|
|
||||||
|
for job in plan.cronJobs {
|
||||||
|
var args = ["cron", "create", "--name", job.name]
|
||||||
|
if let deliver = job.deliver, !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||||
|
if let repeatCount = job.repeatCount { args += ["--repeat", String(repeatCount)] }
|
||||||
|
for skill in job.skills ?? [] where !skill.isEmpty {
|
||||||
|
args += ["--skill", skill]
|
||||||
|
}
|
||||||
|
args.append(job.schedule)
|
||||||
|
if let prompt = job.prompt, !prompt.isEmpty {
|
||||||
|
args.append(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
let (output, exit) = context.runHermes(args)
|
||||||
|
guard exit == 0 else {
|
||||||
|
throw ProjectTemplateError.cronCreateFailed(job: job.name, output: output)
|
||||||
|
}
|
||||||
|
createdNames.append(job.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff the current job set against the snapshot we took before
|
||||||
|
// creating — anything new belongs to this install and gets paused.
|
||||||
|
// We pause by id (not name) because `cron pause` takes an id.
|
||||||
|
let currentJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
|
let newJobs = currentJobs.filter { !existingBefore.contains($0.id) && createdNames.contains($0.name) }
|
||||||
|
for job in newJobs {
|
||||||
|
let (_, exit) = context.runHermes(["cron", "pause", job.id])
|
||||||
|
if exit != 0 {
|
||||||
|
Self.logger.warning("couldn't pause newly-created cron job \(job.id, privacy: .public) — leaving enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdNames
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry
|
||||||
|
|
||||||
|
nonisolated private func registerProject(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||||
|
let service = ProjectDashboardService(context: context)
|
||||||
|
var registry = service.loadRegistry()
|
||||||
|
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
||||||
|
registry.projects.append(entry)
|
||||||
|
service.saveRegistry(registry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock file
|
||||||
|
|
||||||
|
nonisolated private func writeLockFile(
|
||||||
|
plan: TemplateInstallPlan,
|
||||||
|
cronJobNames: [String]
|
||||||
|
) throws {
|
||||||
|
// Every value that ended up as a keychainRef in config.json gets
|
||||||
|
// tracked in the lock so the uninstaller can SecItemDelete each
|
||||||
|
// entry. Field keys are recorded separately for informational
|
||||||
|
// display in the uninstall preview sheet.
|
||||||
|
let keychainItems: [String]? = {
|
||||||
|
let refs = plan.configValues.compactMap { (_, value) -> String? in
|
||||||
|
if case .keychainRef(let uri) = value { return uri } else { return nil }
|
||||||
|
}
|
||||||
|
return refs.isEmpty ? nil : refs.sorted()
|
||||||
|
}()
|
||||||
|
let configFields: [String]? = {
|
||||||
|
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||||
|
return schema.fields.map(\.key)
|
||||||
|
}()
|
||||||
|
|
||||||
|
let lock = TemplateLock(
|
||||||
|
templateId: plan.manifest.id,
|
||||||
|
templateVersion: plan.manifest.version,
|
||||||
|
templateName: plan.manifest.name,
|
||||||
|
installedAt: ISO8601DateFormatter().string(from: Date()),
|
||||||
|
projectFiles: plan.projectFiles.map(\.destinationPath),
|
||||||
|
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||||
|
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||||
|
cronJobNames: cronJobNames,
|
||||||
|
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||||
|
configKeychainItems: keychainItems,
|
||||||
|
configFields: configFields
|
||||||
|
)
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||||
|
let data = try encoder.encode(lock)
|
||||||
|
let path = plan.projectDir + "/.scarf/template.lock.json"
|
||||||
|
try context.makeTransport().writeFile(path, data: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Reads, validates, and plans the install of a `.scarftemplate` bundle. Pure
|
||||||
|
/// — owns no state across calls. The installer (see
|
||||||
|
/// `ProjectTemplateInstaller`) consumes the `TemplateInstallPlan` this
|
||||||
|
/// produces.
|
||||||
|
///
|
||||||
|
/// Responsibilities:
|
||||||
|
/// 1. Unpack a `.scarftemplate` zip into a caller-owned temp directory.
|
||||||
|
/// 2. Parse `template.json` and validate it against the schema we know about.
|
||||||
|
/// 3. Walk the unpacked contents and verify they match the manifest's
|
||||||
|
/// `contents` claim (so a malicious bundle can't hide files from the
|
||||||
|
/// preview sheet).
|
||||||
|
/// 4. Produce a `TemplateInstallPlan` describing every concrete filesystem
|
||||||
|
/// op the installer will perform, given a parent directory the user
|
||||||
|
/// picked.
|
||||||
|
struct ProjectTemplateService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateService")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Inspection
|
||||||
|
|
||||||
|
/// Unpack the zip at `zipPath` into a fresh temp directory, parse and
|
||||||
|
/// validate the manifest, and walk the contents. Throws on any
|
||||||
|
/// inconsistency. On success, the caller owns `inspection.unpackedDir`
|
||||||
|
/// and must remove it once they're done.
|
||||||
|
nonisolated func inspect(zipPath: String) throws -> TemplateInspection {
|
||||||
|
let unpackedDir = try makeTempDir()
|
||||||
|
try unzip(zipPath: zipPath, intoDir: unpackedDir)
|
||||||
|
|
||||||
|
let manifestPath = unpackedDir + "/template.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: manifestPath) else {
|
||||||
|
throw ProjectTemplateError.manifestMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
let manifestData: Data
|
||||||
|
do {
|
||||||
|
manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
let manifest: ProjectTemplateManifest
|
||||||
|
do {
|
||||||
|
manifest = try JSONDecoder().decode(ProjectTemplateManifest.self, from: manifestData)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
|
||||||
|
// optional `config` block. Both are valid. Newer versions get
|
||||||
|
// refused so the installer never silently misinterprets a
|
||||||
|
// future-shape bundle.
|
||||||
|
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
|
||||||
|
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the optional config schema at inspect time — a
|
||||||
|
// malformed schema (duplicate keys, secret-with-default, etc.)
|
||||||
|
// gets rejected before the user ever sees the preview sheet.
|
||||||
|
if let schema = manifest.config {
|
||||||
|
do {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed(
|
||||||
|
"invalid config schema: \(error.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let files = try Self.walk(unpackedDir)
|
||||||
|
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||||
|
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
||||||
|
|
||||||
|
return TemplateInspection(
|
||||||
|
manifest: manifest,
|
||||||
|
unpackedDir: unpackedDir,
|
||||||
|
files: files,
|
||||||
|
cronJobs: cronJobs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning
|
||||||
|
|
||||||
|
/// Turn an inspection into a concrete install plan given the parent
|
||||||
|
/// directory the user picked. The plan is deterministic — two calls with
|
||||||
|
/// the same inputs produce the same ops.
|
||||||
|
nonisolated func buildPlan(
|
||||||
|
inspection: TemplateInspection,
|
||||||
|
parentDir: String
|
||||||
|
) throws -> TemplateInstallPlan {
|
||||||
|
let manifest = inspection.manifest
|
||||||
|
let slug = manifest.slug
|
||||||
|
let projectDir = parentDir + "/" + slug
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: projectDir) {
|
||||||
|
throw ProjectTemplateError.projectDirExists(projectDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectFiles: [TemplateFileCopy] = [
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "README.md",
|
||||||
|
destinationPath: projectDir + "/README.md"
|
||||||
|
),
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "AGENTS.md",
|
||||||
|
destinationPath: projectDir + "/AGENTS.md"
|
||||||
|
),
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "dashboard.json",
|
||||||
|
destinationPath: projectDir + "/.scarf/dashboard.json"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
// Optional per-agent instruction shims. Each is copied verbatim to
|
||||||
|
// its conventional project-root path; we don't try to be clever.
|
||||||
|
let instructionRoot = "instructions"
|
||||||
|
for relative in (manifest.contents.instructions ?? []) {
|
||||||
|
let source = instructionRoot + "/" + relative
|
||||||
|
guard inspection.files.contains(source) else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(source)
|
||||||
|
}
|
||||||
|
projectFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: source,
|
||||||
|
destinationPath: projectDir + "/" + relative
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespaced skills: copied wholesale from skills/<name>/** into
|
||||||
|
// ~/.hermes/skills/templates/<slug>/<name>/**.
|
||||||
|
var skillsFiles: [TemplateFileCopy] = []
|
||||||
|
var skillsNamespaceDir: String? = nil
|
||||||
|
if let skillNames = manifest.contents.skills, !skillNames.isEmpty {
|
||||||
|
let namespaceDir = context.paths.skillsDir + "/templates/" + slug
|
||||||
|
skillsNamespaceDir = namespaceDir
|
||||||
|
for skillName in skillNames {
|
||||||
|
let prefix = "skills/" + skillName + "/"
|
||||||
|
let skillFiles = inspection.files.filter { $0.hasPrefix(prefix) }
|
||||||
|
guard !skillFiles.isEmpty else {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing(prefix)
|
||||||
|
}
|
||||||
|
for relative in skillFiles {
|
||||||
|
let suffix = String(relative.dropFirst("skills/".count))
|
||||||
|
skillsFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: relative,
|
||||||
|
destinationPath: namespaceDir + "/" + suffix
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cron jobs: always prefix name with the template tag so users can
|
||||||
|
// find and remove them later. Jobs ship disabled — the installer
|
||||||
|
// pauses each one immediately after `cron create`.
|
||||||
|
let cronJobs: [TemplateCronJobSpec] = inspection.cronJobs.map { job in
|
||||||
|
TemplateCronJobSpec(
|
||||||
|
name: "[tmpl:\(manifest.id)] \(job.name)",
|
||||||
|
schedule: job.schedule,
|
||||||
|
prompt: job.prompt,
|
||||||
|
deliver: job.deliver,
|
||||||
|
skills: job.skills,
|
||||||
|
repeatCount: job.repeatCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory appendix: wrap whatever the template ships in
|
||||||
|
// begin/end markers so an uninstall can find and remove exactly the
|
||||||
|
// bytes this template added. `verifyClaims` already guaranteed the
|
||||||
|
// file is present — so a read error here means something unusual
|
||||||
|
// (permissions, encoding, etc.); surface it with the real
|
||||||
|
// `error.localizedDescription` rather than hiding behind a
|
||||||
|
// generic "file missing."
|
||||||
|
var memoryAppendix: String? = nil
|
||||||
|
if manifest.contents.memory?.append == true {
|
||||||
|
let appendSource = inspection.unpackedDir + "/memory/append.md"
|
||||||
|
let raw: String
|
||||||
|
do {
|
||||||
|
raw = try String(contentsOf: URL(fileURLWithPath: appendSource), encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
Self.logger.error("failed to read memory/append.md in unpacked bundle: \(error.localizedDescription, privacy: .public)")
|
||||||
|
throw ProjectTemplateError.manifestParseFailed("memory/append.md: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
memoryAppendix = Self.wrapMemoryBlock(
|
||||||
|
templateId: manifest.id,
|
||||||
|
templateVersion: manifest.version,
|
||||||
|
body: raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration schema + manifest cache. The installer writes
|
||||||
|
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
|
||||||
|
// (schema cache used by the post-install editor) when the
|
||||||
|
// template declares a non-empty schema. Both paths go into
|
||||||
|
// projectFiles so the uninstaller picks them up via the lock.
|
||||||
|
var configSchema: TemplateConfigSchema? = nil
|
||||||
|
var manifestCachePath: String? = nil
|
||||||
|
if let schema = manifest.config, !schema.isEmpty {
|
||||||
|
configSchema = schema
|
||||||
|
let configPath = projectDir + "/.scarf/config.json"
|
||||||
|
projectFiles.append(
|
||||||
|
// Source is synthesized by the installer from configValues;
|
||||||
|
// no file in the unpacked bundle maps to this entry. We use
|
||||||
|
// an empty `sourceRelativePath` as the "no physical source"
|
||||||
|
// sentinel — the installer special-cases it below (see
|
||||||
|
// ProjectTemplateInstaller.createProjectFiles).
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "",
|
||||||
|
destinationPath: configPath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let cachePath = projectDir + "/.scarf/manifest.json"
|
||||||
|
manifestCachePath = cachePath
|
||||||
|
projectFiles.append(
|
||||||
|
TemplateFileCopy(
|
||||||
|
sourceRelativePath: "template.json",
|
||||||
|
destinationPath: cachePath
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return TemplateInstallPlan(
|
||||||
|
manifest: manifest,
|
||||||
|
unpackedDir: inspection.unpackedDir,
|
||||||
|
projectDir: projectDir,
|
||||||
|
projectFiles: projectFiles,
|
||||||
|
skillsNamespaceDir: skillsNamespaceDir,
|
||||||
|
skillsFiles: skillsFiles,
|
||||||
|
cronJobs: cronJobs,
|
||||||
|
memoryAppendix: memoryAppendix,
|
||||||
|
memoryPath: context.paths.memoryMD,
|
||||||
|
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
|
||||||
|
configSchema: configSchema,
|
||||||
|
configValues: [:], // filled in by TemplateInstallerViewModel before install()
|
||||||
|
manifestCachePath: manifestCachePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
/// Remove a temp dir created by `inspect`. Safe to call if it already
|
||||||
|
/// doesn't exist (install or cancel flows both end here).
|
||||||
|
nonisolated func cleanupTempDir(_ path: String) {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory block helpers (installer + future uninstaller share these)
|
||||||
|
|
||||||
|
nonisolated static func memoryBlockBeginMarker(templateId: String) -> String {
|
||||||
|
"<!-- scarf-template:\(templateId):begin -->"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func memoryBlockEndMarker(templateId: String) -> String {
|
||||||
|
"<!-- scarf-template:\(templateId):end -->"
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func wrapMemoryBlock(
|
||||||
|
templateId: String,
|
||||||
|
templateVersion: String,
|
||||||
|
body: String
|
||||||
|
) -> String {
|
||||||
|
let begin = memoryBlockBeginMarker(templateId: templateId)
|
||||||
|
let end = memoryBlockEndMarker(templateId: templateId)
|
||||||
|
return "\n\n\(begin) v\(templateVersion)\n\(body)\n\(end)\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private nonisolated func makeTempDir() throws -> String {
|
||||||
|
let base = NSTemporaryDirectory() + "scarf-template-" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
atPath: base,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shell out to `/usr/bin/unzip` — matches the existing profile-export
|
||||||
|
/// pattern (`hermes profile import` shells to `unzip`) and avoids
|
||||||
|
/// pulling in a third-party zip library.
|
||||||
|
private nonisolated func unzip(zipPath: String, intoDir: String) throws {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||||
|
process.arguments = ["-qq", "-o", zipPath, "-d", intoDir]
|
||||||
|
|
||||||
|
let outPipe = Pipe()
|
||||||
|
let errPipe = Pipe()
|
||||||
|
process.standardOutput = outPipe
|
||||||
|
process.standardError = errPipe
|
||||||
|
|
||||||
|
// Foundation dup()s these handles into the child on `run()`, but the
|
||||||
|
// parent copies stay open until explicitly released. Both ends must
|
||||||
|
// be closed or each Process spawn leaks 4 fds.
|
||||||
|
func closePipes() {
|
||||||
|
try? outPipe.fileHandleForReading.close()
|
||||||
|
try? outPipe.fileHandleForWriting.close()
|
||||||
|
try? errPipe.fileHandleForReading.close()
|
||||||
|
try? errPipe.fileHandleForWriting.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
closePipes()
|
||||||
|
throw ProjectTemplateError.unzipFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
process.waitUntilExit()
|
||||||
|
let errData = try? errPipe.fileHandleForReading.readToEnd()
|
||||||
|
closePipes()
|
||||||
|
|
||||||
|
guard process.terminationStatus == 0 else {
|
||||||
|
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||||
|
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively walk `dir` and return every file (not directory) as a
|
||||||
|
/// path relative to `dir`. Skips symlinks entirely — templates should
|
||||||
|
/// never contain them, and following them could escape the unpack dir.
|
||||||
|
///
|
||||||
|
/// Both the base dir and the enumerated URLs are resolved via
|
||||||
|
/// `resolvingSymlinksInPath` before comparison. On macOS, temp dirs
|
||||||
|
/// under `/var/folders/…` resolve to `/private/var/folders/…`, so a
|
||||||
|
/// naive string-prefix check would produce malformed relative paths
|
||||||
|
/// when the base is unresolved but enumerated URLs are resolved.
|
||||||
|
nonisolated private static func walk(_ dir: String) throws -> [String] {
|
||||||
|
var results: [String] = []
|
||||||
|
let baseURL = URL(fileURLWithPath: dir).resolvingSymlinksInPath()
|
||||||
|
let basePath = baseURL.path.hasSuffix("/") ? baseURL.path : baseURL.path + "/"
|
||||||
|
let enumerator = FileManager.default.enumerator(
|
||||||
|
at: baseURL,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
)
|
||||||
|
while let url = enumerator?.nextObject() as? URL {
|
||||||
|
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||||
|
if values.isSymbolicLink == true {
|
||||||
|
throw ProjectTemplateError.unsafeZipEntry(url.path)
|
||||||
|
}
|
||||||
|
guard values.isRegularFile == true else { continue }
|
||||||
|
var full = url.resolvingSymlinksInPath().path
|
||||||
|
if full.hasPrefix(basePath) {
|
||||||
|
full.removeFirst(basePath.count)
|
||||||
|
}
|
||||||
|
if full.contains("..") {
|
||||||
|
throw ProjectTemplateError.unsafeZipEntry(full)
|
||||||
|
}
|
||||||
|
results.append(full)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func readCronJobs(unpackedDir: String) throws -> [TemplateCronJobSpec] {
|
||||||
|
let path = unpackedDir + "/cron/jobs.json"
|
||||||
|
guard FileManager.default.fileExists(atPath: path) else { return [] }
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("cron/jobs.json")
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([TemplateCronJobSpec].self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.manifestParseFailed("cron/jobs.json: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the manifest's `contents` claim exactly matches the unpacked
|
||||||
|
/// files. Any mismatch — claimed-but-missing or present-but-unclaimed —
|
||||||
|
/// throws, so the preview sheet the user sees is always accurate.
|
||||||
|
nonisolated private static func verifyClaims(
|
||||||
|
manifest: ProjectTemplateManifest,
|
||||||
|
files: [String],
|
||||||
|
cronJobCount: Int
|
||||||
|
) throws {
|
||||||
|
let fileSet = Set(files)
|
||||||
|
|
||||||
|
if manifest.contents.dashboard {
|
||||||
|
if !fileSet.contains("dashboard.json") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if manifest.contents.agentsMd {
|
||||||
|
if !fileSet.contains("AGENTS.md") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// README and AGENTS are always required; dashboard is always required
|
||||||
|
// per spec. `contents.dashboard`/`contents.agentsMd` exist so a future
|
||||||
|
// schema can relax those rules; for v1 we hard-require them regardless.
|
||||||
|
if !fileSet.contains("README.md") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("README.md")
|
||||||
|
}
|
||||||
|
if !fileSet.contains("AGENTS.md") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
|
||||||
|
}
|
||||||
|
if !fileSet.contains("dashboard.json") {
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let claimed = manifest.contents.instructions {
|
||||||
|
for rel in claimed {
|
||||||
|
let full = "instructions/" + rel
|
||||||
|
if !fileSet.contains(full) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest lists \(full) but the file is missing from the bundle"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let present = fileSet.filter { $0.hasPrefix("instructions/") }
|
||||||
|
let claimedFull = Set(claimed.map { "instructions/" + $0 })
|
||||||
|
if let extra = present.first(where: { !claimedFull.contains($0) }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle contains \(extra) but it's not listed in manifest.contents.instructions"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if fileSet.contains(where: { $0.hasPrefix("instructions/") }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle has instructions/ files but manifest.contents.instructions is missing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let claimed = manifest.contents.skills {
|
||||||
|
for name in claimed {
|
||||||
|
let prefix = "skills/" + name + "/"
|
||||||
|
if !fileSet.contains(where: { $0.hasPrefix(prefix) }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest lists skill \(name) but skills/\(name)/ has no files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let presentSkills = Set(fileSet.compactMap { path -> String? in
|
||||||
|
guard path.hasPrefix("skills/") else { return nil }
|
||||||
|
let rest = path.dropFirst("skills/".count)
|
||||||
|
return rest.split(separator: "/", maxSplits: 1).first.map(String.init)
|
||||||
|
})
|
||||||
|
let claimedSet = Set(claimed)
|
||||||
|
if let extra = presentSkills.subtracting(claimedSet).first {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle contains skills/\(extra)/ but it's not listed in manifest.contents.skills"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if fileSet.contains(where: { $0.hasPrefix("skills/") }) {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"bundle contains skills/ but manifest.contents.skills is missing"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let claimedCron = manifest.contents.cron ?? 0
|
||||||
|
if claimedCron != cronJobCount {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.cron=\(claimedCron) but bundle contains \(cronJobCount) cron jobs"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasMemoryFile = fileSet.contains("memory/append.md")
|
||||||
|
let claimsMemory = manifest.contents.memory?.append == true
|
||||||
|
if claimsMemory != hasMemoryFile {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config claim must match the schema's actual field count so
|
||||||
|
// the preview sheet is honest about the size of the configure
|
||||||
|
// step. `nil` in contents means "no schema" just like `0`;
|
||||||
|
// we normalise both to 0 before comparing.
|
||||||
|
let claimedConfig = manifest.contents.config ?? 0
|
||||||
|
let actualConfig = manifest.config?.fields.count ?? 0
|
||||||
|
if claimedConfig != actualConfig {
|
||||||
|
throw ProjectTemplateError.contentClaimMismatch(
|
||||||
|
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a project-registry name that doesn't collide. Deterministic
|
||||||
|
/// — given the same existing registry, always returns the same answer.
|
||||||
|
nonisolated private static func uniqueProjectName(
|
||||||
|
preferred: String,
|
||||||
|
context: ServerContext
|
||||||
|
) -> String {
|
||||||
|
let existing = Set(ProjectDashboardService(context: context).loadRegistry().projects.map(\.name))
|
||||||
|
if !existing.contains(preferred) { return preferred }
|
||||||
|
var i = 2
|
||||||
|
while existing.contains("\(preferred) \(i)") {
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return "\(preferred) \(i)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Reverses the work of `ProjectTemplateInstaller`, driven by the
|
||||||
|
/// `<project>/.scarf/template.lock.json` the installer dropped. Symmetric
|
||||||
|
/// with the installer: `loadUninstallPlan(for:)` builds a plan the preview
|
||||||
|
/// sheet can display honestly; `uninstall(plan:)` executes it. No hidden
|
||||||
|
/// side effects — every path the uninstaller touches is in the plan.
|
||||||
|
///
|
||||||
|
/// **User-added files are preserved.** The lock records exactly what the
|
||||||
|
/// installer wrote; any file the user created in the project dir after
|
||||||
|
/// install (e.g. a `sites.txt` or `status-log.md` authored by the agent
|
||||||
|
/// on first run) is listed as an "extra entry" in the plan and left on
|
||||||
|
/// disk. If the project dir ends up empty after removing lock-tracked
|
||||||
|
/// files, the dir itself is removed; otherwise the dir (with user content)
|
||||||
|
/// stays.
|
||||||
|
struct ProjectTemplateUninstaller: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateUninstaller")
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
|
||||||
|
nonisolated init(context: ServerContext = .local) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Detection
|
||||||
|
|
||||||
|
/// Is the given project installed from a template that we can
|
||||||
|
/// uninstall cleanly? Cheap — just a file-existence check on the lock
|
||||||
|
/// path.
|
||||||
|
nonisolated func isTemplateInstalled(project: ProjectEntry) -> Bool {
|
||||||
|
context.makeTransport().fileExists(lockPath(for: project))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning
|
||||||
|
|
||||||
|
/// Read the lock file, walk the filesystem + cron list, and produce a
|
||||||
|
/// plan listing every op the uninstaller will perform. Does not
|
||||||
|
/// modify anything.
|
||||||
|
nonisolated func loadUninstallPlan(for project: ProjectEntry) throws -> TemplateUninstallPlan {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
let path = lockPath(for: project)
|
||||||
|
guard transport.fileExists(path) else {
|
||||||
|
throw ProjectTemplateError.lockFileMissing(path)
|
||||||
|
}
|
||||||
|
let lockData: Data
|
||||||
|
do {
|
||||||
|
lockData = try transport.readFile(path)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
let lock: TemplateLock
|
||||||
|
do {
|
||||||
|
lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||||
|
} catch {
|
||||||
|
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Partition tracked project files into present vs. already-gone.
|
||||||
|
// The lock file itself is always in `projectFiles` — the installer
|
||||||
|
// doesn't explicitly record it, but the preview sheet and the
|
||||||
|
// execute step must remove it.
|
||||||
|
var lockTrackedFiles = lock.projectFiles
|
||||||
|
lockTrackedFiles.append(path)
|
||||||
|
var toRemove: [String] = []
|
||||||
|
var alreadyGone: [String] = []
|
||||||
|
for file in lockTrackedFiles {
|
||||||
|
if transport.fileExists(file) {
|
||||||
|
toRemove.append(file)
|
||||||
|
} else {
|
||||||
|
alreadyGone.append(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan the project dir for entries that AREN'T in the lock — these
|
||||||
|
// are user-added and we preserve them. An empty project dir (after
|
||||||
|
// removing lock-tracked files) gets removed too.
|
||||||
|
let trackedSet = Set(lockTrackedFiles)
|
||||||
|
let extras = try enumerateProjectDirExtras(
|
||||||
|
projectDir: project.path,
|
||||||
|
trackedPaths: trackedSet,
|
||||||
|
transport: transport
|
||||||
|
)
|
||||||
|
let projectDirBecomesEmpty = extras.isEmpty
|
||||||
|
|
||||||
|
// Resolve cron job ids by matching lock names against the live
|
||||||
|
// list. Names that no longer exist go into the already-gone bucket
|
||||||
|
// — the user likely removed them by hand.
|
||||||
|
let currentJobs = HermesFileService(context: context).loadCronJobs()
|
||||||
|
var cronToRemove: [(id: String, name: String)] = []
|
||||||
|
var cronGone: [String] = []
|
||||||
|
for name in lock.cronJobNames {
|
||||||
|
if let match = currentJobs.first(where: { $0.name == name }) {
|
||||||
|
cronToRemove.append((id: match.id, name: match.name))
|
||||||
|
} else {
|
||||||
|
cronGone.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory block detection. The installer wraps its appendix between
|
||||||
|
// `<!-- scarf-template:<id>:begin -->` / `:end -->` markers; look
|
||||||
|
// for the begin marker in the current MEMORY.md. If it's missing
|
||||||
|
// (never installed, or removed by hand) we simply skip the memory
|
||||||
|
// strip step.
|
||||||
|
let memoryPath = context.paths.memoryMD
|
||||||
|
var memoryBlockPresent = false
|
||||||
|
if lock.memoryBlockId != nil {
|
||||||
|
if transport.fileExists(memoryPath),
|
||||||
|
let data = try? transport.readFile(memoryPath),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(
|
||||||
|
templateId: lock.memoryBlockId!
|
||||||
|
)
|
||||||
|
memoryBlockPresent = text.contains(beginMarker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TemplateUninstallPlan(
|
||||||
|
lock: lock,
|
||||||
|
project: project,
|
||||||
|
projectFilesToRemove: toRemove,
|
||||||
|
projectFilesAlreadyGone: alreadyGone,
|
||||||
|
extraProjectEntries: extras,
|
||||||
|
projectDirBecomesEmpty: projectDirBecomesEmpty,
|
||||||
|
skillsNamespaceDir: lock.skillsNamespaceDir,
|
||||||
|
cronJobsToRemove: cronToRemove,
|
||||||
|
cronJobsAlreadyGone: cronGone,
|
||||||
|
memoryBlockPresent: memoryBlockPresent,
|
||||||
|
memoryPath: memoryPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Execution
|
||||||
|
|
||||||
|
/// Execute the plan. Non-atomic: steps run in order, and if any step
|
||||||
|
/// throws, later steps don't run. v1 doesn't ship rollback — the lock
|
||||||
|
/// file itself is only removed at the very end, so a mid-flight
|
||||||
|
/// failure leaves enough breadcrumbs for the user to retry or finish
|
||||||
|
/// by hand.
|
||||||
|
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
|
||||||
|
let transport = context.makeTransport()
|
||||||
|
|
||||||
|
// 1. Project files (tracked only — user additions untouched).
|
||||||
|
for file in plan.projectFilesToRemove {
|
||||||
|
do {
|
||||||
|
try transport.removeFile(file)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't remove project file \(file, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
// keep going — partial cleanup is better than bailing and
|
||||||
|
// leaving orphan skills/cron state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plan.projectDirBecomesEmpty, transport.fileExists(plan.project.path) {
|
||||||
|
do {
|
||||||
|
try transport.removeFile(plan.project.path)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't remove empty project dir \(plan.project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Skills namespace dir (always removed wholesale — it's
|
||||||
|
// isolated, never mixed with user skills).
|
||||||
|
if let skillsDir = plan.skillsNamespaceDir, transport.fileExists(skillsDir) {
|
||||||
|
try removeRecursively(skillsDir, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cron jobs via CLI — `hermes cron remove <id>`. A non-zero
|
||||||
|
// exit gets logged but doesn't abort the uninstall; leaving a
|
||||||
|
// stray cron job is better than leaving it AND the skills/memory
|
||||||
|
// state that was supposed to pair with it.
|
||||||
|
for job in plan.cronJobsToRemove {
|
||||||
|
let (output, exit) = context.runHermes(["cron", "remove", job.id])
|
||||||
|
if exit != 0 {
|
||||||
|
Self.logger.warning("failed to remove cron job \(job.id, privacy: .public) \(job.name, privacy: .public): \(output, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Memory block — strip the bracketed block in place. Safe
|
||||||
|
// when the block is absent; we already decided presence in the
|
||||||
|
// plan and only come here when `memoryBlockPresent` was true
|
||||||
|
// AND the plan recorded a memoryBlockId.
|
||||||
|
if plan.memoryBlockPresent, let blockId = plan.lock.memoryBlockId {
|
||||||
|
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4a. Config Keychain items — remove every secret the template's
|
||||||
|
// install step stashed in the login Keychain. Items that were
|
||||||
|
// already deleted (e.g. user cleaned them with Keychain Access)
|
||||||
|
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
|
||||||
|
// a stale lock doesn't abort the rest of the uninstall.
|
||||||
|
let keychain = ProjectConfigKeychain()
|
||||||
|
for uri in plan.lock.configKeychainItems ?? [] {
|
||||||
|
guard let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Projects registry — remove the entry by path (more stable
|
||||||
|
// than name: user may have renamed the project in the UI).
|
||||||
|
let dashboardService = ProjectDashboardService(context: context)
|
||||||
|
var registry = dashboardService.loadRegistry()
|
||||||
|
registry.projects.removeAll { $0.path == plan.project.path }
|
||||||
|
dashboardService.saveRegistry(registry)
|
||||||
|
|
||||||
|
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
nonisolated private func lockPath(for project: ProjectEntry) -> String {
|
||||||
|
project.path + "/.scarf/template.lock.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk the project dir and return the absolute paths of every entry
|
||||||
|
/// not in `trackedPaths`. `.scarf/` (and its remaining contents after
|
||||||
|
/// the lock is recorded) is filtered out because the installer owns
|
||||||
|
/// that directory entirely — if the user dropped a file into it,
|
||||||
|
/// that's on them, but the common case is that `.scarf/` only holds
|
||||||
|
/// our dashboard.json + template.lock.json.
|
||||||
|
nonisolated private func enumerateProjectDirExtras(
|
||||||
|
projectDir: String,
|
||||||
|
trackedPaths: Set<String>,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws -> [String] {
|
||||||
|
guard transport.fileExists(projectDir) else { return [] }
|
||||||
|
var extras: [String] = []
|
||||||
|
let entries: [String]
|
||||||
|
do {
|
||||||
|
entries = try transport.listDirectory(projectDir)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
for entry in entries {
|
||||||
|
let full = projectDir + "/" + entry
|
||||||
|
// Skip the .scarf/ dir entirely when deciding "does the
|
||||||
|
// project dir have user content?" — the only files we put
|
||||||
|
// there (dashboard.json + lock) are tracked already, and
|
||||||
|
// if they're still there the overall project is not yet
|
||||||
|
// "empty."
|
||||||
|
if entry == ".scarf" { continue }
|
||||||
|
if trackedPaths.contains(full) { continue }
|
||||||
|
extras.append(full)
|
||||||
|
}
|
||||||
|
return extras
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recursively delete a directory via the transport. The transport's
|
||||||
|
/// `removeFile` works on files and on empty directories; we walk
|
||||||
|
/// children first, then remove the now-empty parent.
|
||||||
|
nonisolated private func removeRecursively(
|
||||||
|
_ path: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
guard transport.fileExists(path) else { return }
|
||||||
|
if transport.stat(path)?.isDirectory != true {
|
||||||
|
try transport.removeFile(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let entries = (try? transport.listDirectory(path)) ?? []
|
||||||
|
for entry in entries {
|
||||||
|
try removeRecursively(path + "/" + entry, transport: transport)
|
||||||
|
}
|
||||||
|
try transport.removeFile(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the `<!-- scarf-template:<id>:begin --> … :end -->` block
|
||||||
|
/// from MEMORY.md, preserving everything else. A missing end marker
|
||||||
|
/// is logged but doesn't fail — we strip from the begin marker to
|
||||||
|
/// EOF in that case, on the theory that a broken template block is
|
||||||
|
/// worse than a slightly aggressive strip.
|
||||||
|
nonisolated private func stripMemoryBlock(
|
||||||
|
blockId: String,
|
||||||
|
memoryPath: String,
|
||||||
|
transport: any ServerTransport
|
||||||
|
) throws {
|
||||||
|
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(templateId: blockId)
|
||||||
|
let endMarker = ProjectTemplateService.memoryBlockEndMarker(templateId: blockId)
|
||||||
|
|
||||||
|
let data = try transport.readFile(memoryPath)
|
||||||
|
guard let text = String(data: data, encoding: .utf8) else { return }
|
||||||
|
guard let beginRange = text.range(of: beginMarker) else { return }
|
||||||
|
|
||||||
|
let stripRange: Range<String.Index>
|
||||||
|
if let endRange = text.range(of: endMarker, range: beginRange.upperBound..<text.endIndex) {
|
||||||
|
// Include the end marker and one trailing newline if present.
|
||||||
|
var upper = endRange.upperBound
|
||||||
|
if upper < text.endIndex, text[upper] == "\n" {
|
||||||
|
upper = text.index(after: upper)
|
||||||
|
}
|
||||||
|
stripRange = beginRange.lowerBound..<upper
|
||||||
|
} else {
|
||||||
|
Self.logger.warning("memory block for \(blockId, privacy: .public) has begin marker but no end marker; stripping to EOF")
|
||||||
|
stripRange = beginRange.lowerBound..<text.endIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also consume one leading blank line that the installer inserts
|
||||||
|
// before the begin marker, so repeated install/uninstall cycles
|
||||||
|
// don't accumulate blank lines at the insertion site.
|
||||||
|
var lower = stripRange.lowerBound
|
||||||
|
if lower > text.startIndex {
|
||||||
|
let prev = text.index(before: lower)
|
||||||
|
if text[prev] == "\n", prev > text.startIndex {
|
||||||
|
let prevPrev = text.index(before: prev)
|
||||||
|
if text[prevPrev] == "\n" {
|
||||||
|
lower = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let updated = text.replacingCharacters(in: lower..<stripRange.upperBound, with: "")
|
||||||
|
guard let outData = updated.data(using: .utf8) else { return }
|
||||||
|
try transport.writeFile(memoryPath, data: outData)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Process-wide router for `scarf://install?url=…` URLs. The app delegate's
|
||||||
|
/// `onOpenURL` hands the URL in here; the Projects feature observes
|
||||||
|
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
|
||||||
|
///
|
||||||
|
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
|
||||||
|
/// launch from a browser link) and still be picked up by the first
|
||||||
|
/// `ProjectsView` that appears.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateURLRouter {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
|
||||||
|
|
||||||
|
static let shared = TemplateURLRouter()
|
||||||
|
|
||||||
|
/// Non-nil when an install request is waiting to be handled. Can be
|
||||||
|
/// either a remote `https://…` URL (from a `scarf://install?url=…` deep
|
||||||
|
/// link) or a local `file://…` URL (from a Finder double-click on a
|
||||||
|
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
|
||||||
|
/// this, dispatch by scheme, present the install sheet, then call
|
||||||
|
/// `consume` to clear it. Only one pending install at a time — if a
|
||||||
|
/// second arrives before the first is consumed, it replaces the first
|
||||||
|
/// (matches browser-link intuition where the latest click wins).
|
||||||
|
var pendingInstallURL: URL?
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
/// Parse and validate an inbound URL. Returns `true` if the URL was
|
||||||
|
/// recognized and staged for handling. Unknown schemes or malformed
|
||||||
|
/// payloads return `false` so the caller can log/ignore. Supports:
|
||||||
|
///
|
||||||
|
/// - `scarf://install?url=https://…` — remote template URL from a web link.
|
||||||
|
/// - `file:///…/foo.scarftemplate` — local file from a Finder
|
||||||
|
/// double-click or a drag onto the app icon.
|
||||||
|
@discardableResult
|
||||||
|
func handle(_ url: URL) -> Bool {
|
||||||
|
if url.isFileURL {
|
||||||
|
return handleFileURL(url)
|
||||||
|
}
|
||||||
|
if url.scheme?.lowercased() == "scarf" {
|
||||||
|
return handleScarfURL(url)
|
||||||
|
}
|
||||||
|
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleFileURL(_ url: URL) -> Bool {
|
||||||
|
guard url.pathExtension.lowercased() == "scarftemplate" else {
|
||||||
|
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingInstallURL = url
|
||||||
|
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleScarfURL(_ url: URL) -> Bool {
|
||||||
|
guard url.host?.lowercased() == "install" else {
|
||||||
|
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||||
|
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
|
||||||
|
let remote = URL(string: raw) else {
|
||||||
|
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Refuse anything but https — defense-in-depth against a browser or
|
||||||
|
// mail client that would happily hand us a javascript: or http://
|
||||||
|
// URL pointing at something unexpected.
|
||||||
|
guard remote.scheme?.lowercased() == "https" else {
|
||||||
|
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
pendingInstallURL = remote
|
||||||
|
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by the install sheet once it has picked up the URL.
|
||||||
|
func consume() {
|
||||||
|
pendingInstallURL = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
private enum DashboardTab: String, CaseIterable {
|
private enum DashboardTab: String, CaseIterable {
|
||||||
case dashboard = "Dashboard"
|
case dashboard = "Dashboard"
|
||||||
@@ -14,12 +15,34 @@ private enum DashboardTab: String, CaseIterable {
|
|||||||
|
|
||||||
struct ProjectsView: View {
|
struct ProjectsView: View {
|
||||||
@State private var viewModel: ProjectsViewModel
|
@State private var viewModel: ProjectsViewModel
|
||||||
|
@State private var installerViewModel: TemplateInstallerViewModel
|
||||||
|
@State private var uninstallerViewModel: TemplateUninstallerViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||||
|
@Environment(\.serverContext) private var serverContext
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
|
@State private var showingInstallSheet = false
|
||||||
|
@State private var exportSheetProject: ProjectEntry?
|
||||||
|
@State private var showingInstallURLPrompt = false
|
||||||
|
@State private var installURLInput = ""
|
||||||
|
@State private var showingUninstallSheet = false
|
||||||
|
@State private var configEditorProject: ProjectEntry?
|
||||||
|
|
||||||
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
init(context: ServerContext) {
|
init(context: ServerContext) {
|
||||||
_viewModel = State(initialValue: ProjectsViewModel(context: context))
|
_viewModel = State(initialValue: ProjectsViewModel(context: context))
|
||||||
|
_installerViewModel = State(initialValue: TemplateInstallerViewModel(context: context))
|
||||||
|
_uninstallerViewModel = State(initialValue: TemplateUninstallerViewModel(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
|
||||||
@@ -32,6 +55,7 @@ struct ProjectsView: View {
|
|||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
.navigationTitle("Projects")
|
.navigationTitle("Projects")
|
||||||
|
.toolbar { templatesToolbar }
|
||||||
.task {
|
.task {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
if let name = coordinator.selectedProjectName,
|
if let name = coordinator.selectedProjectName,
|
||||||
@@ -39,11 +63,157 @@ struct ProjectsView: View {
|
|||||||
viewModel.selectProject(project)
|
viewModel.selectProject(project)
|
||||||
}
|
}
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
// Cold-launch deep link or Finder double-click: the router may
|
||||||
|
// have a URL staged before this view installed the onChange
|
||||||
|
// observer below. Without this first-appearance check,
|
||||||
|
// SwiftUI's .onChange would never fire (it only reacts to
|
||||||
|
// *changes* after installation) and the URL would sit on the
|
||||||
|
// singleton forever.
|
||||||
|
if let pending = TemplateURLRouter.shared.pendingInstallURL {
|
||||||
|
dispatchPendingInstall(pending)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: fileWatcher.lastChangeDate) {
|
.onChange(of: fileWatcher.lastChangeDate) {
|
||||||
viewModel.load()
|
viewModel.load()
|
||||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
}
|
}
|
||||||
|
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
|
||||||
|
// A URL landed *while the app was already running*.
|
||||||
|
if let new {
|
||||||
|
dispatchPendingInstall(new)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingInstallSheet) {
|
||||||
|
TemplateInstallSheet(viewModel: installerViewModel) { entry in
|
||||||
|
viewModel.load()
|
||||||
|
coordinator.selectedProjectName = entry.name
|
||||||
|
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
|
||||||
|
viewModel.selectProject(project)
|
||||||
|
}
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $exportSheetProject) { project in
|
||||||
|
TemplateExportSheet(
|
||||||
|
viewModel: TemplateExporterViewModel(context: serverContext, project: project)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingInstallURLPrompt) {
|
||||||
|
installURLSheet
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingUninstallSheet) {
|
||||||
|
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
|
||||||
|
// Refresh the registry and clear selection if we just
|
||||||
|
// removed the project the user was viewing.
|
||||||
|
if viewModel.selectedProject?.path == removed.path {
|
||||||
|
viewModel.selectedProject = nil
|
||||||
|
}
|
||||||
|
if coordinator.selectedProjectName == removed.name {
|
||||||
|
coordinator.selectedProjectName = nil
|
||||||
|
}
|
||||||
|
viewModel.load()
|
||||||
|
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $configEditorProject) { project in
|
||||||
|
ConfigEditorSheet(
|
||||||
|
context: serverContext,
|
||||||
|
project: project
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Toolbar
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var templatesToolbar: some ToolbarContent {
|
||||||
|
ToolbarItem(placement: .primaryAction) {
|
||||||
|
Menu {
|
||||||
|
Button("Install from File…", systemImage: "tray.and.arrow.down") {
|
||||||
|
openInstallFilePicker()
|
||||||
|
}
|
||||||
|
Button("Install from URL…", systemImage: "link") {
|
||||||
|
installURLInput = ""
|
||||||
|
showingInstallURLPrompt = true
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
if let selected = viewModel.selectedProject {
|
||||||
|
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
|
||||||
|
exportSheetProject = selected
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button("Export as Template…", systemImage: "tray.and.arrow.up") {}
|
||||||
|
.disabled(true)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Templates", systemImage: "shippingbox")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var installURLSheet: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Install Template from URL")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Paste an https URL pointing at a .scarftemplate file.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { showingInstallURLPrompt = false }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Install") {
|
||||||
|
if let url = URL(string: installURLInput), url.scheme?.lowercased() == "https" {
|
||||||
|
installerViewModel.openRemoteURL(url)
|
||||||
|
showingInstallURLPrompt = false
|
||||||
|
showingInstallSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(minWidth: 480)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a pending install URL to the right VM entry point. `file://`
|
||||||
|
/// URLs come from Finder double-clicks + the "Install from File…" flow
|
||||||
|
/// when routed via the router; `https://` URLs come from `scarf://`
|
||||||
|
/// deep links and the "Install from URL…" prompt.
|
||||||
|
private func dispatchPendingInstall(_ url: URL) {
|
||||||
|
if url.isFileURL {
|
||||||
|
installerViewModel.openLocalFile(url.path)
|
||||||
|
} else {
|
||||||
|
installerViewModel.openRemoteURL(url)
|
||||||
|
}
|
||||||
|
TemplateURLRouter.shared.consume()
|
||||||
|
showingInstallSheet = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openInstallFilePicker() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = false
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
// Accept both the declared Scarf template UTI and plain zip — the
|
||||||
|
// custom UTI wins for files with the .scarftemplate extension, and
|
||||||
|
// the zip fallback means an author distributing under .zip (e.g.
|
||||||
|
// before the UTI is registered on the receiving Mac) still works.
|
||||||
|
var types: [UTType] = [.zip]
|
||||||
|
if let templateType = UTType("com.scarf.template") {
|
||||||
|
types.insert(templateType, at: 0)
|
||||||
|
}
|
||||||
|
panel.allowedContentTypes = types
|
||||||
|
panel.allowsOtherFileTypes = true
|
||||||
|
panel.prompt = String(localized: "Install Template")
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
installerViewModel.openLocalFile(url.path)
|
||||||
|
showingInstallSheet = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Project List
|
// MARK: - Project List
|
||||||
@@ -65,6 +235,23 @@ struct ProjectsView: View {
|
|||||||
Text(project.name)
|
Text(project.name)
|
||||||
}
|
}
|
||||||
.tag(project)
|
.tag(project)
|
||||||
|
.contextMenu {
|
||||||
|
if isConfigurable(project) {
|
||||||
|
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||||
|
configEditorProject = project
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if uninstaller.isTemplateInstalled(project: project) {
|
||||||
|
Button("Uninstall Template…", systemImage: "trash") {
|
||||||
|
uninstallerViewModel.begin(project: project)
|
||||||
|
showingUninstallSheet = true
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
Button("Remove from Scarf", systemImage: "minus.circle") {
|
||||||
|
viewModel.removeProject(project)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
|
|
||||||
@@ -216,6 +403,25 @@ 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) {
|
||||||
|
Button {
|
||||||
|
uninstallerViewModel.begin(project: project)
|
||||||
|
showingUninstallSheet = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "shippingbox.and.arrow.backward")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Uninstall template")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,28 @@ struct ManageServersView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var list: some View {
|
private var list: some View {
|
||||||
List {
|
let defaultID = registry.defaultServerID
|
||||||
|
return List {
|
||||||
|
// Local sits at the top so users can mark it as the open-on-launch
|
||||||
|
// default alongside remote servers. It's synthesized (not in
|
||||||
|
// `registry.entries`), so render it explicitly.
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
defaultStar(for: ServerContext.local.id, currentDefault: defaultID)
|
||||||
|
Image(systemName: "laptopcomputer")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Local").font(.body)
|
||||||
|
Text("This Mac")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
ForEach(registry.entries) { entry in
|
ForEach(registry.entries) { entry in
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
defaultStar(for: entry.id, currentDefault: defaultID)
|
||||||
Image(systemName: "server.rack")
|
Image(systemName: "server.rack")
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(.blue)
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -123,6 +142,23 @@ struct ManageServersView: View {
|
|||||||
.listStyle(.inset)
|
.listStyle(.inset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A star button that marks the open-on-launch default. Filled + yellow
|
||||||
|
/// on the current default row (and non-interactive — clicking it is a
|
||||||
|
/// no-op since the flag is already set); outline + secondary elsewhere,
|
||||||
|
/// clicking promotes that row to default.
|
||||||
|
@ViewBuilder
|
||||||
|
private func defaultStar(for id: ServerID, currentDefault: ServerID) -> some View {
|
||||||
|
let isDefault = id == currentDefault
|
||||||
|
Button {
|
||||||
|
if !isDefault { registry.setDefaultServer(id) }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: isDefault ? "star.fill" : "star")
|
||||||
|
.foregroundStyle(isDefault ? .yellow : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help(isDefault ? "Opens on launch" : "Set as default — open this server when Scarf launches.")
|
||||||
|
}
|
||||||
|
|
||||||
private func summary(for config: SSHConfig) -> String {
|
private func summary(for config: SSHConfig) -> String {
|
||||||
var s = ""
|
var s = ""
|
||||||
if let user = config.user, !user.isEmpty { s += "\(user)@" }
|
if let user = config.user, !user.isEmpty { s += "\(user)@" }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the template export sheet. Holds form state for the author-facing
|
||||||
|
/// fields (id, name, version, description, …) and the selection of skills
|
||||||
|
/// and cron jobs to include, then builds and writes the `.scarftemplate` on
|
||||||
|
/// confirm.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateExporterViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateExporterViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case exporting
|
||||||
|
case succeeded(path: String)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
let project: ProjectEntry
|
||||||
|
private let exporter: ProjectTemplateExporter
|
||||||
|
|
||||||
|
init(context: ServerContext, project: ProjectEntry) {
|
||||||
|
self.context = context
|
||||||
|
self.project = project
|
||||||
|
self.exporter = ProjectTemplateExporter(context: context)
|
||||||
|
|
||||||
|
self.templateName = project.name
|
||||||
|
self.templateId = "you/\(ProjectTemplateExporter.slugify(project.name))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form fields
|
||||||
|
var templateId: String
|
||||||
|
var templateName: String
|
||||||
|
var templateVersion: String = "1.0.0"
|
||||||
|
var templateDescription: String = ""
|
||||||
|
var authorName: String = ""
|
||||||
|
var authorURL: String = ""
|
||||||
|
var category: String = ""
|
||||||
|
var tags: String = ""
|
||||||
|
var includeSkillIds: Set<String> = []
|
||||||
|
var includeCronJobIds: Set<String> = []
|
||||||
|
var memoryAppendix: String = ""
|
||||||
|
|
||||||
|
// Derived: what the author can pick from
|
||||||
|
var availableSkills: [HermesSkill] = []
|
||||||
|
var availableCronJobs: [HermesCronJob] = []
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
|
||||||
|
func load() {
|
||||||
|
let ctx = context
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let service = HermesFileService(context: ctx)
|
||||||
|
let skills = service.loadSkills().flatMap(\.skills)
|
||||||
|
let jobs = service.loadCronJobs()
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.availableSkills = skills
|
||||||
|
self?.availableCronJobs = jobs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func previewPlan() -> ProjectTemplateExporter.ExportPlan {
|
||||||
|
exporter.previewPlan(for: currentInputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick off the export, writing to `outputPath`. The caller is
|
||||||
|
/// responsible for bouncing the user through an `NSSavePanel` to get
|
||||||
|
/// that path.
|
||||||
|
func export(to outputPath: String) {
|
||||||
|
stage = .exporting
|
||||||
|
let exporter = exporter
|
||||||
|
let inputs = currentInputs
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try exporter.export(inputs: inputs, outputZipPath: outputPath)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .succeeded(path: outputPath)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private
|
||||||
|
|
||||||
|
private var currentInputs: ProjectTemplateExporter.ExportInputs {
|
||||||
|
let parsedTags = tags
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
let trimmedAppendix = memoryAppendix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return ProjectTemplateExporter.ExportInputs(
|
||||||
|
project: project,
|
||||||
|
templateId: templateId.trimmingCharacters(in: .whitespaces),
|
||||||
|
templateName: templateName.trimmingCharacters(in: .whitespaces),
|
||||||
|
templateVersion: templateVersion.trimmingCharacters(in: .whitespaces),
|
||||||
|
description: templateDescription.trimmingCharacters(in: .whitespaces),
|
||||||
|
authorName: authorName.isEmpty ? nil : authorName,
|
||||||
|
authorUrl: authorURL.isEmpty ? nil : authorURL,
|
||||||
|
category: category.isEmpty ? nil : category,
|
||||||
|
tags: parsedTags,
|
||||||
|
includeSkillIds: Array(includeSkillIds),
|
||||||
|
includeCronJobIds: Array(includeCronJobIds),
|
||||||
|
memoryAppendix: trimmedAppendix.isEmpty ? nil : trimmedAppendix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProjectTemplateExporter {
|
||||||
|
/// Lowercase-and-hyphenate a human name into something safe for a
|
||||||
|
/// template id suffix. Only used to seed the default id in the export
|
||||||
|
/// form — the author can overwrite it.
|
||||||
|
nonisolated static func slugify(_ raw: String) -> String {
|
||||||
|
let lower = raw.lowercased()
|
||||||
|
let mapped = lower.unicodeScalars.map { scalar -> Character in
|
||||||
|
let c = Character(scalar)
|
||||||
|
if c.isLetter || c.isNumber { return c }
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
let collapsed = String(mapped)
|
||||||
|
.split(separator: "-", omittingEmptySubsequences: true)
|
||||||
|
.joined(separator: "-")
|
||||||
|
return collapsed.isEmpty ? "template" : collapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the template install sheet. Handles three entry points:
|
||||||
|
/// 1. `openLocalFile(_:)` — user picked a `.scarftemplate` from disk.
|
||||||
|
/// 2. `openRemoteURL(_:)` — user pasted/deeplinked a https URL.
|
||||||
|
/// 3. `confirmInstall()` — user clicked "Install" in the preview sheet.
|
||||||
|
///
|
||||||
|
/// The view model owns one ephemeral temp dir at a time (the unpacked
|
||||||
|
/// bundle). `cancel()` or `confirmInstall()` removes it.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateInstallerViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case fetching(sourceDescription: String)
|
||||||
|
case inspecting
|
||||||
|
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 installing
|
||||||
|
case succeeded(installed: ProjectEntry)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
private let templateService: ProjectTemplateService
|
||||||
|
private let installer: ProjectTemplateInstaller
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
self.templateService = ProjectTemplateService(context: context)
|
||||||
|
self.installer = ProjectTemplateInstaller(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
var inspection: TemplateInspection?
|
||||||
|
var plan: TemplateInstallPlan?
|
||||||
|
var chosenParentDirectory: String?
|
||||||
|
/// README body preloaded off MainActor when inspection completes, so the
|
||||||
|
/// preview sheet can render it without hitting `String(contentsOf:)` from
|
||||||
|
/// inside a View body.
|
||||||
|
var readmeBody: String?
|
||||||
|
|
||||||
|
// MARK: - Entry points
|
||||||
|
|
||||||
|
/// Inspect a local `.scarftemplate` file. Moves stage to `.inspecting`
|
||||||
|
/// then either `.awaitingParentDirectory` or `.failed`. The unpacked
|
||||||
|
/// README body is read off MainActor here and stored on the VM so the
|
||||||
|
/// preview sheet doesn't do sync I/O during View body evaluation.
|
||||||
|
func openLocalFile(_ zipPath: String) {
|
||||||
|
resetTempState()
|
||||||
|
stage = .inspecting
|
||||||
|
let service = templateService
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let inspection = try service.inspect(zipPath: zipPath)
|
||||||
|
let readme = Self.readReadme(unpackedDir: inspection.unpackedDir)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.inspection = inspection
|
||||||
|
self.readmeBody = readme
|
||||||
|
self.stage = .awaitingParentDirectory
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read README.md from an unpacked template dir. Nonisolated so the
|
||||||
|
/// inspect task can call it off MainActor. Returns `nil` on any I/O
|
||||||
|
/// failure — the preview sheet treats a nil README as "no section."
|
||||||
|
nonisolated private static func readReadme(unpackedDir: String) -> String? {
|
||||||
|
let path = unpackedDir + "/README.md"
|
||||||
|
do {
|
||||||
|
return try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
|
||||||
|
.warning("couldn't read README at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download a https `.scarftemplate` to a temp file, then hand off to
|
||||||
|
/// `openLocalFile`. The 50 MB cap matches the plan — templates shouldn't
|
||||||
|
/// be anywhere near that, and rejecting huge downloads is cheap defense.
|
||||||
|
///
|
||||||
|
/// Content-Length is checked first as an early-out, but chunked
|
||||||
|
/// transfer responses omit that header. The authoritative check is the
|
||||||
|
/// actual on-disk file size after the download completes — it runs
|
||||||
|
/// unconditionally and covers the chunked-transfer case.
|
||||||
|
func openRemoteURL(_ url: URL) {
|
||||||
|
resetTempState()
|
||||||
|
stage = .fetching(sourceDescription: url.host ?? url.absoluteString)
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let maxBytes: Int64 = 50 * 1024 * 1024
|
||||||
|
do {
|
||||||
|
let tempZip = NSTemporaryDirectory() + "scarf-template-download-" + UUID().uuidString + ".scarftemplate"
|
||||||
|
let (tempURL, response) = try await URLSession.shared.download(from: url)
|
||||||
|
defer { try? FileManager.default.removeItem(at: tempURL) }
|
||||||
|
if let httpResponse = response as? HTTPURLResponse {
|
||||||
|
guard (200...299).contains(httpResponse.statusCode) else {
|
||||||
|
throw ProjectTemplateError.unzipFailed("HTTP \(httpResponse.statusCode)")
|
||||||
|
}
|
||||||
|
if let length = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||||
|
let bytes = Int64(length), bytes > maxBytes {
|
||||||
|
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(bytes) bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unconditional post-download size check — catches chunked
|
||||||
|
// responses that ship no Content-Length. The download already
|
||||||
|
// hit disk, but refusing to *process* it bounds the blast
|
||||||
|
// radius to one temp file that gets removed in the defer.
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: tempURL.path)
|
||||||
|
let actualSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||||
|
guard actualSize <= maxBytes else {
|
||||||
|
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(actualSize) bytes)")
|
||||||
|
}
|
||||||
|
try FileManager.default.moveItem(atPath: tempURL.path, toPath: tempZip)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.openLocalFile(tempZip)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed("Couldn't fetch template: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning + confirmation
|
||||||
|
|
||||||
|
/// Finalize the plan now that the user has picked a parent directory.
|
||||||
|
func pickParentDirectory(_ parentDir: String) {
|
||||||
|
guard let inspection else { return }
|
||||||
|
chosenParentDirectory = parentDir
|
||||||
|
let service = templateService
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
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 {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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() {
|
||||||
|
guard let plan else { return }
|
||||||
|
stage = .installing
|
||||||
|
let installer = installer
|
||||||
|
let service = templateService
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
service.cleanupTempDir(plan.unpackedDir)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.stage = .succeeded(installed: entry)
|
||||||
|
self.inspection = nil
|
||||||
|
self.plan = nil
|
||||||
|
self.chosenParentDirectory = nil
|
||||||
|
self.readmeBody = nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cleanup
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
resetTempState()
|
||||||
|
stage = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetTempState() {
|
||||||
|
if let inspection {
|
||||||
|
templateService.cleanupTempDir(inspection.unpackedDir)
|
||||||
|
}
|
||||||
|
inspection = nil
|
||||||
|
plan = nil
|
||||||
|
chosenParentDirectory = nil
|
||||||
|
readmeBody = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
/// Drives the template-uninstall sheet. Mirrors the installer VM in
|
||||||
|
/// stage shape: open a plan (`begin`), preview it, confirm or cancel.
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class TemplateUninstallerViewModel {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateUninstallerViewModel")
|
||||||
|
|
||||||
|
enum Stage: Sendable {
|
||||||
|
case idle
|
||||||
|
case loading
|
||||||
|
case planned
|
||||||
|
case uninstalling
|
||||||
|
case succeeded(removed: ProjectEntry)
|
||||||
|
case failed(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: ServerContext
|
||||||
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
|
init(context: ServerContext) {
|
||||||
|
self.context = context
|
||||||
|
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stage: Stage = .idle
|
||||||
|
var plan: TemplateUninstallPlan?
|
||||||
|
|
||||||
|
/// Load the `template.lock.json` for the given project and build a
|
||||||
|
/// removal plan. Moves stage to `.planned` on success.
|
||||||
|
func begin(project: ProjectEntry) {
|
||||||
|
stage = .loading
|
||||||
|
let uninstaller = uninstaller
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let plan = try uninstaller.loadUninstallPlan(for: project)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.plan = plan
|
||||||
|
self.stage = .planned
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirmUninstall() {
|
||||||
|
guard let plan else { return }
|
||||||
|
stage = .uninstalling
|
||||||
|
let uninstaller = uninstaller
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try uninstaller.uninstall(plan: plan)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.stage = .succeeded(removed: plan.project)
|
||||||
|
self.plan = nil
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
self?.stage = .failed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancel() {
|
||||||
|
plan = nil
|
||||||
|
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,384 @@
|
|||||||
|
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") {
|
||||||
|
onCancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button(commitLabel) {
|
||||||
|
if let finalized = viewModel.commit(project: project) {
|
||||||
|
onCommit(finalized)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
||||||
|
Text(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
/// Author-facing sheet for exporting an existing project as a
|
||||||
|
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
|
||||||
|
/// pick which skills/cron jobs to include, save via NSSavePanel.
|
||||||
|
struct TemplateExportSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State var viewModel: TemplateExporterViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle:
|
||||||
|
form
|
||||||
|
case .exporting:
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Building template…")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .succeeded(let path):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Exported").font(.title2.bold())
|
||||||
|
Text(path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Button("Show in Finder") {
|
||||||
|
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
||||||
|
}
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
case .failed(let message):
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Export Failed").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 620, minHeight: 560)
|
||||||
|
.padding()
|
||||||
|
.task { viewModel.load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var form: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("Export \"\(viewModel.project.name)\" as Template")
|
||||||
|
.font(.title2.bold())
|
||||||
|
metadataGroup
|
||||||
|
Divider()
|
||||||
|
requiredFilesGroup
|
||||||
|
Divider()
|
||||||
|
instructionsGroup
|
||||||
|
Divider()
|
||||||
|
skillsGroup
|
||||||
|
Divider()
|
||||||
|
cronGroup
|
||||||
|
Divider()
|
||||||
|
memoryGroup
|
||||||
|
}
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Export…") { runExport() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(!canExport)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var metadataGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Metadata").font(.headline)
|
||||||
|
LabeledContent("Template ID") {
|
||||||
|
TextField("owner/name", text: $viewModel.templateId)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Display Name") {
|
||||||
|
TextField("", text: $viewModel.templateName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Version") {
|
||||||
|
TextField("1.0.0", text: $viewModel.templateVersion)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Description") {
|
||||||
|
TextField("One-line pitch", text: $viewModel.templateDescription, axis: .vertical)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Author") {
|
||||||
|
TextField("Your name", text: $viewModel.authorName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Author URL") {
|
||||||
|
TextField("https://…", text: $viewModel.authorURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Category") {
|
||||||
|
TextField("e.g. productivity", text: $viewModel.category)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
LabeledContent("Tags (comma-separated)") {
|
||||||
|
TextField("focus, timer", text: $viewModel.tags)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var requiredFilesGroup: some View {
|
||||||
|
let plan = viewModel.previewPlan()
|
||||||
|
return VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Required Files").font(.headline)
|
||||||
|
check(label: "dashboard.json (\(plan.projectDir)/.scarf/dashboard.json)", ok: plan.dashboardPresent)
|
||||||
|
check(label: "README.md (\(plan.projectDir)/README.md)", ok: plan.readmePresent)
|
||||||
|
check(label: "AGENTS.md (\(plan.projectDir)/AGENTS.md)", ok: plan.agentsMdPresent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var instructionsGroup: some View {
|
||||||
|
let plan = viewModel.previewPlan()
|
||||||
|
return VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Agent-specific instructions (optional)").font(.headline)
|
||||||
|
if plan.instructionFiles.isEmpty {
|
||||||
|
Text("No per-agent instruction files found in the project root.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(plan.instructionFiles, id: \.self) { file in
|
||||||
|
Label(file, systemImage: "doc.plaintext")
|
||||||
|
.font(.callout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var skillsGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Include Skills").font(.headline)
|
||||||
|
if viewModel.availableSkills.isEmpty {
|
||||||
|
Text("No skills found.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.availableSkills) { skill in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { viewModel.includeSkillIds.contains(skill.id) },
|
||||||
|
set: { on in
|
||||||
|
if on { viewModel.includeSkillIds.insert(skill.id) }
|
||||||
|
else { viewModel.includeSkillIds.remove(skill.id) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Text(skill.id).font(.callout.monospaced())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cronGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Include Cron Jobs").font(.headline)
|
||||||
|
if viewModel.availableCronJobs.isEmpty {
|
||||||
|
Text("No cron jobs found.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.availableCronJobs) { job in
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { viewModel.includeCronJobIds.contains(job.id) },
|
||||||
|
set: { on in
|
||||||
|
if on { viewModel.includeCronJobIds.insert(job.id) }
|
||||||
|
else { viewModel.includeCronJobIds.remove(job.id) }
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(job.name).font(.callout)
|
||||||
|
Text(job.schedule.display ?? job.schedule.expression ?? job.schedule.kind)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var memoryGroup: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Memory Appendix (optional)").font(.headline)
|
||||||
|
Text("Markdown that will be appended to the installer's MEMORY.md, wrapped in template-specific markers so it can be removed cleanly later.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextEditor(text: $viewModel.memoryAppendix)
|
||||||
|
.font(.callout.monospaced())
|
||||||
|
.frame(minHeight: 80, maxHeight: 160)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(.secondary.opacity(0.4))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func check(label: String, ok: Bool) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: ok ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||||
|
.foregroundStyle(ok ? .green : .red)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(ok ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canExport: Bool {
|
||||||
|
let plan = viewModel.previewPlan()
|
||||||
|
return plan.dashboardPresent
|
||||||
|
&& plan.readmePresent
|
||||||
|
&& plan.agentsMdPresent
|
||||||
|
&& !viewModel.templateId.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !viewModel.templateName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !viewModel.templateVersion.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& !viewModel.templateDescription.trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func runExport() {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.allowedContentTypes = [.zip]
|
||||||
|
panel.nameFieldStringValue = ProjectTemplateExporter.slugify(viewModel.templateName) + ".scarftemplate"
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
viewModel.export(to: url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
/// Preview-and-confirm sheet for installing a `.scarftemplate`. Honest
|
||||||
|
/// accounting: shows every file that will be written, every cron job that
|
||||||
|
/// will be registered, and the memory diff — nothing gets written until the
|
||||||
|
/// user clicks Install.
|
||||||
|
struct TemplateInstallSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State var viewModel: TemplateInstallerViewModel
|
||||||
|
let onCompleted: (ProjectEntry) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle:
|
||||||
|
idleView
|
||||||
|
case .fetching(let src):
|
||||||
|
progress("Downloading from \(src)…")
|
||||||
|
case .inspecting:
|
||||||
|
progress("Inspecting template…")
|
||||||
|
case .awaitingParentDirectory:
|
||||||
|
pickParentView
|
||||||
|
case .awaitingConfig:
|
||||||
|
configureView
|
||||||
|
case .planned:
|
||||||
|
if let plan = viewModel.plan {
|
||||||
|
plannedView(plan: plan)
|
||||||
|
} else {
|
||||||
|
progress("Preparing…")
|
||||||
|
}
|
||||||
|
case .installing:
|
||||||
|
progress("Installing…")
|
||||||
|
case .succeeded(let entry):
|
||||||
|
successView(entry: entry)
|
||||||
|
case .failed(let message):
|
||||||
|
failureView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 640, minHeight: 520)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stages
|
||||||
|
|
||||||
|
private var idleView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("No template loaded.")
|
||||||
|
.font(.headline)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progress(_ label: LocalizedStringKey) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pickParentView: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
if let manifest = viewModel.inspection?.manifest {
|
||||||
|
manifestHeader(manifest)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
Text("Where should this project live?")
|
||||||
|
.font(.headline)
|
||||||
|
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Button("Choose Folder…") { chooseParentDirectory() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
manifestHeader(plan.manifest)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
projectFilesSection(plan: plan)
|
||||||
|
if plan.skillsNamespaceDir != nil {
|
||||||
|
skillsSection(plan: plan)
|
||||||
|
}
|
||||||
|
if !plan.cronJobs.isEmpty {
|
||||||
|
cronSection(plan: plan)
|
||||||
|
}
|
||||||
|
if plan.memoryAppendix != nil {
|
||||||
|
memorySection(plan: plan)
|
||||||
|
}
|
||||||
|
if let schema = plan.configSchema, !schema.isEmpty {
|
||||||
|
configurationSection(plan: plan, schema: schema)
|
||||||
|
}
|
||||||
|
readmeSection
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Text("\(plan.totalWriteCount) changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Install") { viewModel.confirmInstall() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func manifestHeader(_ manifest: ProjectTemplateManifest) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text(manifest.name).font(.title2.bold())
|
||||||
|
Text("v\(manifest.version)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(manifest.id)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(manifest.description)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let author = manifest.author {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "person.crop.circle")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(author.name)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
if let url = author.url, let parsed = URL(string: url) {
|
||||||
|
Link(parsed.host ?? url, destination: parsed)
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectFilesSection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(title: "New project directory", subtitle: plan.projectDir) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(plan.projectFiles, id: \.destinationPath) { copy in
|
||||||
|
fileRow(label: copy.destinationPath, systemImage: "doc.text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skillsSection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(
|
||||||
|
title: "Skills (namespaced, safe to remove later)",
|
||||||
|
subtitle: plan.skillsNamespaceDir
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(plan.skillsFiles, id: \.destinationPath) { copy in
|
||||||
|
fileRow(label: copy.destinationPath, systemImage: "puzzlepiece")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(plan.cronJobs, id: \.name) { job in
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(job.name).font(.callout.monospaced())
|
||||||
|
Text("schedule: \(job.schedule)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func memorySection(plan: TemplateInstallPlan) -> some View {
|
||||||
|
section(title: "Memory appendix", subtitle: plan.memoryPath) {
|
||||||
|
ScrollView {
|
||||||
|
Text(plan.memoryAppendix ?? "")
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 160)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
Group {
|
||||||
|
// The body is preloaded in the VM off MainActor when inspection
|
||||||
|
// completes — no sync file I/O during View body evaluation.
|
||||||
|
if let readme = viewModel.readmeBody {
|
||||||
|
section(title: "README", subtitle: nil) {
|
||||||
|
ScrollView {
|
||||||
|
Text(readme)
|
||||||
|
.font(.callout)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section<Content: View>(title: String, subtitle: String?, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.headline)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileRow(label: String, systemImage: String) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func successView(entry: ProjectEntry) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Installed \(entry.name)")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Text(entry.path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Open Project") {
|
||||||
|
onCompleted(entry)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failureView(message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Install Failed").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func chooseParentDirectory() {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.canChooseDirectories = true
|
||||||
|
panel.canChooseFiles = false
|
||||||
|
panel.allowsMultipleSelection = false
|
||||||
|
panel.prompt = String(localized: "Choose Parent Folder")
|
||||||
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
viewModel.pickParentDirectory(url.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Preview-and-confirm sheet for uninstalling a template-installed
|
||||||
|
/// project. Symmetric with the install sheet: lists every file, cron
|
||||||
|
/// job, and memory block that will be removed BEFORE anything happens.
|
||||||
|
struct TemplateUninstallSheet: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State var viewModel: TemplateUninstallerViewModel
|
||||||
|
/// Called on success with the project that was removed. Parent uses
|
||||||
|
/// this to refresh its projects list and clear any selection.
|
||||||
|
let onCompleted: (ProjectEntry) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
switch viewModel.stage {
|
||||||
|
case .idle:
|
||||||
|
idleView
|
||||||
|
case .loading:
|
||||||
|
progress("Reading template.lock.json…")
|
||||||
|
case .planned:
|
||||||
|
if let plan = viewModel.plan {
|
||||||
|
plannedView(plan: plan)
|
||||||
|
} else {
|
||||||
|
progress("Preparing…")
|
||||||
|
}
|
||||||
|
case .uninstalling:
|
||||||
|
progress("Removing…")
|
||||||
|
case .succeeded(let removed):
|
||||||
|
successView(removed: removed)
|
||||||
|
case .failed(let message):
|
||||||
|
failureView(message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 620, minHeight: 480)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stages
|
||||||
|
|
||||||
|
private var idleView: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("No template loaded.")
|
||||||
|
.font(.headline)
|
||||||
|
Button("Close") { dismiss() }
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func progress(_ label: LocalizedStringKey) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func plannedView(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header(plan: plan)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
projectFilesSection(plan: plan)
|
||||||
|
if plan.skillsNamespaceDir != nil {
|
||||||
|
skillsSection(plan: plan)
|
||||||
|
}
|
||||||
|
cronSection(plan: plan)
|
||||||
|
memorySection(plan: plan)
|
||||||
|
registrySection(plan: plan)
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
Spacer()
|
||||||
|
Text("\(plan.totalRemoveCount) changes")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Remove") { viewModel.confirmUninstall() }
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func header(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("Remove “\(plan.lock.templateName)”").font(.title2.bold())
|
||||||
|
Text("v\(plan.lock.templateVersion)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Text(plan.lock.templateId)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text("Installed \(plan.lock.installedAt)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func projectFilesSection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(title: "Project directory", subtitle: plan.project.path) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(plan.projectFilesToRemove, id: \.self) { path in
|
||||||
|
fileRow(
|
||||||
|
label: path,
|
||||||
|
systemImage: "minus.circle",
|
||||||
|
color: .red,
|
||||||
|
tag: "remove"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ForEach(plan.projectFilesAlreadyGone, id: \.self) { path in
|
||||||
|
fileRow(
|
||||||
|
label: path,
|
||||||
|
systemImage: "questionmark.circle",
|
||||||
|
color: .secondary,
|
||||||
|
tag: "already gone"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ForEach(plan.extraProjectEntries, id: \.self) { path in
|
||||||
|
fileRow(
|
||||||
|
label: path,
|
||||||
|
systemImage: "lock.shield",
|
||||||
|
color: .green,
|
||||||
|
tag: "keep (not installed by template)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if plan.projectDirBecomesEmpty {
|
||||||
|
Text("Project directory will also be removed (nothing user-owned left inside).")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
} else if !plan.extraProjectEntries.isEmpty {
|
||||||
|
Text("Project directory stays — it still holds files you created after install.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func skillsSection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(
|
||||||
|
title: "Skills",
|
||||||
|
subtitle: plan.skillsNamespaceDir
|
||||||
|
) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Remove the entire namespace dir recursively")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cronSection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(
|
||||||
|
title: "Cron jobs",
|
||||||
|
subtitle: plan.cronJobsToRemove.isEmpty && plan.cronJobsAlreadyGone.isEmpty
|
||||||
|
? "none"
|
||||||
|
: nil
|
||||||
|
) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
ForEach(plan.cronJobsToRemove, id: \.id) { job in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(job.name).font(.callout.monospaced())
|
||||||
|
Text(job.id)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(plan.cronJobsAlreadyGone, id: \.self) { name in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "questionmark.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.font(.caption)
|
||||||
|
Text("\(name) — already gone")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func memorySection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
if plan.memoryBlockPresent {
|
||||||
|
section(title: "Memory block", subtitle: plan.memoryPath) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Strip the template's begin/end block, preserve everything else in MEMORY.md")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if plan.lock.memoryBlockId != nil {
|
||||||
|
section(title: "Memory block", subtitle: nil) {
|
||||||
|
Text("A memory block was recorded in the lock but is no longer present in MEMORY.md — skipping.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func registrySection(plan: TemplateUninstallPlan) -> some View {
|
||||||
|
section(title: "Projects registry", subtitle: nil) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.caption)
|
||||||
|
Text("Remove \"\(plan.project.name)\" from Scarf's project list")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func section<Content: View>(
|
||||||
|
title: LocalizedStringKey,
|
||||||
|
subtitle: String?,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.headline)
|
||||||
|
if let subtitle {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileRow(label: String, systemImage: String, color: Color, tag: LocalizedStringKey) -> some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.font(.caption)
|
||||||
|
Text(label)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
Spacer()
|
||||||
|
Text(tag)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func successView(removed: ProjectEntry) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
Text("Removed \(removed.name)")
|
||||||
|
.font(.title2.bold())
|
||||||
|
Button("Done") {
|
||||||
|
onCompleted(removed)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failureView(message: String) -> some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Uninstall Failed").font(.title2.bold())
|
||||||
|
Text(message)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
Button("Close") {
|
||||||
|
viewModel.cancel()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,5 +38,56 @@
|
|||||||
<integer>86400</integer>
|
<integer>86400</integer>
|
||||||
<key>SUEnableInstallerLauncherService</key>
|
<key>SUEnableInstallerLauncherService</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>com.scarf.url</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>scarf</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>UTExportedTypeDeclarations</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>UTTypeIdentifier</key>
|
||||||
|
<string>com.scarf.template</string>
|
||||||
|
<key>UTTypeDescription</key>
|
||||||
|
<string>Scarf Project Template</string>
|
||||||
|
<key>UTTypeConformsTo</key>
|
||||||
|
<array>
|
||||||
|
<string>public.zip-archive</string>
|
||||||
|
<string>public.data</string>
|
||||||
|
</array>
|
||||||
|
<key>UTTypeTagSpecification</key>
|
||||||
|
<dict>
|
||||||
|
<key>public.filename-extension</key>
|
||||||
|
<array>
|
||||||
|
<string>scarftemplate</string>
|
||||||
|
</array>
|
||||||
|
<key>public.mime-type</key>
|
||||||
|
<array>
|
||||||
|
<string>application/vnd.scarf.template+zip</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
<key>CFBundleDocumentTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeName</key>
|
||||||
|
<string>Scarf Project Template</string>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>LSHandlerRank</key>
|
||||||
|
<string>Owner</string>
|
||||||
|
<key>LSItemContentTypes</key>
|
||||||
|
<array>
|
||||||
|
<string>com.scarf.template</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
{
|
{
|
||||||
"sourceLanguage" : "en",
|
"sourceLanguage" : "en",
|
||||||
"strings" : {
|
"strings" : {
|
||||||
|
"CFBundleDisplayName" : {
|
||||||
|
"comment" : "Bundle display name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "Scarf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"CFBundleName" : {
|
||||||
|
"comment" : "Bundle name",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "scarf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NSHumanReadableCopyright" : {
|
||||||
|
"comment" : "Copyright (human-readable)",
|
||||||
|
"extractionState" : "extracted_with_value",
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"NSMicrophoneUsageDescription" : {
|
"NSMicrophoneUsageDescription" : {
|
||||||
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
|
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
@@ -48,6 +84,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Scarf Project Template" : {
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.0"
|
"version" : "1.0"
|
||||||
|
|||||||
+3371
-2970
File diff suppressed because it is too large
Load Diff
@@ -59,5 +59,6 @@ struct SidebarView: View {
|
|||||||
}
|
}
|
||||||
.listStyle(.sidebar)
|
.listStyle(.sidebar)
|
||||||
.navigationTitle("Scarf")
|
.navigationTitle("Scarf")
|
||||||
|
.splitViewAutosaveName("ScarfMainSidebar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Makes the enclosing `NSSplitView` remember its divider positions across
|
||||||
|
/// app launches. `NavigationSplitView` is backed by `NSSplitViewController`,
|
||||||
|
/// whose split view honours `autosaveName` — AppKit writes the divider
|
||||||
|
/// offsets to `UserDefaults` on drag and restores them on the next launch.
|
||||||
|
///
|
||||||
|
/// Usage: attach `.splitViewAutosaveName("…")` to a child of the split view
|
||||||
|
/// (the sidebar is a good choice). The modifier installs an invisible helper
|
||||||
|
/// that walks up the view hierarchy on first layout, finds the `NSSplitView`,
|
||||||
|
/// and assigns its autosave name. Subsequent launches restore the divider
|
||||||
|
/// positions before the window appears.
|
||||||
|
///
|
||||||
|
/// The name is also used to key the entry in `UserDefaults` (AppKit stores
|
||||||
|
/// it as `NSSplitView Subview Frames <name>`), so changing the name resets
|
||||||
|
/// the remembered width. Pick a stable string and leave it alone.
|
||||||
|
struct SplitViewAutosaveFinder: NSViewRepresentable {
|
||||||
|
let autosaveName: String
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> NSView {
|
||||||
|
let view = NSView()
|
||||||
|
// Defer the hierarchy walk until after SwiftUI has attached this
|
||||||
|
// view to its host window — at makeNSView time the view has no
|
||||||
|
// superview yet, so we can't find the split view above us.
|
||||||
|
DispatchQueue.main.async { [weak view] in
|
||||||
|
guard let view else { return }
|
||||||
|
SplitViewAutosaveFinder.apply(autosaveName, startingFrom: view)
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||||
|
|
||||||
|
private static func apply(_ name: String, startingFrom view: NSView) {
|
||||||
|
var current: NSView? = view
|
||||||
|
while let node = current {
|
||||||
|
if let split = node as? NSSplitView {
|
||||||
|
// Only set once — reassigning clobbers AppKit's restore path.
|
||||||
|
if split.autosaveName != NSSplitView.AutosaveName(name) {
|
||||||
|
split.autosaveName = NSSplitView.AutosaveName(name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current = node.superview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Persist the enclosing `NavigationSplitView` / `NSSplitView` divider
|
||||||
|
/// positions to `UserDefaults` under `autosaveName`. Attach to any child
|
||||||
|
/// of the split view (the sidebar works well).
|
||||||
|
func splitViewAutosaveName(_ autosaveName: String) -> some View {
|
||||||
|
background(SplitViewAutosaveFinder(autosaveName: autosaveName))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,13 +57,33 @@ struct ScarfApp: App {
|
|||||||
// covers the case where the user added a server in
|
// covers the case where the user added a server in
|
||||||
// another window since this one last opened.
|
// another window since this one last opened.
|
||||||
.onAppear { liveRegistry.rebuild() }
|
.onAppear { liveRegistry.rebuild() }
|
||||||
|
// scarf://install?url=… deep-link handler. Stages the
|
||||||
|
// URL on the process-wide router; ProjectsView picks it
|
||||||
|
// up and presents the install sheet. Activating the
|
||||||
|
// app here ensures a cold launch from a browser click
|
||||||
|
// surfaces the sheet without the user having to click
|
||||||
|
// into Scarf first.
|
||||||
|
.onOpenURL { url in
|
||||||
|
TemplateURLRouter.shared.handle(url)
|
||||||
|
NSApplication.shared.activate()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// MissingServerView is a dead-end "server was removed" pane
|
||||||
|
// with no ProjectsView — so no observer of the router's
|
||||||
|
// pendingInstallURL exists in this window. Routing a
|
||||||
|
// scarf://install URL here would silently drop it. Leave
|
||||||
|
// onOpenURL off this branch; ContextBoundRoot windows in
|
||||||
|
// the same app instance will still handle it.
|
||||||
MissingServerView(removedServerID: serverID)
|
MissingServerView(removedServerID: serverID)
|
||||||
.environment(registry)
|
.environment(registry)
|
||||||
.environment(updater)
|
.environment(updater)
|
||||||
}
|
}
|
||||||
} defaultValue: {
|
} defaultValue: {
|
||||||
ServerContext.local.id
|
// Honour the user's "open on launch" choice from the Manage
|
||||||
|
// Servers popover. Falls back to Local when no entry is flagged
|
||||||
|
// (the default behaviour for fresh installs) or when the
|
||||||
|
// flagged entry was removed while the app was closed.
|
||||||
|
registry.defaultServerID
|
||||||
}
|
}
|
||||||
.defaultSize(width: 1100, height: 700)
|
.defaultSize(width: 1100, height: 700)
|
||||||
.commands {
|
.commands {
|
||||||
|
|||||||
@@ -0,0 +1,927 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import scarf
|
||||||
|
|
||||||
|
/// Exercises the service's ability to unpack, parse, and validate bundles.
|
||||||
|
/// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so
|
||||||
|
/// these don't need write access to ~/.hermes.
|
||||||
|
@Suite struct ProjectTemplateServiceTests {
|
||||||
|
|
||||||
|
@Test func manifestSlugSanitizesPunctuation() {
|
||||||
|
let manifest = Self.sampleManifest(id: "alan@w/focus dashboard!")
|
||||||
|
#expect(manifest.slug == "alan-w-focus-dashboard")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func manifestSlugFallsBackToPlaceholder() {
|
||||||
|
let manifest = Self.sampleManifest(id: "////")
|
||||||
|
#expect(manifest.slug == "template")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inspectRejectsMissingManifest() throws {
|
||||||
|
let dir = try Self.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||||
|
|
||||||
|
// A zip with no template.json
|
||||||
|
let bundle = try Self.makeBundle(dir: dir, files: [
|
||||||
|
"README.md": "hi",
|
||||||
|
"AGENTS.md": "hi",
|
||||||
|
"dashboard.json": "{}"
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try service.inspect(zipPath: bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inspectRejectsMissingAgentsMd() throws {
|
||||||
|
let dir = try Self.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||||
|
|
||||||
|
let bundle = try Self.makeBundle(dir: dir, files: [
|
||||||
|
"README.md": "# Readme",
|
||||||
|
"dashboard.json": Self.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try service.inspect(zipPath: bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inspectAcceptsMinimalValidBundle() throws {
|
||||||
|
let dir = try Self.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||||
|
|
||||||
|
let bundle = try Self.makeBundle(dir: dir, files: [
|
||||||
|
"README.md": "# Readme",
|
||||||
|
"AGENTS.md": "# Agents",
|
||||||
|
"dashboard.json": Self.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
|
||||||
|
#expect(inspection.manifest.id == "test/example")
|
||||||
|
#expect(inspection.manifest.slug == "test-example")
|
||||||
|
#expect(inspection.cronJobs.isEmpty)
|
||||||
|
#expect(inspection.files.contains("AGENTS.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inspectRejectsContentClaimMismatch() throws {
|
||||||
|
let dir = try Self.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||||
|
|
||||||
|
// Claim cron: 2 but ship no cron dir → service must reject.
|
||||||
|
let manifest = Self.sampleManifest(cron: 2)
|
||||||
|
let manifestJSON = try JSONEncoder().encode(manifest)
|
||||||
|
let manifestString = String(data: manifestJSON, encoding: .utf8)!
|
||||||
|
|
||||||
|
let bundle = try Self.makeBundle(dir: dir, files: [
|
||||||
|
"README.md": "# Readme",
|
||||||
|
"AGENTS.md": "# Agents",
|
||||||
|
"dashboard.json": Self.sampleDashboardJSON,
|
||||||
|
"template.json": manifestString
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try service.inspect(zipPath: bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
static let sampleDashboardJSON = """
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "Example",
|
||||||
|
"description": "test",
|
||||||
|
"sections": []
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
static func sampleManifest(
|
||||||
|
id: String = "test/example",
|
||||||
|
cron: Int? = nil,
|
||||||
|
skills: [String]? = nil,
|
||||||
|
instructions: [String]? = nil,
|
||||||
|
configFieldCount: Int? = nil,
|
||||||
|
configSchema: TemplateConfigSchema? = nil
|
||||||
|
) -> ProjectTemplateManifest {
|
||||||
|
// schemaVersion auto-bumps to 2 when a schema is present so tests
|
||||||
|
// that exercise the schema path mirror real manifest behaviour.
|
||||||
|
let version = (configSchema != nil) ? 2 : 1
|
||||||
|
return ProjectTemplateManifest(
|
||||||
|
schemaVersion: version,
|
||||||
|
id: id,
|
||||||
|
name: "Example",
|
||||||
|
version: "1.0.0",
|
||||||
|
minScarfVersion: nil,
|
||||||
|
minHermesVersion: nil,
|
||||||
|
author: TemplateAuthor(name: "Tester", url: nil),
|
||||||
|
description: "Test template",
|
||||||
|
category: nil,
|
||||||
|
tags: nil,
|
||||||
|
icon: nil,
|
||||||
|
screenshots: nil,
|
||||||
|
contents: TemplateContents(
|
||||||
|
dashboard: true,
|
||||||
|
agentsMd: true,
|
||||||
|
instructions: instructions,
|
||||||
|
skills: skills,
|
||||||
|
cron: cron,
|
||||||
|
memory: nil,
|
||||||
|
config: configFieldCount ?? configSchema?.fields.count
|
||||||
|
),
|
||||||
|
config: configSchema
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeTempDir() throws -> String {
|
||||||
|
let dir = NSTemporaryDirectory() + "scarf-template-test-" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write files to a staging dir, then zip them into `<dir>/bundle.scarftemplate`
|
||||||
|
/// and return its path. When `includeManifest` is true the caller doesn't
|
||||||
|
/// need to provide `template.json` — we synthesize a valid one.
|
||||||
|
static func makeBundle(
|
||||||
|
dir: String,
|
||||||
|
files: [String: String],
|
||||||
|
includeManifest: Bool = true
|
||||||
|
) throws -> String {
|
||||||
|
let staging = dir + "/staging"
|
||||||
|
try FileManager.default.createDirectory(atPath: staging, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
for (relativePath, content) in files {
|
||||||
|
let full = staging + "/" + relativePath
|
||||||
|
let parent = (full as NSString).deletingLastPathComponent
|
||||||
|
if !FileManager.default.fileExists(atPath: parent) {
|
||||||
|
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
try content.data(using: .utf8)!.write(to: URL(fileURLWithPath: full))
|
||||||
|
}
|
||||||
|
if includeManifest {
|
||||||
|
let manifest = sampleManifest()
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
let data = try encoder.encode(manifest)
|
||||||
|
try data.write(to: URL(fileURLWithPath: staging + "/template.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let bundlePath = dir + "/bundle.scarftemplate"
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||||
|
process.currentDirectoryURL = URL(fileURLWithPath: staging)
|
||||||
|
process.arguments = ["-qq", "-r", bundlePath, "."]
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
#expect(process.terminationStatus == 0)
|
||||||
|
return bundlePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL-router has no filesystem side effects — safe to unit-test directly.
|
||||||
|
@Suite struct TemplateURLRouterTests {
|
||||||
|
|
||||||
|
@Test @MainActor func refusesNonScarfScheme() {
|
||||||
|
let router = TemplateURLRouter.shared
|
||||||
|
router.pendingInstallURL = nil
|
||||||
|
let ok = router.handle(URL(string: "https://example.com/foo")!)
|
||||||
|
#expect(ok == false)
|
||||||
|
#expect(router.pendingInstallURL == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func refusesUnknownHost() {
|
||||||
|
let router = TemplateURLRouter.shared
|
||||||
|
router.pendingInstallURL = nil
|
||||||
|
let ok = router.handle(URL(string: "scarf://bogus?url=https://example.com/x.scarftemplate")!)
|
||||||
|
#expect(ok == false)
|
||||||
|
#expect(router.pendingInstallURL == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func refusesNonHttpsPayload() {
|
||||||
|
let router = TemplateURLRouter.shared
|
||||||
|
router.pendingInstallURL = nil
|
||||||
|
let ok = router.handle(URL(string: "scarf://install?url=file:///etc/passwd")!)
|
||||||
|
#expect(ok == false)
|
||||||
|
#expect(router.pendingInstallURL == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func acceptsFileURLWithScarftemplateExtension() {
|
||||||
|
let router = TemplateURLRouter.shared
|
||||||
|
router.pendingInstallURL = nil
|
||||||
|
let path = "/tmp/example.scarftemplate"
|
||||||
|
let ok = router.handle(URL(fileURLWithPath: path))
|
||||||
|
#expect(ok)
|
||||||
|
#expect(router.pendingInstallURL?.isFileURL == true)
|
||||||
|
#expect(router.pendingInstallURL?.path == path)
|
||||||
|
router.consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func refusesFileURLWithOtherExtension() {
|
||||||
|
let router = TemplateURLRouter.shared
|
||||||
|
router.pendingInstallURL = nil
|
||||||
|
let ok = router.handle(URL(fileURLWithPath: "/tmp/somefile.zip"))
|
||||||
|
#expect(ok == false)
|
||||||
|
#expect(router.pendingInstallURL == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor func acceptsHttpsInstallUrl() {
|
||||||
|
let router = TemplateURLRouter.shared
|
||||||
|
router.pendingInstallURL = nil
|
||||||
|
let target = "https://example.com/foo.scarftemplate"
|
||||||
|
let ok = router.handle(URL(string: "scarf://install?url=\(target)")!)
|
||||||
|
#expect(ok)
|
||||||
|
#expect(router.pendingInstallURL?.absoluteString == target)
|
||||||
|
router.consume()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end install test against a minimal bundle (dashboard + README +
|
||||||
|
/// AGENTS.md, no skills/cron/memory). Exercises the full install path
|
||||||
|
/// through `preflight → createProjectFiles → registerProject →
|
||||||
|
/// writeLockFile`. We avoid touching user state by:
|
||||||
|
/// 1. Picking a temp `projectDir` under `NSTemporaryDirectory()`.
|
||||||
|
/// 2. Snapshotting and restoring `~/.hermes/scarf/projects.json` around
|
||||||
|
/// each test so the registry write is reversible.
|
||||||
|
/// Skills/cron/memory paths aren't touched because the test bundles claim
|
||||||
|
/// none. That's the intentional v1 coverage: the project-dir side effects
|
||||||
|
/// are exhaustively tested; global-state side effects (skills namespace,
|
||||||
|
/// cron CLI, memory append) are covered by manual verification per the
|
||||||
|
/// plan's step 7.
|
||||||
|
@Suite(.serialized) struct ProjectTemplateInstallerTests {
|
||||||
|
|
||||||
|
@Test func installsMinimalBundleAndWritesLockFile() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"README.md": "# Minimal",
|
||||||
|
"AGENTS.md": "# Agent notes",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md"))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md"))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/dashboard.json"))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json"))
|
||||||
|
#expect(entry.path == plan.projectDir)
|
||||||
|
|
||||||
|
let lockData = try Data(contentsOf: URL(fileURLWithPath: plan.projectDir + "/.scarf/template.lock.json"))
|
||||||
|
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||||
|
#expect(lock.templateId == inspection.manifest.id)
|
||||||
|
#expect(lock.templateVersion == inspection.manifest.version)
|
||||||
|
#expect(lock.projectFiles.contains(plan.projectDir + "/AGENTS.md"))
|
||||||
|
#expect(lock.cronJobNames.isEmpty)
|
||||||
|
#expect(lock.memoryBlockId == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preflightRejectsExistingProjectDir() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"README.md": "# Minimal",
|
||||||
|
"AGENTS.md": "# Agent notes",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
// Simulate a concurrent creation between buildPlan and install.
|
||||||
|
try FileManager.default.createDirectory(atPath: plan.projectDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try installer.install(plan: plan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func buildPlanRefusesDuplicateProjectDir() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"README.md": "# Minimal",
|
||||||
|
"AGENTS.md": "# Agent notes",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
|
||||||
|
// Pre-create the slugged project dir so buildPlan's collision check
|
||||||
|
// fires before we get to install.
|
||||||
|
let slugDir = parentDir + "/" + inspection.manifest.slug
|
||||||
|
try FileManager.default.createDirectory(atPath: slugDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry snapshot helpers
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// — restore by deleting whatever got created.
|
||||||
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
if let snapshot {
|
||||||
|
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end install + uninstall test: install a minimal bundle, uninstall
|
||||||
|
/// 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
|
||||||
|
/// to bundles with no skills/cron/memory so no global state is touched.
|
||||||
|
@Suite(.serialized) struct ProjectTemplateUninstallerTests {
|
||||||
|
|
||||||
|
@Test func roundTripsInstallThenUninstall() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"README.md": "# Minimal",
|
||||||
|
"AGENTS.md": "# Agent notes",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
|
||||||
|
|
||||||
|
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||||
|
#expect(uninstaller.isTemplateInstalled(project: entry))
|
||||||
|
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||||||
|
#expect(uninstallPlan.projectFilesToRemove.count == 4) // README, AGENTS, dashboard.json, lock
|
||||||
|
#expect(uninstallPlan.extraProjectEntries.isEmpty)
|
||||||
|
#expect(uninstallPlan.projectDirBecomesEmpty)
|
||||||
|
#expect(uninstallPlan.skillsNamespaceDir == nil)
|
||||||
|
#expect(uninstallPlan.cronJobsToRemove.isEmpty)
|
||||||
|
#expect(uninstallPlan.memoryBlockPresent == false)
|
||||||
|
|
||||||
|
try uninstaller.uninstall(plan: uninstallPlan)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir) == false)
|
||||||
|
// Registry entry gone — length matches pre-install snapshot.
|
||||||
|
let service2 = ProjectDashboardService(context: .local)
|
||||||
|
let registryAfter = service2.loadRegistry()
|
||||||
|
#expect(registryAfter.projects.contains(where: { $0.path == entry.path }) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservesUserAddedFiles() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"README.md": "# Minimal",
|
||||||
|
"AGENTS.md": "# Agent notes",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
])
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
|
||||||
|
// Simulate the user / agent creating files post-install.
|
||||||
|
let userFile = plan.projectDir + "/sites.txt"
|
||||||
|
try "https://example.com\n".data(using: .utf8)!
|
||||||
|
.write(to: URL(fileURLWithPath: userFile))
|
||||||
|
|
||||||
|
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||||
|
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||||||
|
#expect(uninstallPlan.extraProjectEntries.contains(userFile))
|
||||||
|
#expect(uninstallPlan.projectDirBecomesEmpty == false)
|
||||||
|
|
||||||
|
try uninstaller.uninstall(plan: uninstallPlan)
|
||||||
|
|
||||||
|
// Project dir should still exist because sites.txt is there.
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: userFile))
|
||||||
|
// Lock-tracked files are gone.
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md") == false)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md") == false)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loadUninstallPlanRejectsProjectWithoutLock() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
try FileManager.default.createDirectory(atPath: scratch + "/bare", withIntermediateDirectories: true)
|
||||||
|
let entry = ProjectEntry(name: "Bare", path: scratch + "/bare")
|
||||||
|
|
||||||
|
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||||
|
#expect(uninstaller.isTemplateInstalled(project: entry) == false)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try uninstaller.loadUninstallPlan(for: entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry snapshot helpers (dup'd intentionally from
|
||||||
|
// ProjectTemplateInstallerTests — small helper, not worth a shared
|
||||||
|
// fixture file for one more suite).
|
||||||
|
|
||||||
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
if let snapshot {
|
||||||
|
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end tests for manifest schemaVersion 2 (template configuration).
|
||||||
|
/// Exercises the full cycle: inspect → buildPlan → install → uninstall
|
||||||
|
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
||||||
|
/// service suffix so no leftover login-Keychain items remain after the
|
||||||
|
/// test — every secret we write is deleted on teardown.
|
||||||
|
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
|
||||||
|
|
||||||
|
/// Minimal schemaful manifest with one non-secret field + one
|
||||||
|
/// secret field. Written into the synthesized `.scarftemplate`
|
||||||
|
/// bundle for the round-trip tests.
|
||||||
|
static func makeSchemafulManifest() -> ProjectTemplateManifest {
|
||||||
|
ProjectTemplateServiceTests.sampleManifest(
|
||||||
|
id: "tester/configured",
|
||||||
|
configSchema: TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "site_url", type: .string, label: "Site URL",
|
||||||
|
description: "where to ping", 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),
|
||||||
|
.init(key: "api_token", type: .secret, label: "API Token",
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func inspectAcceptsSchemaV2Bundle() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestData = try JSONEncoder().encode(manifest)
|
||||||
|
let manifestString = String(data: manifestData, encoding: .utf8)!
|
||||||
|
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestString,
|
||||||
|
"README.md": "# r",
|
||||||
|
"AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
|
||||||
|
#expect(inspection.manifest.schemaVersion == 2)
|
||||||
|
#expect(inspection.manifest.config?.fields.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func buildPlanSurfacesSchemaAndQueuesConfigFiles() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||||
|
|
||||||
|
// Schema carried through the plan.
|
||||||
|
#expect(plan.configSchema?.fields.count == 2)
|
||||||
|
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||||
|
// config.json + manifest.json entries in projectFiles.
|
||||||
|
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||||
|
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||||
|
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func verifyClaimsRejectsConfigCountMismatch() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
|
||||||
|
// Hand-build a manifest whose contents.config claim (2) doesn't
|
||||||
|
// match its schema.fields.count (1) — validator should reject.
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "only", type: .string, label: "Only",
|
||||||
|
description: nil, required: false, 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 bogus = ProjectTemplateServiceTests.sampleManifest(
|
||||||
|
id: "tester/mismatch",
|
||||||
|
configFieldCount: 2, // claim lies
|
||||||
|
configSchema: schema // reality is 1
|
||||||
|
)
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(bogus), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
#expect(throws: ProjectTemplateError.self) {
|
||||||
|
try service.inspect(zipPath: bundle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func installWritesConfigJsonAndManifestCache() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
// Isolated Keychain service suffix so the test doesn't touch
|
||||||
|
// the real login Keychain.
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let configService = ProjectConfigService(keychain: keychain)
|
||||||
|
|
||||||
|
// Store secret via the service (VM would do this before install).
|
||||||
|
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||||
|
let secretRef = try configService.storeSecret(
|
||||||
|
templateSlug: manifest.slug,
|
||||||
|
fieldKey: "api_token",
|
||||||
|
project: project,
|
||||||
|
secret: Data("sk-top-secret".utf8)
|
||||||
|
)
|
||||||
|
plan.configValues = [
|
||||||
|
"site_url": .string("https://example.com"),
|
||||||
|
"api_token": secretRef
|
||||||
|
]
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
_ = try installer.install(plan: plan)
|
||||||
|
|
||||||
|
// config.json landed with non-secret values + keychain ref.
|
||||||
|
let configPath = plan.projectDir + "/.scarf/config.json"
|
||||||
|
#expect(FileManager.default.fileExists(atPath: configPath))
|
||||||
|
let configData = try Data(contentsOf: URL(fileURLWithPath: configPath))
|
||||||
|
let configFile = try JSONDecoder().decode(ProjectConfigFile.self, from: configData)
|
||||||
|
#expect(configFile.values["site_url"] == .string("https://example.com"))
|
||||||
|
if case .keychainRef(let uri) = configFile.values["api_token"] {
|
||||||
|
#expect(uri.hasPrefix("keychain://"))
|
||||||
|
} else {
|
||||||
|
Issue.record("api_token should have been stored as keychainRef")
|
||||||
|
}
|
||||||
|
|
||||||
|
// manifest.json cache landed for the post-install editor.
|
||||||
|
let cachePath = plan.projectDir + "/.scarf/manifest.json"
|
||||||
|
#expect(FileManager.default.fileExists(atPath: cachePath))
|
||||||
|
let cachedManifest = try JSONDecoder().decode(
|
||||||
|
ProjectTemplateManifest.self,
|
||||||
|
from: Data(contentsOf: URL(fileURLWithPath: cachePath))
|
||||||
|
)
|
||||||
|
#expect(cachedManifest.config?.fields.count == 2)
|
||||||
|
|
||||||
|
// Lock file records the keychain item so uninstall can clean up.
|
||||||
|
let lockPath = plan.projectDir + "/.scarf/template.lock.json"
|
||||||
|
let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath))
|
||||||
|
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||||
|
#expect(lock.configKeychainItems?.count == 1)
|
||||||
|
#expect(lock.configFields == ["site_url", "api_token"])
|
||||||
|
|
||||||
|
// Clean up the real Keychain entry we created outside the
|
||||||
|
// test-suffixed namespace (storeSecret uses real service name
|
||||||
|
// because the test's config-service wasn't isolated for this
|
||||||
|
// call's secret; we manually delete via our test keychain).
|
||||||
|
if let ref = TemplateKeychainRef.parse(
|
||||||
|
(configFile.values["api_token"].flatMap { v -> String? in
|
||||||
|
if case .keychainRef(let u) = v { return u } else { return nil }
|
||||||
|
}) ?? ""
|
||||||
|
) {
|
||||||
|
try? ProjectConfigKeychain().delete(ref: ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func uninstallDeletesKeychainItemsViaLock() throws {
|
||||||
|
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let parentDir = scratch + "/parent"
|
||||||
|
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
let manifest = Self.makeSchemafulManifest()
|
||||||
|
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||||
|
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||||
|
"template.json": manifestJSON,
|
||||||
|
"README.md": "# r", "AGENTS.md": "# a",
|
||||||
|
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
], includeManifest: false)
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||||
|
|
||||||
|
// Real Keychain — we store, install, then uninstall and verify
|
||||||
|
// the item is gone. Uses the real service name (no test suffix)
|
||||||
|
// because the installer + uninstaller go through their own
|
||||||
|
// ProjectConfigKeychain instances without a suffix.
|
||||||
|
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||||
|
let configService = ProjectConfigService()
|
||||||
|
let secretRef = try configService.storeSecret(
|
||||||
|
templateSlug: manifest.slug,
|
||||||
|
fieldKey: "api_token",
|
||||||
|
project: project,
|
||||||
|
secret: Data("delete-me".utf8)
|
||||||
|
)
|
||||||
|
plan.configValues = [
|
||||||
|
"site_url": .string("https://example.com"),
|
||||||
|
"api_token": secretRef
|
||||||
|
]
|
||||||
|
|
||||||
|
let registryBefore = Self.snapshotRegistry()
|
||||||
|
defer { Self.restoreRegistry(registryBefore) }
|
||||||
|
|
||||||
|
let installer = ProjectTemplateInstaller(context: .local)
|
||||||
|
let entry = try installer.install(plan: plan)
|
||||||
|
|
||||||
|
// Verify the secret is there before uninstall.
|
||||||
|
guard case .keychainRef(let uri) = secretRef,
|
||||||
|
let ref = TemplateKeychainRef.parse(uri) else {
|
||||||
|
Issue.record("expected secret to be a keychainRef")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect((try ProjectConfigKeychain().get(ref: ref)) == Data("delete-me".utf8))
|
||||||
|
|
||||||
|
// Uninstall → secret should be gone.
|
||||||
|
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||||
|
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||||||
|
try uninstaller.uninstall(plan: uninstallPlan)
|
||||||
|
|
||||||
|
#expect((try ProjectConfigKeychain().get(ref: ref)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
||||||
|
|
||||||
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
if let snapshot {
|
||||||
|
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates every `.scarftemplate` shipped under `templates/<author>/<name>/`
|
||||||
|
/// in the repo. A template whose manifest, `contents` claim, or file set is
|
||||||
|
/// out of sync will fail here — so shipped templates can't silently rot.
|
||||||
|
@Suite struct ProjectTemplateExampleTemplateTests {
|
||||||
|
|
||||||
|
@Test func siteStatusCheckerParsesAndPlans() throws {
|
||||||
|
let bundle = try Self.locateExample(author: "awizemann", name: "site-status-checker")
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: bundle)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
|
||||||
|
#expect(inspection.manifest.id == "awizemann/site-status-checker")
|
||||||
|
#expect(inspection.manifest.schemaVersion == 2) // config-enabled
|
||||||
|
#expect(inspection.manifest.contents.dashboard)
|
||||||
|
#expect(inspection.manifest.contents.agentsMd)
|
||||||
|
#expect(inspection.manifest.contents.cron == 1)
|
||||||
|
#expect(inspection.manifest.contents.config == 2)
|
||||||
|
#expect(inspection.cronJobs.count == 1)
|
||||||
|
#expect(inspection.cronJobs.first?.name == "Check site status")
|
||||||
|
#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()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||||
|
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||||
|
#expect(plan.projectDir.hasSuffix("awizemann-site-status-checker"))
|
||||||
|
#expect(plan.skillsFiles.isEmpty)
|
||||||
|
#expect(plan.memoryAppendix == nil)
|
||||||
|
#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
|
||||||
|
// find + remove it later.
|
||||||
|
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
|
||||||
|
|
||||||
|
// Verify the bundled dashboard.json decodes against the same
|
||||||
|
// `ProjectDashboard` struct the app uses at runtime — catches drift
|
||||||
|
// between template-author conventions and the actual renderer
|
||||||
|
// (e.g. a widget type that ProjectsView doesn't know, a
|
||||||
|
// non-number value for a stat, etc.).
|
||||||
|
let dashboardPath = inspection.unpackedDir + "/dashboard.json"
|
||||||
|
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
||||||
|
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
||||||
|
#expect(dashboard.title == "Site Status")
|
||||||
|
#expect(dashboard.sections.count == 3)
|
||||||
|
|
||||||
|
// First section should have three stat widgets that the cron job
|
||||||
|
// updates by value. Assert titles + types so the AGENTS.md contract
|
||||||
|
// can't drift from the actual dashboard.
|
||||||
|
let statsSection = dashboard.sections[0]
|
||||||
|
#expect(statsSection.title == "Current Status")
|
||||||
|
let statTitles = statsSection.widgets.filter { $0.type == "stat" }.map(\.title)
|
||||||
|
#expect(statTitles.contains("Sites Up"))
|
||||||
|
#expect(statTitles.contains("Sites Down"))
|
||||||
|
#expect(statTitles.contains("Last Checked"))
|
||||||
|
|
||||||
|
// Cron prompt references .scarf/config.json (where values.sites
|
||||||
|
// + values.timeout_seconds live) and the dashboard/log it writes.
|
||||||
|
// 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 ?? ""
|
||||||
|
#expect(cronPrompt.contains("config.json"))
|
||||||
|
#expect(cronPrompt.contains("values.sites"))
|
||||||
|
#expect(cronPrompt.contains("dashboard.json"))
|
||||||
|
#expect(cronPrompt.contains("status-log.md"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||||||
|
/// differ between `xcodebuild test` (project root) and an Xcode IDE
|
||||||
|
/// run (build-output dir), so we walk up from this source file until
|
||||||
|
/// we find the repo root. Templates live at
|
||||||
|
/// `templates/<author>/<name>/<name>.scarftemplate` per the catalog
|
||||||
|
/// layout (see `templates/README.md`).
|
||||||
|
nonisolated private static func locateExample(author: String, name: String) throws -> String {
|
||||||
|
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
|
||||||
|
for _ in 0..<6 {
|
||||||
|
let candidate = dir.appendingPathComponent("templates/\(author)/\(name)/\(name).scarftemplate")
|
||||||
|
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate.path
|
||||||
|
}
|
||||||
|
dir = dir.deletingLastPathComponent()
|
||||||
|
}
|
||||||
|
throw ProjectTemplateError.requiredFileMissing("templates/\(author)/\(name)/\(name).scarftemplate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Round-trips a real project structure through the exporter and back into
|
||||||
|
/// the service. Does NOT run the installer (which would write to
|
||||||
|
/// ~/.hermes) — it verifies the produced bundle is valid, and stops there.
|
||||||
|
@Suite struct ProjectTemplateExportTests {
|
||||||
|
|
||||||
|
@Test func roundTripsMinimalProject() throws {
|
||||||
|
let fakeProject = NSTemporaryDirectory() + "scarf-project-" + UUID().uuidString
|
||||||
|
try FileManager.default.createDirectory(atPath: fakeProject + "/.scarf", withIntermediateDirectories: true)
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: fakeProject) }
|
||||||
|
|
||||||
|
try ProjectTemplateServiceTests.sampleDashboardJSON
|
||||||
|
.data(using: .utf8)!
|
||||||
|
.write(to: URL(fileURLWithPath: fakeProject + "/.scarf/dashboard.json"))
|
||||||
|
try "# Test project".data(using: .utf8)!
|
||||||
|
.write(to: URL(fileURLWithPath: fakeProject + "/README.md"))
|
||||||
|
try "# Agent notes".data(using: .utf8)!
|
||||||
|
.write(to: URL(fileURLWithPath: fakeProject + "/AGENTS.md"))
|
||||||
|
|
||||||
|
let entry = ProjectEntry(name: "Round Trip", path: fakeProject)
|
||||||
|
let exporter = ProjectTemplateExporter(context: .local)
|
||||||
|
let outputDir = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
defer { try? FileManager.default.removeItem(atPath: outputDir) }
|
||||||
|
let outputPath = outputDir + "/rt.scarftemplate"
|
||||||
|
|
||||||
|
let inputs = ProjectTemplateExporter.ExportInputs(
|
||||||
|
project: entry,
|
||||||
|
templateId: "tester/round-trip",
|
||||||
|
templateName: "Round Trip",
|
||||||
|
templateVersion: "0.1.0",
|
||||||
|
description: "round-trip test",
|
||||||
|
authorName: "Tester",
|
||||||
|
authorUrl: nil,
|
||||||
|
category: nil,
|
||||||
|
tags: [],
|
||||||
|
includeSkillIds: [],
|
||||||
|
includeCronJobIds: [],
|
||||||
|
memoryAppendix: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
try exporter.export(inputs: inputs, outputZipPath: outputPath)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outputPath))
|
||||||
|
|
||||||
|
let service = ProjectTemplateService(context: .local)
|
||||||
|
let inspection = try service.inspect(zipPath: outputPath)
|
||||||
|
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||||
|
#expect(inspection.manifest.id == "tester/round-trip")
|
||||||
|
#expect(inspection.files.contains("dashboard.json"))
|
||||||
|
#expect(inspection.files.contains("README.md"))
|
||||||
|
#expect(inspection.files.contains("AGENTS.md"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
@testable import scarf
|
||||||
|
|
||||||
|
// MARK: - Schema validation
|
||||||
|
|
||||||
|
@Suite struct TemplateConfigSchemaValidationTests {
|
||||||
|
|
||||||
|
@Test func acceptsMinimalValidSchema() throws {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "name", type: .string, label: "Name",
|
||||||
|
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
|
||||||
|
)
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsDuplicateKeys() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "same", type: .string, label: "A", description: nil,
|
||||||
|
required: false, placeholder: nil, defaultValue: nil,
|
||||||
|
options: nil, minLength: nil, maxLength: nil,
|
||||||
|
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||||
|
step: nil, itemType: nil, minItems: nil, maxItems: nil),
|
||||||
|
.init(key: "same", type: .bool, label: "B", description: nil,
|
||||||
|
required: false, 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
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsSecretWithDefault() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "api_key", type: .secret, label: "API Key",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: .string("leaked-by-accident"),
|
||||||
|
options: nil, minLength: nil, maxLength: nil,
|
||||||
|
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||||
|
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsEnumWithoutOptions() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "choice", type: .enum, label: "Choice",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: [],
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsEnumWithDuplicateValues() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "choice", type: .enum, label: "Choice",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil,
|
||||||
|
options: [.init(value: "a", label: "A"),
|
||||||
|
.init(value: "a", label: "Another A")],
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsUnsupportedListItemType() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "items", type: .list, label: "Items",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil,
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: "number", minItems: 1, maxItems: 10)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rejectsEmptyModelPreferred() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [],
|
||||||
|
modelRecommendation: .init(preferred: " ", rationale: nil, alternatives: nil)
|
||||||
|
)
|
||||||
|
#expect(throws: TemplateConfigSchemaError.self) {
|
||||||
|
try ProjectConfigService.validateSchema(schema)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Value validation
|
||||||
|
|
||||||
|
@Suite struct TemplateConfigValueValidationTests {
|
||||||
|
|
||||||
|
@Test func requiredFieldRejectsEmptyString() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "name", type: .string, label: "Name",
|
||||||
|
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 errors = ProjectConfigService.validateValues(
|
||||||
|
["name": .string("")], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
#expect(errors.first?.fieldKey == "name")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func patternRejectsBadInput() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "email", type: .string, label: "Email",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: "^[^@]+@[^@]+$",
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["email": .string("not-an-email")], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func numberRangeEnforced() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "port", type: .number, label: "Port",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: 1024,
|
||||||
|
maxNumber: 65535, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["port": .number(80)], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func enumRejectsUnknownValue() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "mode", type: .enum, label: "Mode",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil,
|
||||||
|
options: [.init(value: "fast", label: "Fast"),
|
||||||
|
.init(value: "slow", label: "Slow")],
|
||||||
|
minLength: nil, maxLength: nil, pattern: nil,
|
||||||
|
minNumber: nil, maxNumber: nil, step: nil,
|
||||||
|
itemType: nil, minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let errors = ProjectConfigService.validateValues(
|
||||||
|
["mode": .string("medium")], against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func listItemBoundsEnforced() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "urls", type: .list, label: "URLs",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: "string",
|
||||||
|
minItems: 1, maxItems: 3)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let tooFew = ProjectConfigService.validateValues(
|
||||||
|
["urls": .list([])], against: schema
|
||||||
|
)
|
||||||
|
let tooMany = ProjectConfigService.validateValues(
|
||||||
|
["urls": .list(["a", "b", "c", "d"])], against: schema
|
||||||
|
)
|
||||||
|
let justRight = ProjectConfigService.validateValues(
|
||||||
|
["urls": .list(["a", "b"])], against: schema
|
||||||
|
)
|
||||||
|
#expect(tooFew.count == 1)
|
||||||
|
#expect(tooMany.count == 1)
|
||||||
|
#expect(justRight.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func secretFieldAcceptsKeychainRef() {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "tok", type: .secret, label: "Token",
|
||||||
|
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 errors = ProjectConfigService.validateValues(
|
||||||
|
["tok": .keychainRef("keychain://test/tok:abc")],
|
||||||
|
against: schema
|
||||||
|
)
|
||||||
|
#expect(errors.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain ref helpers
|
||||||
|
|
||||||
|
@Suite struct TemplateKeychainRefTests {
|
||||||
|
|
||||||
|
@Test func uriRoundTrips() {
|
||||||
|
let ref = TemplateKeychainRef(
|
||||||
|
service: "com.scarf.template.alice-foo",
|
||||||
|
account: "api_key:deadbeef"
|
||||||
|
)
|
||||||
|
#expect(ref.uri == "keychain://com.scarf.template.alice-foo/api_key:deadbeef")
|
||||||
|
let parsed = TemplateKeychainRef.parse(ref.uri)
|
||||||
|
#expect(parsed == ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func parseRejectsMalformedUris() {
|
||||||
|
#expect(TemplateKeychainRef.parse("") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("keychain://") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("keychain:///account-only") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("keychain://service-only") == nil)
|
||||||
|
#expect(TemplateKeychainRef.parse("https://example.com/foo") == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func hashDiffersByProjectPath() {
|
||||||
|
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||||
|
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p2")
|
||||||
|
#expect(a.service == b.service) // same template
|
||||||
|
#expect(a.account != b.account) // different project → different hash suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func hashStableForSamePath() {
|
||||||
|
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||||
|
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||||
|
#expect(a == b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - On-disk config round-trip
|
||||||
|
|
||||||
|
@Suite struct ProjectConfigFileTests {
|
||||||
|
|
||||||
|
@Test func roundTripsNonSecretValues() throws {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: "alice/example",
|
||||||
|
values: [
|
||||||
|
"name": .string("Alice"),
|
||||||
|
"enabled": .bool(true),
|
||||||
|
"count": .number(42),
|
||||||
|
"tags": .list(["a", "b", "c"]),
|
||||||
|
],
|
||||||
|
updatedAt: "2026-04-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
let encoded = try JSONEncoder().encode(file)
|
||||||
|
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||||
|
#expect(decoded.schemaVersion == 2)
|
||||||
|
#expect(decoded.templateId == "alice/example")
|
||||||
|
#expect(decoded.values["name"] == .string("Alice"))
|
||||||
|
#expect(decoded.values["enabled"] == .bool(true))
|
||||||
|
#expect(decoded.values["count"] == .number(42))
|
||||||
|
#expect(decoded.values["tags"] == .list(["a", "b", "c"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func preservesKeychainRefsOnRoundTrip() throws {
|
||||||
|
let file = ProjectConfigFile(
|
||||||
|
schemaVersion: 2,
|
||||||
|
templateId: "alice/example",
|
||||||
|
values: ["tok": .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef")],
|
||||||
|
updatedAt: "2026-04-25T00:00:00Z"
|
||||||
|
)
|
||||||
|
let encoded = try JSONEncoder().encode(file)
|
||||||
|
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||||
|
// Keychain refs must NOT demote to plain strings on round-trip
|
||||||
|
// — otherwise a post-install editor would lose the secret
|
||||||
|
// binding when saving unchanged values.
|
||||||
|
#expect(decoded.values["tok"] == .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ProjectConfigService + Keychain integration
|
||||||
|
|
||||||
|
/// Exercises the full secret-storage path through a real macOS Keychain
|
||||||
|
/// with a test-only service suffix so nothing leaks into the user's
|
||||||
|
/// login Keychain. Every test sets + reads + deletes within a unique
|
||||||
|
/// service name so parallel runs don't collide.
|
||||||
|
@Suite struct ProjectConfigSecretsTests {
|
||||||
|
|
||||||
|
@Test func storeAndResolveSecret() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let service = ProjectConfigService(keychain: keychain)
|
||||||
|
let project = ProjectEntry(name: "Scratch", path: NSTemporaryDirectory() + UUID().uuidString)
|
||||||
|
|
||||||
|
let stored = try service.storeSecret(
|
||||||
|
templateSlug: "alice-example",
|
||||||
|
fieldKey: "api_key",
|
||||||
|
project: project,
|
||||||
|
secret: Data("hunter2".utf8)
|
||||||
|
)
|
||||||
|
|
||||||
|
// What goes into config.json is a keychainRef, not the bytes.
|
||||||
|
guard case .keychainRef(let uri) = stored else {
|
||||||
|
Issue.record("expected keychainRef, got \(stored)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(uri.hasPrefix("keychain://"))
|
||||||
|
|
||||||
|
// Resolve brings the bytes back.
|
||||||
|
let resolved = try service.resolveSecret(ref: stored)
|
||||||
|
#expect(resolved == Data("hunter2".utf8))
|
||||||
|
|
||||||
|
// Clean up so we don't leave a test item in the Keychain.
|
||||||
|
if let ref = TemplateKeychainRef.parse(uri) {
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
#expect((try keychain.get(ref: ref)) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func setOverwritesExistingSecret() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let ref = TemplateKeychainRef(service: "com.scarf.template.overwrite", account: "k:1")
|
||||||
|
try keychain.set(ref: ref, secret: Data("first".utf8))
|
||||||
|
try keychain.set(ref: ref, secret: Data("second".utf8))
|
||||||
|
#expect((try keychain.get(ref: ref)) == Data("second".utf8))
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deleteOfMissingItemSucceeds() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let ref = TemplateKeychainRef(service: "com.scarf.template.absent", account: "never:set")
|
||||||
|
// Deleting a non-existent item is a no-op — must not throw.
|
||||||
|
try keychain.delete(ref: ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func deleteMultipleSecretsClearsAll() throws {
|
||||||
|
let suffix = "tests-" + UUID().uuidString
|
||||||
|
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||||
|
let service = ProjectConfigService(keychain: keychain)
|
||||||
|
|
||||||
|
let refs = (0..<3).map { i in
|
||||||
|
TemplateKeychainRef(service: "com.scarf.template.bulk", account: "k:\(i)")
|
||||||
|
}
|
||||||
|
for ref in refs {
|
||||||
|
try keychain.set(ref: ref, secret: Data("v".utf8))
|
||||||
|
}
|
||||||
|
try service.deleteSecrets(refs: refs)
|
||||||
|
for ref in refs {
|
||||||
|
#expect((try keychain.get(ref: ref)) == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+135
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Scarf templates catalog helper — runs the Python validator, renders the
|
||||||
|
# static site into .gh-pages-worktree/templates/, and (on `publish`)
|
||||||
|
# commits + pushes that subdir on the gh-pages branch.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./scripts/catalog.sh check # validate every template; no output
|
||||||
|
# ./scripts/catalog.sh build # validate + write templates/catalog.json + .gh-pages-worktree/templates/
|
||||||
|
# ./scripts/catalog.sh preview [DIR] # render self-contained preview; DIR defaults to /tmp/scarf-catalog-preview
|
||||||
|
# ./scripts/catalog.sh publish # secret-scan + commit + push gh-pages (templates subdir only)
|
||||||
|
# ./scripts/catalog.sh serve [PORT] # serve .gh-pages-worktree/ on localhost:PORT (default 8000)
|
||||||
|
# ./scripts/catalog.sh --help # this help
|
||||||
|
#
|
||||||
|
# The secret-scan runs BEFORE publish and inspects the generated
|
||||||
|
# .gh-pages-worktree/templates/ tree — same hard-pattern regex as
|
||||||
|
# scripts/wiki.sh so template README/AGENTS content that accidentally
|
||||||
|
# leaks credentials gets blocked before it reaches the public site.
|
||||||
|
#
|
||||||
|
# Bootstrap (one-time): requires a .gh-pages-worktree/ clone of the
|
||||||
|
# gh-pages branch. The release script (scripts/release.sh) creates it on
|
||||||
|
# first use. If it's missing:
|
||||||
|
# git worktree add .gh-pages-worktree gh-pages
|
||||||
|
#
|
||||||
|
# Recovery: if .gh-pages-worktree/ is deleted, re-run the command above.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ---------- config ----------
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
GHPAGES_DIR="$REPO_ROOT/.gh-pages-worktree"
|
||||||
|
CATALOG_SUBDIR="templates"
|
||||||
|
PY="${PYTHON:-python3}"
|
||||||
|
BUILDER="$REPO_ROOT/tools/build-catalog.py"
|
||||||
|
|
||||||
|
# ---------- helpers (same shape as scripts/wiki.sh so a reader doesn't
|
||||||
|
# have to learn two conventions) ----------
|
||||||
|
log() { printf '\033[1;34m==> %s\033[0m\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31m[ERR] %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
need_builder() {
|
||||||
|
[[ -f "$BUILDER" ]] || die "missing $BUILDER"
|
||||||
|
command -v "$PY" >/dev/null 2>&1 || die "python3 not found (set \$PYTHON if needed)"
|
||||||
|
}
|
||||||
|
|
||||||
|
need_ghpages() {
|
||||||
|
[[ -d "$GHPAGES_DIR/.git" ]] || die "no gh-pages worktree at $GHPAGES_DIR
|
||||||
|
Run: git worktree add .gh-pages-worktree gh-pages"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- secret-scan (mirrors scripts/wiki.sh hard-pattern set) ----------
|
||||||
|
hard_regex='(sk-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{30,}|ghs_[A-Za-z0-9]{30,}|ghu_[A-Za-z0-9]{30,}|gho_[A-Za-z0-9]{30,}|ghr_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|-----BEGIN [A-Z ]*PRIVATE KEY-----|BEGIN OPENSSH PRIVATE KEY)'
|
||||||
|
|
||||||
|
scan_hard_ghpages() {
|
||||||
|
# Scan the generated output, NOT the repo source — the validator
|
||||||
|
# already scans bundle contents. This pass catches anything that leaked
|
||||||
|
# through template.json fields or README prose.
|
||||||
|
local hits
|
||||||
|
hits="$(grep -rInE --exclude-dir=.git "$hard_regex" "$GHPAGES_DIR/$CATALOG_SUBDIR" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$hits" ]]; then
|
||||||
|
printf '%s\n' "$hits" >&2
|
||||||
|
die "hard-pattern secret match in rendered site — refusing to publish."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- commands ----------
|
||||||
|
cmd_check() {
|
||||||
|
need_builder
|
||||||
|
"$PY" "$BUILDER" --check --repo "$REPO_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_build() {
|
||||||
|
need_builder
|
||||||
|
"$PY" "$BUILDER" --build --repo "$REPO_ROOT"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_preview() {
|
||||||
|
need_builder
|
||||||
|
local dir="${1:-/tmp/scarf-catalog-preview}"
|
||||||
|
rm -rf "$dir"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
"$PY" "$BUILDER" --preview "$dir" --repo "$REPO_ROOT"
|
||||||
|
log "Preview rendered to $dir"
|
||||||
|
log "Serve with: (cd $dir && python3 -m http.server 8000) then open http://localhost:8000/"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_serve() {
|
||||||
|
need_ghpages
|
||||||
|
local port="${1:-8000}"
|
||||||
|
log "Serving $GHPAGES_DIR on http://localhost:$port/"
|
||||||
|
(cd "$GHPAGES_DIR" && "$PY" -m http.server "$port")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_publish() {
|
||||||
|
need_builder
|
||||||
|
need_ghpages
|
||||||
|
log "Validating"
|
||||||
|
"$PY" "$BUILDER" --check --repo "$REPO_ROOT"
|
||||||
|
log "Building"
|
||||||
|
"$PY" "$BUILDER" --build --repo "$REPO_ROOT"
|
||||||
|
|
||||||
|
log "Secret-scanning rendered site"
|
||||||
|
scan_hard_ghpages
|
||||||
|
|
||||||
|
log "Staging + committing gh-pages"
|
||||||
|
(cd "$GHPAGES_DIR" && git add "$CATALOG_SUBDIR")
|
||||||
|
if (cd "$GHPAGES_DIR" && git diff --cached --quiet); then
|
||||||
|
log "No changes to publish."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local msg
|
||||||
|
msg="catalog: rebuild at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
(cd "$GHPAGES_DIR" && git commit -m "$msg")
|
||||||
|
log "Pushing gh-pages"
|
||||||
|
(cd "$GHPAGES_DIR" && git push origin gh-pages)
|
||||||
|
log "Published."
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_help() {
|
||||||
|
sed -n '1,30p' "$0" | sed -n '/^# Usage/,/^#$/p'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- dispatch ----------
|
||||||
|
sub="${1:-help}"
|
||||||
|
shift || true
|
||||||
|
case "$sub" in
|
||||||
|
check) cmd_check "$@" ;;
|
||||||
|
build) cmd_build "$@" ;;
|
||||||
|
preview) cmd_preview "$@" ;;
|
||||||
|
serve) cmd_serve "$@" ;;
|
||||||
|
publish) cmd_publish "$@" ;;
|
||||||
|
help|--help|-h) cmd_help ;;
|
||||||
|
*) die "unknown command: $sub (try --help)" ;;
|
||||||
|
esac
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Scarf Templates</title>
|
||||||
|
<meta name="description" content="Community catalog of Scarf project templates — pre-configured AI-agent projects with dashboards, cron jobs, and agent instructions.">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="icon" type="image/png" href="assets/icon.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href=".">
|
||||||
|
<img src="assets/icon.png" alt="" width="40" height="40">
|
||||||
|
<span class="brand-name">Scarf Templates</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="https://github.com/awizemann/scarf">GitHub</a>
|
||||||
|
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">Contribute</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Pre-packaged projects for Scarf</h1>
|
||||||
|
<p>
|
||||||
|
Browse {{COUNT}} community template{{COUNT_PLURAL}} — each ships with a
|
||||||
|
ready-to-install Scarf dashboard, a cross-agent <code>AGENTS.md</code>, optional
|
||||||
|
cron jobs and skills. Click a template to see what it does; one click installs
|
||||||
|
it into Scarf.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="catalog">
|
||||||
|
<div class="grid">
|
||||||
|
{{CARDS}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p>
|
||||||
|
Scarf is open source:
|
||||||
|
<a href="https://github.com/awizemann/scarf">github.com/awizemann/scarf</a>.
|
||||||
|
Want to add your own template? See the
|
||||||
|
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">contribution guide</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+441
@@ -0,0 +1,441 @@
|
|||||||
|
/* Scarf Templates — catalog site.
|
||||||
|
* Vanilla CSS, no framework. Matches Scarf's green accent and keeps
|
||||||
|
* decoration minimal — the live dashboard preview is the point. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--fg: #1a1a1a;
|
||||||
|
--fg-muted: #666;
|
||||||
|
--bg: #fafafa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--accent: #2aa876; /* scarf green */
|
||||||
|
--accent-dark: #1f7f5a;
|
||||||
|
--red: #d9534f;
|
||||||
|
--blue: #3498db;
|
||||||
|
--orange: #f0ad4e;
|
||||||
|
--radius: 8px;
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.04);
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #e5e5e5;
|
||||||
|
--fg-muted: #9a9a9a;
|
||||||
|
--bg: #141414;
|
||||||
|
--bg-card: #1e1e1e;
|
||||||
|
--border: #2a2a2a;
|
||||||
|
--accent: #3abf8a;
|
||||||
|
--accent-dark: #2aa876;
|
||||||
|
--shadow: 0 1px 2px rgba(0,0,0,0.3), 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--sans);
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
code, pre {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: rgba(0,0,0,0.05);
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
pre code { background: transparent; padding: 0; }
|
||||||
|
|
||||||
|
a { color: var(--accent-dark); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
h1, h2, h3 { line-height: 1.25; }
|
||||||
|
|
||||||
|
/* ---------- header / footer ---------- */
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.brand:hover { text-decoration: none; }
|
||||||
|
.brand-name { font-weight: 600; font-size: 18px; }
|
||||||
|
.site-nav a {
|
||||||
|
margin-left: 20px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- landing ---------- */
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
padding: 48px 32px 24px;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero h1 { font-size: 32px; margin: 0 0 12px; }
|
||||||
|
.hero p { color: var(--fg-muted); }
|
||||||
|
|
||||||
|
.catalog {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 16px 32px 48px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
color: inherit;
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.06), 0 8px 24px rgba(0,0,0,0.06);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.card h3 { margin: 0 0 6px; font-size: 18px; }
|
||||||
|
.card .desc { color: var(--fg-muted); margin: 0 0 14px; font-size: 14px; }
|
||||||
|
.card .meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.card .author { font-weight: 500; }
|
||||||
|
.card .version { font-family: var(--mono); }
|
||||||
|
.tags { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(42,168,118,0.15);
|
||||||
|
color: var(--accent-dark);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- template detail page ---------- */
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 32px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.detail-header h1 { margin: 0 0 4px; }
|
||||||
|
.detail-header h1 .version {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.detail-header .desc { color: var(--fg-muted); margin: 0 0 12px; }
|
||||||
|
.detail-header .meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.detail-header .id { font-family: var(--mono); }
|
||||||
|
|
||||||
|
.install-actions { display: flex; flex-direction: column; gap: 8px; min-width: 200px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--accent-dark); text-decoration: none; color: white; }
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { border-color: var(--accent); text-decoration: none; color: var(--accent-dark); }
|
||||||
|
|
||||||
|
.detail-dashboard {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.detail-dashboard h2 { margin-top: 0; }
|
||||||
|
.detail-dashboard-note {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-readme h2 { margin-top: 0; }
|
||||||
|
.detail-readme > div {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- config schema panel (v2.3) ---------- */
|
||||||
|
|
||||||
|
.detail-config { margin-bottom: 32px; }
|
||||||
|
.detail-config:empty, .detail-config > div:empty { display: none; }
|
||||||
|
|
||||||
|
.config-schema {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.config-schema-header { margin-top: 0; }
|
||||||
|
.config-schema-desc {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.config-schema-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.config-field-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.config-field-key { font-family: var(--mono); font-size: 13px; }
|
||||||
|
.config-field-type {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0,0,0,0.08);
|
||||||
|
color: var(--fg-muted);
|
||||||
|
}
|
||||||
|
.config-field-required {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--red);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(217,83,79,0.12);
|
||||||
|
}
|
||||||
|
.config-field-body {
|
||||||
|
margin: 0 0 4px 0;
|
||||||
|
padding-left: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.config-field-label {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.config-field-description {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.config-field-constraint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-model-rec {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: rgba(42,168,118,0.08);
|
||||||
|
border: 1px solid rgba(42,168,118,0.2);
|
||||||
|
}
|
||||||
|
.config-model-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.config-model-preferred {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.config-model-rationale {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.config-model-alternatives {
|
||||||
|
color: var(--fg-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- dashboard preview ---------- */
|
||||||
|
|
||||||
|
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
|
||||||
|
.dashboard-desc { color: var(--fg-muted); margin: 0 0 24px; font-size: 14px; }
|
||||||
|
.dashboard-section { margin-bottom: 24px; }
|
||||||
|
.section-title { margin: 0 0 10px; font-size: 14px; font-weight: 600; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
|
||||||
|
.widget-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(var(--cols, 3), 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* stat */
|
||||||
|
.widget-stat { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.widget-stat-top { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.widget-stat-icon { font-size: 14px; color: var(--fg-muted); }
|
||||||
|
.widget-stat-value { font-size: 32px; font-weight: 600; line-height: 1.1; }
|
||||||
|
.widget-stat-subtitle { font-size: 11px; color: var(--fg-muted); }
|
||||||
|
.widget-stat[data-color="green"] .widget-stat-icon { color: var(--accent); }
|
||||||
|
.widget-stat[data-color="red"] .widget-stat-icon { color: var(--red); }
|
||||||
|
.widget-stat[data-color="blue"] .widget-stat-icon { color: var(--blue); }
|
||||||
|
.widget-stat[data-color="orange"] .widget-stat-icon { color: var(--orange); }
|
||||||
|
|
||||||
|
/* progress */
|
||||||
|
.widget-progress-label { font-size: 13px; margin: 6px 0 8px; }
|
||||||
|
.progress-bar { height: 8px; background: rgba(0,0,0,0.05); border-radius: 4px; overflow: hidden; }
|
||||||
|
.progress-fill { height: 100%; background: var(--accent); border-radius: 4px; }
|
||||||
|
|
||||||
|
/* text */
|
||||||
|
.widget-text-body { font-size: 14px; margin-top: 6px; }
|
||||||
|
.widget-text-body h1 { font-size: 20px; margin: 12px 0 8px; }
|
||||||
|
.widget-text-body h2 { font-size: 17px; margin: 10px 0 6px; }
|
||||||
|
.widget-text-body h3 { font-size: 14px; margin: 8px 0 4px; }
|
||||||
|
.widget-text-body p { margin: 8px 0; }
|
||||||
|
.widget-text-body ul, .widget-text-body ol { padding-left: 22px; }
|
||||||
|
|
||||||
|
/* table */
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
||||||
|
.data-table th, .data-table td { padding: 6px 8px; border-bottom: 1px solid var(--border); text-align: left; }
|
||||||
|
.data-table th { font-weight: 500; color: var(--fg-muted); }
|
||||||
|
|
||||||
|
/* list */
|
||||||
|
.widget-list-items { margin: 6px 0 0; padding-left: 18px; font-size: 13px; }
|
||||||
|
.widget-list-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
.widget-list-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.widget-list-status {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0,0,0,0.08);
|
||||||
|
color: var(--fg-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.widget-list-status[data-status="up"] { background: rgba(42,168,118,0.18); color: var(--accent-dark); }
|
||||||
|
.widget-list-status[data-status="down"] { background: rgba(217,83,79,0.18); color: var(--red); }
|
||||||
|
|
||||||
|
/* chart */
|
||||||
|
.widget-chart-svg { width: 100%; height: auto; margin-top: 8px; }
|
||||||
|
.chart-axis { stroke: var(--border); stroke-width: 1; }
|
||||||
|
.chart-line { fill: none; stroke-width: 2; }
|
||||||
|
.chart-line[data-color="accent"], .chart-bar[data-color="accent"] { stroke: var(--accent); fill: var(--accent); }
|
||||||
|
.chart-line[data-color="red"], .chart-bar[data-color="red"] { stroke: var(--red); fill: var(--red); }
|
||||||
|
.chart-line[data-color="blue"], .chart-bar[data-color="blue"] { stroke: var(--blue); fill: var(--blue); }
|
||||||
|
.chart-line[data-color="orange"], .chart-bar[data-color="orange"] { stroke: var(--orange); fill: var(--orange); }
|
||||||
|
.widget-chart-empty { color: var(--fg-muted); font-size: 13px; padding: 20px 0; text-align: center; }
|
||||||
|
|
||||||
|
/* webview */
|
||||||
|
.widget-webview iframe { border: 1px solid var(--border); border-radius: 6px; margin-top: 8px; }
|
||||||
|
|
||||||
|
/* unknown */
|
||||||
|
.widget-unknown-body { color: var(--fg-muted); font-size: 13px; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* ---------- responsive ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.site-header { padding: 12px 16px; }
|
||||||
|
.site-nav a { margin-left: 12px; font-size: 13px; }
|
||||||
|
.hero { padding: 32px 16px 16px; }
|
||||||
|
.catalog, .detail { padding: 16px; }
|
||||||
|
.detail-header { flex-direction: column; gap: 16px; }
|
||||||
|
.install-actions { flex-direction: row; min-width: 0; }
|
||||||
|
.btn { flex: 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{NAME}} — Scarf Templates</title>
|
||||||
|
<meta name="description" content="{{DESC}}">
|
||||||
|
<link rel="stylesheet" href="../styles.css">
|
||||||
|
<link rel="icon" type="image/png" href="../assets/icon.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<a class="brand" href="..">
|
||||||
|
<img src="../assets/icon.png" alt="" width="40" height="40">
|
||||||
|
<span class="brand-name">Scarf Templates</span>
|
||||||
|
</a>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="..">Catalog</a>
|
||||||
|
<a href="https://github.com/awizemann/scarf">GitHub</a>
|
||||||
|
<a href="https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md">Contribute</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="detail">
|
||||||
|
<section class="detail-header">
|
||||||
|
<div>
|
||||||
|
<h1>{{NAME}} <span class="version">v{{VERSION}}</span></h1>
|
||||||
|
<p class="desc">{{DESC}}</p>
|
||||||
|
<p class="meta">
|
||||||
|
<span class="author">by {{AUTHOR_HTML}}</span>
|
||||||
|
<span class="id">{{ID}}</span>
|
||||||
|
<span class="category">{{CATEGORY}}</span>
|
||||||
|
</p>
|
||||||
|
<p class="tags">{{TAGS_HTML}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="install-actions">
|
||||||
|
<a class="btn btn-primary" href="{{SCARF_INSTALL_URL}}">Install with Scarf</a>
|
||||||
|
<a class="btn btn-secondary" href="{{INSTALL_URL_ENCODED}}">Download .scarftemplate</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-dashboard">
|
||||||
|
<h2>Live dashboard preview</h2>
|
||||||
|
<p class="detail-dashboard-note">
|
||||||
|
Exactly what you'll see inside Scarf after install. Values shown here are
|
||||||
|
placeholders; the agent updates them each time the cron job runs.
|
||||||
|
</p>
|
||||||
|
<div id="dashboard-preview"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-config">
|
||||||
|
<div id="config-schema"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-readme">
|
||||||
|
<h2>README</h2>
|
||||||
|
<div id="readme-body"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<p>
|
||||||
|
Scarf is open source:
|
||||||
|
<a href="https://github.com/awizemann/scarf">github.com/awizemann/scarf</a>.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="../widgets.js"></script>
|
||||||
|
<script>
|
||||||
|
// Fetch + render dashboard + README + config schema at page load.
|
||||||
|
// 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 () {
|
||||||
|
const dashboardEl = document.getElementById("dashboard-preview");
|
||||||
|
const readmeEl = document.getElementById("readme-body");
|
||||||
|
const configEl = document.getElementById("config-schema");
|
||||||
|
try {
|
||||||
|
const d = await fetch("dashboard.json").then(r => r.json());
|
||||||
|
ScarfWidgets.renderDashboard(dashboardEl, d);
|
||||||
|
} catch (e) {
|
||||||
|
dashboardEl.textContent = "Could not load dashboard preview.";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const md = await fetch("README.md").then(r => r.text());
|
||||||
|
readmeEl.innerHTML = ScarfWidgets.renderMarkdown(md);
|
||||||
|
} catch (e) {
|
||||||
|
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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+523
@@ -0,0 +1,523 @@
|
|||||||
|
// Scarf dashboard widget renderer — the dogfood piece.
|
||||||
|
//
|
||||||
|
// Takes the SAME `dashboard.json` shape the Scarf macOS app renders
|
||||||
|
// (see scarf/scarf/Core/Models/ProjectDashboard.swift) and produces an
|
||||||
|
// HTML approximation for the catalog site. A template's detail page
|
||||||
|
// shows a live preview of exactly what the user's project dashboard
|
||||||
|
// will look like after install.
|
||||||
|
//
|
||||||
|
// Widget types mirrored from the Swift dispatcher:
|
||||||
|
// stat — big number + label + icon + color
|
||||||
|
// progress — label + 0..1 bar
|
||||||
|
// text — markdown (tiny subset renderer)
|
||||||
|
// table — plain HTML table
|
||||||
|
// list — bulleted list with optional status badge
|
||||||
|
// chart — SVG line/bar by series
|
||||||
|
// webview — sandboxed <iframe>
|
||||||
|
//
|
||||||
|
// Vanilla JS, no build step, no external deps. ~300 lines.
|
||||||
|
|
||||||
|
(function (global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const SF_SYMBOL_FALLBACK = "●"; // SF Symbols aren't available on the web — use a dot.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Entry point
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a ProjectDashboard JSON into `container`.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @param {object} dashboard
|
||||||
|
*/
|
||||||
|
function renderDashboard(container, dashboard) {
|
||||||
|
container.innerHTML = "";
|
||||||
|
if (!dashboard || !Array.isArray(dashboard.sections)) {
|
||||||
|
container.appendChild(elt("div", "dashboard-error", "Could not render dashboard."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = elt("div", "dashboard");
|
||||||
|
if (dashboard.title) {
|
||||||
|
const header = elt("div", "dashboard-header");
|
||||||
|
header.appendChild(elt("h1", "dashboard-title", dashboard.title));
|
||||||
|
if (dashboard.description) {
|
||||||
|
header.appendChild(elt("p", "dashboard-desc", dashboard.description));
|
||||||
|
}
|
||||||
|
root.appendChild(header);
|
||||||
|
}
|
||||||
|
for (const section of dashboard.sections) {
|
||||||
|
root.appendChild(renderSection(section));
|
||||||
|
}
|
||||||
|
container.appendChild(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(section) {
|
||||||
|
const wrap = elt("section", "dashboard-section");
|
||||||
|
if (section.title) {
|
||||||
|
wrap.appendChild(elt("h2", "section-title", section.title));
|
||||||
|
}
|
||||||
|
const cols = Math.max(1, Math.min(6, section.columns || 3));
|
||||||
|
const grid = elt("div", "widget-grid");
|
||||||
|
grid.style.setProperty("--cols", String(cols));
|
||||||
|
// Webview widgets render in a dedicated tab in the Scarf app but
|
||||||
|
// we inline them here so the catalog preview is single-scroll.
|
||||||
|
for (const widget of section.widgets || []) {
|
||||||
|
grid.appendChild(renderWidget(widget));
|
||||||
|
}
|
||||||
|
wrap.appendChild(grid);
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWidget(widget) {
|
||||||
|
try {
|
||||||
|
switch (widget.type) {
|
||||||
|
case "stat": return renderStat(widget);
|
||||||
|
case "progress": return renderProgress(widget);
|
||||||
|
case "text": return renderText(widget);
|
||||||
|
case "table": return renderTable(widget);
|
||||||
|
case "list": return renderList(widget);
|
||||||
|
case "chart": return renderChart(widget);
|
||||||
|
case "webview": return renderWebview(widget);
|
||||||
|
default: return renderUnknown(widget);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("widget render error", widget, e);
|
||||||
|
return renderUnknown({ ...widget, title: (widget.title || "") + " (render error)" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Stat
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderStat(widget) {
|
||||||
|
const card = elt("div", "widget widget-stat");
|
||||||
|
card.dataset.color = widget.color || "blue";
|
||||||
|
const top = elt("div", "widget-stat-top");
|
||||||
|
top.appendChild(elt("span", "widget-stat-icon", SF_SYMBOL_FALLBACK));
|
||||||
|
top.appendChild(elt("span", "widget-title", widget.title || ""));
|
||||||
|
card.appendChild(top);
|
||||||
|
const value = elt("div", "widget-stat-value", displayValue(widget.value));
|
||||||
|
card.appendChild(value);
|
||||||
|
if (widget.subtitle) {
|
||||||
|
card.appendChild(elt("div", "widget-stat-subtitle", widget.subtitle));
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayValue(v) {
|
||||||
|
if (v === null || v === undefined) return "—";
|
||||||
|
if (typeof v === "number") {
|
||||||
|
return Number.isInteger(v) ? v.toLocaleString() : v.toFixed(1);
|
||||||
|
}
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Progress
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderProgress(widget) {
|
||||||
|
const card = elt("div", "widget widget-progress");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
if (widget.label) {
|
||||||
|
card.appendChild(elt("div", "widget-progress-label", widget.label));
|
||||||
|
}
|
||||||
|
const bar = elt("div", "progress-bar");
|
||||||
|
const fill = elt("div", "progress-fill");
|
||||||
|
const pct = Math.max(0, Math.min(1, Number(widget.value) || 0));
|
||||||
|
fill.style.width = (pct * 100).toFixed(1) + "%";
|
||||||
|
bar.appendChild(fill);
|
||||||
|
card.appendChild(bar);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Text (markdown)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderText(widget) {
|
||||||
|
const card = elt("div", "widget widget-text");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
const body = elt("div", "widget-text-body");
|
||||||
|
if ((widget.format || "").toLowerCase() === "markdown") {
|
||||||
|
body.innerHTML = renderMarkdown(widget.content || "");
|
||||||
|
} else {
|
||||||
|
body.textContent = widget.content || "";
|
||||||
|
}
|
||||||
|
card.appendChild(body);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal markdown subset: headings, bold, italic, inline code, code
|
||||||
|
* blocks, bullet/numbered lists, links, paragraphs. Deliberately tiny
|
||||||
|
* — the catalog showcases dashboards, not blog posts. */
|
||||||
|
function renderMarkdown(src) {
|
||||||
|
const lines = src.split(/\r?\n/);
|
||||||
|
let html = "";
|
||||||
|
let inCode = false;
|
||||||
|
let inList = null; // "ul" | "ol" | null
|
||||||
|
const flushList = () => {
|
||||||
|
if (inList) {
|
||||||
|
html += `</${inList}>`;
|
||||||
|
inList = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine;
|
||||||
|
if (line.trim().startsWith("```")) {
|
||||||
|
flushList();
|
||||||
|
if (inCode) {
|
||||||
|
html += "</code></pre>";
|
||||||
|
inCode = false;
|
||||||
|
} else {
|
||||||
|
html += "<pre><code>";
|
||||||
|
inCode = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (inCode) {
|
||||||
|
html += escapeHTML(line) + "\n";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^#{1,6}\s/.test(line)) {
|
||||||
|
flushList();
|
||||||
|
const level = Math.min(6, (line.match(/^#+/) || ["#"])[0].length);
|
||||||
|
const text = line.replace(/^#+\s*/, "");
|
||||||
|
html += `<h${level}>${renderInline(text)}</h${level}>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const bulletMatch = line.match(/^\s*[-*]\s+(.*)$/);
|
||||||
|
const orderedMatch = line.match(/^\s*\d+\.\s+(.*)$/);
|
||||||
|
if (bulletMatch) {
|
||||||
|
if (inList !== "ul") { flushList(); html += "<ul>"; inList = "ul"; }
|
||||||
|
html += `<li>${renderInline(bulletMatch[1])}</li>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (orderedMatch) {
|
||||||
|
if (inList !== "ol") { flushList(); html += "<ol>"; inList = "ol"; }
|
||||||
|
html += `<li>${renderInline(orderedMatch[1])}</li>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.trim() === "") {
|
||||||
|
flushList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
html += `<p>${renderInline(line)}</p>`;
|
||||||
|
}
|
||||||
|
flushList();
|
||||||
|
if (inCode) html += "</code></pre>";
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInline(text) {
|
||||||
|
// Escape first, then re-apply formatting on the escaped text.
|
||||||
|
let s = escapeHTML(text);
|
||||||
|
// Inline code before bold/italic so the markers inside `…` stay literal.
|
||||||
|
s = s.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||||
|
s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||||
|
s = s.replace(/(^|[^\w])\*([^*]+)\*/g, "$1<em>$2</em>");
|
||||||
|
s = s.replace(/(^|[^\w])_([^_]+)_/g, "$1<em>$2</em>");
|
||||||
|
// Links: [text](url)
|
||||||
|
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
|
||||||
|
return `<a href="${url}">${text}</a>`;
|
||||||
|
});
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Table
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderTable(widget) {
|
||||||
|
const card = elt("div", "widget widget-table");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
const table = elt("table", "data-table");
|
||||||
|
if (Array.isArray(widget.columns)) {
|
||||||
|
const thead = elt("thead");
|
||||||
|
const tr = elt("tr");
|
||||||
|
for (const col of widget.columns) {
|
||||||
|
tr.appendChild(elt("th", null, col));
|
||||||
|
}
|
||||||
|
thead.appendChild(tr);
|
||||||
|
table.appendChild(thead);
|
||||||
|
}
|
||||||
|
if (Array.isArray(widget.rows)) {
|
||||||
|
const tbody = elt("tbody");
|
||||||
|
for (const row of widget.rows) {
|
||||||
|
const tr = elt("tr");
|
||||||
|
for (const cell of row) {
|
||||||
|
tr.appendChild(elt("td", null, cell));
|
||||||
|
}
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
table.appendChild(tbody);
|
||||||
|
}
|
||||||
|
card.appendChild(table);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// List
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderList(widget) {
|
||||||
|
const card = elt("div", "widget widget-list");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
const ul = elt("ul", "widget-list-items");
|
||||||
|
for (const item of widget.items || []) {
|
||||||
|
const li = elt("li", "widget-list-item");
|
||||||
|
li.appendChild(elt("span", "widget-list-text", item.text || ""));
|
||||||
|
if (item.status) {
|
||||||
|
const badge = elt("span", "widget-list-status", item.status);
|
||||||
|
badge.dataset.status = item.status;
|
||||||
|
li.appendChild(badge);
|
||||||
|
}
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
card.appendChild(ul);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Chart (SVG — no Chart.js dep)
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderChart(widget) {
|
||||||
|
const card = elt("div", "widget widget-chart");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
const series = widget.series || [];
|
||||||
|
if (series.length === 0) {
|
||||||
|
card.appendChild(elt("div", "widget-chart-empty", "No chart data."));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
// Collect x-labels (assume aligned across series).
|
||||||
|
const xs = series[0].data.map((p) => p.x);
|
||||||
|
const ys = series.flatMap((s) => s.data.map((p) => p.y));
|
||||||
|
const maxY = Math.max(0, ...ys);
|
||||||
|
const minY = Math.min(0, ...ys);
|
||||||
|
const W = 320;
|
||||||
|
const H = 120;
|
||||||
|
const padL = 24, padR = 8, padT = 8, padB = 22;
|
||||||
|
const plotW = W - padL - padR;
|
||||||
|
const plotH = H - padT - padB;
|
||||||
|
|
||||||
|
const svgNS = "http://www.w3.org/2000/svg";
|
||||||
|
const svg = document.createElementNS(svgNS, "svg");
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||||
|
svg.classList.add("widget-chart-svg");
|
||||||
|
|
||||||
|
const yToPixel = (y) => {
|
||||||
|
if (maxY === minY) return padT + plotH / 2;
|
||||||
|
return padT + plotH - ((y - minY) / (maxY - minY)) * plotH;
|
||||||
|
};
|
||||||
|
const xToPixel = (i) => padL + (plotW * (i / Math.max(1, xs.length - 1)));
|
||||||
|
|
||||||
|
// Axis baseline
|
||||||
|
const axis = document.createElementNS(svgNS, "line");
|
||||||
|
axis.setAttribute("x1", String(padL));
|
||||||
|
axis.setAttribute("y1", String(padT + plotH));
|
||||||
|
axis.setAttribute("x2", String(W - padR));
|
||||||
|
axis.setAttribute("y2", String(padT + plotH));
|
||||||
|
axis.setAttribute("class", "chart-axis");
|
||||||
|
svg.appendChild(axis);
|
||||||
|
|
||||||
|
const kind = (widget.chartType || "line").toLowerCase();
|
||||||
|
series.forEach((s, idx) => {
|
||||||
|
const color = s.color || ["accent", "red", "blue", "orange"][idx % 4];
|
||||||
|
if (kind === "bar") {
|
||||||
|
const barW = Math.max(2, plotW / (xs.length * series.length) - 2);
|
||||||
|
s.data.forEach((p, i) => {
|
||||||
|
const rect = document.createElementNS(svgNS, "rect");
|
||||||
|
const x = xToPixel(i) - barW / 2 + idx * barW;
|
||||||
|
const y = yToPixel(p.y);
|
||||||
|
rect.setAttribute("x", String(x));
|
||||||
|
rect.setAttribute("y", String(y));
|
||||||
|
rect.setAttribute("width", String(barW));
|
||||||
|
rect.setAttribute("height", String(padT + plotH - y));
|
||||||
|
rect.setAttribute("class", "chart-bar");
|
||||||
|
rect.dataset.color = color;
|
||||||
|
svg.appendChild(rect);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const d = s.data.map((p, i) => {
|
||||||
|
const x = xToPixel(i);
|
||||||
|
const y = yToPixel(p.y);
|
||||||
|
return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`;
|
||||||
|
}).join(" ");
|
||||||
|
const path = document.createElementNS(svgNS, "path");
|
||||||
|
path.setAttribute("d", d);
|
||||||
|
path.setAttribute("class", "chart-line");
|
||||||
|
path.dataset.color = color;
|
||||||
|
svg.appendChild(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
card.appendChild(svg);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Webview
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderWebview(widget) {
|
||||||
|
const card = elt("div", "widget widget-webview");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
frame.src = widget.url || "about:blank";
|
||||||
|
frame.setAttribute("sandbox", "allow-scripts allow-popups allow-forms");
|
||||||
|
frame.style.width = "100%";
|
||||||
|
frame.style.height = (widget.height ? Number(widget.height) : 300) + "px";
|
||||||
|
frame.loading = "lazy";
|
||||||
|
card.appendChild(frame);
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Unknown / placeholder
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderUnknown(widget) {
|
||||||
|
const card = elt("div", "widget widget-unknown");
|
||||||
|
card.appendChild(elt("div", "widget-title", widget.title || ""));
|
||||||
|
card.appendChild(elt("div", "widget-unknown-body",
|
||||||
|
`Unknown widget type: ${widget.type}`));
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Utilities
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
function elt(tag, cls, text) {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text !== undefined && text !== null) e.textContent = String(text);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHTML(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.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
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
global.ScarfWidgets = {
|
||||||
|
renderDashboard,
|
||||||
|
renderMarkdown, // used by the detail page's README block
|
||||||
|
renderConfigSchema, // used by the detail page's Configuration block
|
||||||
|
};
|
||||||
|
})(typeof window !== "undefined" ? window : this);
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Contributing a template to Scarf
|
||||||
|
|
||||||
|
Thanks for packaging something up for other Scarf users. This guide walks you through the full submission flow end-to-end.
|
||||||
|
|
||||||
|
## Before you start
|
||||||
|
|
||||||
|
- You need Scarf 2.2 or later installed to build + test your template.
|
||||||
|
- Your template must ship a cross-agent **`AGENTS.md`** — that's the Linux Foundation open standard ([agents.md](https://agents.md/)) every major coding agent reads. Templates without one are rejected; Scarf specifically supports agent-portable projects.
|
||||||
|
- Templates are free and MIT-licensed implicitly by submission. Don't submit anything you don't have rights to.
|
||||||
|
|
||||||
|
## What makes a good template
|
||||||
|
|
||||||
|
- **Scoped.** One purpose per template. A "does-everything" template is harder to maintain than three focused ones.
|
||||||
|
- **Agent-first.** The `AGENTS.md` tells any agent how to interact with your project. Spell out the project layout, what each file is for, and what the agent should do when the user asks common questions ("run the X job", "add a Y").
|
||||||
|
- **Self-contained prompts.** Cron jobs + skills should not assume state the template doesn't ship. If you need a `sites.txt`, have `AGENTS.md` tell the agent to bootstrap it on first run (see `awizemann/site-status-checker` for the pattern).
|
||||||
|
- **Paused by default.** Every cron job ships disabled — Scarf pauses new jobs on install. Write prompts that work whether fired by cron or invoked directly in chat.
|
||||||
|
- **No secrets.** No API keys, no hostnames, no paths specific to your machine. The catalog's CI secret-scan will block obvious cases but this is on you.
|
||||||
|
- **No config writes.** Templates must not modify `~/.hermes/config.yaml`, `auth.json`, or any credential path. The installer refuses v1 bundles that claim to. If you need integration with, say, a specific MCP server, document the prerequisite in your README instead of trying to install it.
|
||||||
|
|
||||||
|
## Step-by-step submission
|
||||||
|
|
||||||
|
### 1. Fork + clone
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gh repo fork awizemann/scarf --clone && cd scarf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create your template directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p templates/<your-github-handle>/<your-template-name>/staging
|
||||||
|
cd templates/<your-github-handle>/<your-template-name>/staging
|
||||||
|
```
|
||||||
|
|
||||||
|
Directory names are lowercase, hyphenated, stable: people will type them.
|
||||||
|
|
||||||
|
### 3. Author the bundle
|
||||||
|
|
||||||
|
Minimum required files under `staging/`:
|
||||||
|
|
||||||
|
- **`template.json`** — manifest. Schema:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "<your-handle>/<your-template-name>",
|
||||||
|
"name": "Your Template Name",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"minScarfVersion": "2.2.0",
|
||||||
|
"minHermesVersion": "0.9.0",
|
||||||
|
"author": { "name": "Your Name", "url": "https://…" },
|
||||||
|
"description": "One-line pitch shown in the catalog.",
|
||||||
|
"category": "monitoring",
|
||||||
|
"tags": ["short", "list"],
|
||||||
|
"contents": {
|
||||||
|
"dashboard": true,
|
||||||
|
"agentsMd": true,
|
||||||
|
"cron": 0,
|
||||||
|
"instructions": null,
|
||||||
|
"skills": null,
|
||||||
|
"memory": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The `contents` claim must exactly match what's in `staging/` — the validator cross-checks and rejects mismatches.
|
||||||
|
|
||||||
|
- **`README.md`** — shown on the catalog detail page. Include: what the project does, what the user has to do after install, how to customize, how to uninstall.
|
||||||
|
|
||||||
|
- **`AGENTS.md`** — the cross-agent spec. Include: project layout, first-run bootstrap (if any), what each cron job expects to happen, and answers to common user prompts (`"what's the status"`, `"add a X"`, etc.).
|
||||||
|
|
||||||
|
- **`dashboard.json`** — the Scarf dashboard that renders on the catalog detail page and after install. See [awizemann/site-status-checker/staging/dashboard.json](awizemann/site-status-checker/staging/dashboard.json) for the schema in action.
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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`.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
From the `staging/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ..
|
||||||
|
zip -qq -r <your-template-name>.scarftemplate staging/
|
||||||
|
mv <your-template-name>.scarftemplate . # end up alongside staging/
|
||||||
|
```
|
||||||
|
|
||||||
|
Or equivalently:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd staging && zip -qq -r ../<your-template-name>.scarftemplate . && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Test locally in Scarf
|
||||||
|
|
||||||
|
1. Open Scarf → Projects → Templates → **Install from File…** → select your `.scarftemplate`.
|
||||||
|
2. Walk through the preview sheet. Make sure every file, cron job, and memory block shown is something you meant to ship.
|
||||||
|
3. Install into a scratch parent dir. Verify the dashboard renders. Enable the cron job(s) if any and trigger them manually to confirm your `AGENTS.md` drives the right behavior.
|
||||||
|
4. Right-click the project → **Uninstall Template…** → verify nothing unexpected remains.
|
||||||
|
|
||||||
|
### 6. Validate
|
||||||
|
|
||||||
|
Before opening the PR, run the catalog validator locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/build-catalog.py --check
|
||||||
|
```
|
||||||
|
|
||||||
|
This checks every template in the repo (including yours), verifies the manifest matches the bundle contents, refuses bundles >5 MB, and flags common secret patterns. If it fails, fix the reported issues before pushing.
|
||||||
|
|
||||||
|
### 7. Open the PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout -b add-<your-template-name>
|
||||||
|
git add templates/<your-handle>/<your-template-name>
|
||||||
|
git commit -m "feat(templates): add <your-template-name>"
|
||||||
|
git push origin add-<your-template-name>
|
||||||
|
gh pr create
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not modify `templates/catalog.json`** — the maintainer regenerates it after merge to keep PR diffs small.
|
||||||
|
|
||||||
|
The scarf repo ships a tailored submission checklist at [.github/PULL_REQUEST_TEMPLATE/template-submission.md](../.github/PULL_REQUEST_TEMPLATE/template-submission.md). To apply it to your PR, append `?template=template-submission.md` to the compare URL when opening the PR in the browser, or copy the checkbox list into the body manually.
|
||||||
|
|
||||||
|
GitHub Actions runs the validator on your PR (see [.github/workflows/validate-template-pr.yml](../.github/workflows/validate-template-pr.yml)). A green check means the bundle structure is sound; it doesn't mean the content is approved. Expect a maintainer pass for content quality (is the `AGENTS.md` clear, does the prompt do what you describe, is the scope reasonable).
|
||||||
|
|
||||||
|
### 8. Iterate + ship
|
||||||
|
|
||||||
|
Respond to review feedback. Common requests:
|
||||||
|
|
||||||
|
- Sharpen the `README.md` so install/uninstall steps are copy-pasteable.
|
||||||
|
- Split ambitious cron prompts into smaller, clearly-scoped ones.
|
||||||
|
- Remove things the template doesn't need (an empty `skills/` dir, an unused `deliver` target, etc.).
|
||||||
|
|
||||||
|
Once merged, your template shows up at `https://awizemann.github.io/scarf/templates/<your-handle>-<your-name>/` within a few minutes (the maintainer pushes the site regeneration by hand).
|
||||||
|
|
||||||
|
## Updating an existing template
|
||||||
|
|
||||||
|
Bump `version` in `template.json`, rebuild the `.scarftemplate`, commit, PR. The Install button on the catalog always points at the latest `main` version — there's no per-version pinning in v1. Users who already installed get no automatic update; they'd have to uninstall + reinstall for v2.
|
||||||
|
|
||||||
|
## Questions?
|
||||||
|
|
||||||
|
Open a [GitHub Discussion](https://github.com/awizemann/scarf/discussions) — the tag `templates` is watched.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Scarf Templates
|
||||||
|
|
||||||
|
The community template catalog for [Scarf](https://github.com/awizemann/scarf) — a macOS GUI for the Hermes AI agent. Each subdirectory here is one installable project template. Browse the live catalog with live dashboard previews at **<https://awizemann.github.io/scarf/templates/>**.
|
||||||
|
|
||||||
|
## What's a template?
|
||||||
|
|
||||||
|
A `.scarftemplate` bundle ships:
|
||||||
|
|
||||||
|
- A pre-configured project **dashboard** (widgets for stats, lists, text, charts).
|
||||||
|
- A cross-agent **`AGENTS.md`** ([agents.md standard](https://agents.md/)) that tells Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, etc. how to work with the project.
|
||||||
|
- Optional **skills**, **cron jobs**, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), and an optional **memory appendix**.
|
||||||
|
|
||||||
|
Users install with one click from the catalog site or by opening a `.scarftemplate` file in Scarf.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
Each template lives at `templates/<github-handle>/<template-name>/` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
templates/<github-handle>/<template-name>/
|
||||||
|
├── staging/ source tree
|
||||||
|
│ ├── template.json manifest (id, name, version, contents claim)
|
||||||
|
│ ├── README.md shown on catalog detail page
|
||||||
|
│ ├── AGENTS.md required cross-agent instructions
|
||||||
|
│ ├── dashboard.json rendered as a live preview on the catalog site
|
||||||
|
│ ├── instructions/… optional per-agent shims
|
||||||
|
│ ├── skills/… optional namespaced skills
|
||||||
|
│ ├── cron/jobs.json optional cron job definitions
|
||||||
|
│ └── memory/append.md optional memory appendix
|
||||||
|
├── <template-name>.scarftemplate built bundle (zipped staging/), committed as-is
|
||||||
|
└── screenshots/ optional PNGs for the detail page
|
||||||
|
```
|
||||||
|
|
||||||
|
The built `.scarftemplate` is served directly from `raw.githubusercontent.com` — the catalog's Install button links at:
|
||||||
|
|
||||||
|
```
|
||||||
|
scarf://install?url=https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing a template
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full submission walkthrough. tl;dr: fork, drop a template under `templates/<your-handle>/<your-name>/`, open a PR. A CI check validates the bundle; a maintainer reviews the content.
|
||||||
|
|
||||||
|
## Catalog metadata
|
||||||
|
|
||||||
|
`catalog.json` at this directory is the aggregate index that the website reads. It's regenerated by the maintainer on merge — **do not modify it in your PR**, the build script will take care of it.
|
||||||
|
|
||||||
|
## Current templates
|
||||||
|
|
||||||
|
| Template | Author | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| [site-status-checker](awizemann/site-status-checker/) | awizemann | Daily HTTP uptime check for a user-editable list of URLs. Dashboard + cron + AGENTS.md. |
|
||||||
Binary file not shown.
@@ -0,0 +1,74 @@
|
|||||||
|
# Site Status Checker — Agent Instructions
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- `.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).
|
||||||
|
- `.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.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
If `status-log.md` doesn't exist, create it with a one-line header:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Site Status Log
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The cron job runs this project's "Check site status" prompt. When invoked:
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
4. Prepend a new section to `status-log.md`:
|
||||||
|
```
|
||||||
|
## <ISO-8601 timestamp>
|
||||||
|
|
||||||
|
| URL | Status | Code | Latency |
|
||||||
|
|-----|--------|------|---------|
|
||||||
|
| … | up | 200 | 142 ms |
|
||||||
|
| … | down | timeout | — |
|
||||||
|
```
|
||||||
|
5. Update `.scarf/dashboard.json`:
|
||||||
|
- `Sites Up` stat widget: `value` = count of up results.
|
||||||
|
- `Sites Down` stat widget: `value` = count of down results.
|
||||||
|
- `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).
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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 invent URLs or pull them from anywhere other than `values.sites`.
|
||||||
|
- Don't run browsers or headless Chrome. Plain HTTP GET is sufficient.
|
||||||
|
|
||||||
|
## When the user asks you things
|
||||||
|
|
||||||
|
- "What's the status of my sites?" — read the top section of `status-log.md` and summarize.
|
||||||
|
- "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.
|
||||||
|
- "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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Site Status Checker
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- **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).
|
||||||
|
- **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.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
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. 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."*
|
||||||
|
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
|
||||||
|
|
||||||
|
- **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/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`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Check site status",
|
||||||
|
"schedule": "0 9 * * *",
|
||||||
|
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read .scarf/config.json to get values.sites (the URL list) and values.timeout_seconds, HTTP GET each URL with the configured timeout, prepend a results section to status-log.md (creating it with the stub header if it doesn't exist yet), 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'."
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"title": "Site Status",
|
||||||
|
"description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.",
|
||||||
|
"theme": { "accent": "green" },
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Current Status",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Sites Up",
|
||||||
|
"value": 0,
|
||||||
|
"icon": "checkmark.circle.fill",
|
||||||
|
"color": "green",
|
||||||
|
"subtitle": "responded 2xx/3xx"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Sites Down",
|
||||||
|
"value": 0,
|
||||||
|
"icon": "xmark.circle.fill",
|
||||||
|
"color": "red",
|
||||||
|
"subtitle": "non-2xx, timeout, DNS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "stat",
|
||||||
|
"title": "Last Checked",
|
||||||
|
"value": "never",
|
||||||
|
"icon": "clock",
|
||||||
|
"color": "blue",
|
||||||
|
"subtitle": "ISO-8601 timestamp"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Watched Sites",
|
||||||
|
"columns": 1,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"title": "Configured Sites (from sites.txt)",
|
||||||
|
"items": [
|
||||||
|
{ "text": "https://example.com", "status": "unknown" },
|
||||||
|
{ "text": "https://example.org", "status": "unknown" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "How to Use",
|
||||||
|
"columns": 1,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"title": "Quick Start",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"id": "awizemann/site-status-checker",
|
||||||
|
"name": "Site Status Checker",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"minScarfVersion": "2.3.0",
|
||||||
|
"minHermesVersion": "0.9.0",
|
||||||
|
"author": {
|
||||||
|
"name": "Alan Wizemann",
|
||||||
|
"url": "https://github.com/awizemann/scarf"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"tags": ["monitoring", "uptime", "cron", "starter", "configurable"],
|
||||||
|
"contents": {
|
||||||
|
"dashboard": true,
|
||||||
|
"agentsMd": true,
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"generated": true,
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"templates": [
|
||||||
|
{
|
||||||
|
"author": {
|
||||||
|
"name": "Alan Wizemann",
|
||||||
|
"url": "https://github.com/awizemann/scarf"
|
||||||
|
},
|
||||||
|
"bundleSha256": "ce68cc20cc67fe688a7ddf0638d35dce3247ba7ed234e6f9d99a1ad3964a81e0",
|
||||||
|
"bundleSize": 6797,
|
||||||
|
"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": {
|
||||||
|
"agentsMd": true,
|
||||||
|
"config": 2,
|
||||||
|
"cron": 1,
|
||||||
|
"dashboard": true
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"id": "awizemann/site-status-checker",
|
||||||
|
"installUrl": "https://raw.githubusercontent.com/awizemann/scarf/main/templates/awizemann/site-status-checker/site-status-checker.scarftemplate",
|
||||||
|
"minHermesVersion": "0.9.0",
|
||||||
|
"minScarfVersion": "2.3.0",
|
||||||
|
"name": "Site Status Checker",
|
||||||
|
"tags": [
|
||||||
|
"monitoring",
|
||||||
|
"uptime",
|
||||||
|
"cron",
|
||||||
|
"starter",
|
||||||
|
"configurable"
|
||||||
|
],
|
||||||
|
"version": "1.1.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+785
@@ -0,0 +1,785 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scarf template catalog builder + validator.
|
||||||
|
|
||||||
|
Walks every `templates/<author>/<name>/` in this repo, validates the
|
||||||
|
`.scarftemplate` bundle against its manifest claim (same invariants the
|
||||||
|
Swift `ProjectTemplateService.verifyClaims` enforces at install time), and
|
||||||
|
produces:
|
||||||
|
|
||||||
|
templates/catalog.json aggregate index for the site
|
||||||
|
.gh-pages-worktree/templates/... per-template HTML + dashboard.json
|
||||||
|
(only produced by --build / --publish)
|
||||||
|
|
||||||
|
This is stdlib-only Python so it runs in a GitHub Action with zero
|
||||||
|
dependencies and in under a second even when the catalog has thousands of
|
||||||
|
templates. Schema drift between this validator and the Swift installer
|
||||||
|
breaks one of two contracts — add a failing test in both places when you
|
||||||
|
change anything here.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tools/build-catalog.py --check validate; no output written
|
||||||
|
tools/build-catalog.py --build validate + write catalog.json + site
|
||||||
|
tools/build-catalog.py --preview DIR render a self-contained preview
|
||||||
|
site into DIR (for local viewing)
|
||||||
|
|
||||||
|
Exit codes:
|
||||||
|
0 success
|
||||||
|
1 validation failure (one or more templates rejected)
|
||||||
|
2 IO / usage error
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema + invariants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
|
||||||
|
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
|
||||||
|
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2}
|
||||||
|
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
|
||||||
|
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
|
||||||
|
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
|
||||||
|
|
||||||
|
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
|
||||||
|
# for error messages that echo this set.
|
||||||
|
SUPPORTED_CONFIG_FIELD_TYPES = {"string", "text", "number", "bool", "enum", "list", "secret"}
|
||||||
|
SUPPORTED_CONFIG_LIST_ITEM_TYPES = {"string"}
|
||||||
|
|
||||||
|
# Common secret patterns — keep in sync with `scripts/wiki.sh` and reuse a
|
||||||
|
# conservative subset. The validator rejects hard matches; the site's
|
||||||
|
# CONTRIBUTING guide covers the rest.
|
||||||
|
SECRET_PATTERNS = [
|
||||||
|
(re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"), "private key block"),
|
||||||
|
(re.compile(r"(?i)\bgh[pousr]_[A-Za-z0-9]{36,}"), "github personal access token"),
|
||||||
|
(re.compile(r"(?i)\bxox[abpso]-[A-Za-z0-9-]{10,}"), "slack token"),
|
||||||
|
(re.compile(r"(?i)\bAKIA[0-9A-Z]{16}"), "aws access key id"),
|
||||||
|
(re.compile(r"(?i)\bsk-[A-Za-z0-9]{32,}"), "openai/anthropic api key"),
|
||||||
|
]
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationError:
|
||||||
|
template_path: Path
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
# Render a repo-relative path when possible for concise CLI output;
|
||||||
|
# fall back to the absolute path when the template lives outside
|
||||||
|
# the repo tree (unit tests use temp dirs).
|
||||||
|
try:
|
||||||
|
rel: Path | str = self.template_path.relative_to(REPO_ROOT)
|
||||||
|
except ValueError:
|
||||||
|
rel = self.template_path
|
||||||
|
return f"{rel}: {self.message}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TemplateRecord:
|
||||||
|
"""One entry in the generated catalog.json. Mirrors the Swift
|
||||||
|
ProjectTemplateManifest but with a few derived fields added."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
manifest: dict
|
||||||
|
bundle_path: Path
|
||||||
|
bundle_sha256: str
|
||||||
|
bundle_size: int
|
||||||
|
install_url: str
|
||||||
|
detail_slug: str
|
||||||
|
|
||||||
|
def to_catalog_entry(self) -> dict:
|
||||||
|
"""Subset suitable for catalog.json. Keep fields stable — the
|
||||||
|
site's widgets.js reads this shape. The optional `config` key
|
||||||
|
mirrors the manifest's `config` block so the site can render
|
||||||
|
the Configuration section on the detail page."""
|
||||||
|
m = self.manifest
|
||||||
|
return {
|
||||||
|
"id": m["id"],
|
||||||
|
"name": m["name"],
|
||||||
|
"version": m["version"],
|
||||||
|
"description": m["description"],
|
||||||
|
"author": m.get("author"),
|
||||||
|
"category": m.get("category"),
|
||||||
|
"tags": m.get("tags") or [],
|
||||||
|
"contents": m["contents"],
|
||||||
|
"config": m.get("config"), # None for schema-less
|
||||||
|
"installUrl": self.install_url,
|
||||||
|
"detailSlug": self.detail_slug,
|
||||||
|
"bundleSha256": self.bundle_sha256,
|
||||||
|
"bundleSize": self.bundle_size,
|
||||||
|
"minScarfVersion": m.get("minScarfVersion"),
|
||||||
|
"minHermesVersion": m.get("minHermesVersion"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Validation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def manifest_slug(manifest_id: str) -> str:
|
||||||
|
"""Mirror of Swift `ProjectTemplateManifest.slug`. Non-alphanumeric
|
||||||
|
runs collapse to single hyphens; empty collapses to 'template'."""
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9_-]+", "-", manifest_id).strip("-")
|
||||||
|
return cleaned or "template"
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_templates(repo_root: Path) -> Iterable[Path]:
|
||||||
|
"""Yield every `templates/<author>/<name>/` directory (those that hold
|
||||||
|
a `template.json` or a built `.scarftemplate`). Authors whose dirs
|
||||||
|
only hold a README are silently skipped."""
|
||||||
|
root = repo_root / "templates"
|
||||||
|
if not root.is_dir():
|
||||||
|
return
|
||||||
|
for author_dir in sorted(root.iterdir()):
|
||||||
|
if not author_dir.is_dir() or author_dir.name.startswith("."):
|
||||||
|
continue
|
||||||
|
for template_dir in sorted(author_dir.iterdir()):
|
||||||
|
if not template_dir.is_dir():
|
||||||
|
continue
|
||||||
|
if (template_dir / "staging").is_dir():
|
||||||
|
yield template_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||||
|
required = ["schemaVersion", "id", "name", "version", "description", "contents"]
|
||||||
|
for field in required:
|
||||||
|
if field not in manifest:
|
||||||
|
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
|
||||||
|
if manifest.get("schemaVersion") not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"unsupported schemaVersion: {manifest.get('schemaVersion')} "
|
||||||
|
f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})"
|
||||||
|
))
|
||||||
|
# Manifest id must match the directory layout.
|
||||||
|
mid = manifest.get("id", "")
|
||||||
|
if "/" not in mid:
|
||||||
|
errors.append(ValidationError(template_dir, f"manifest id must be owner/name, got {mid!r}"))
|
||||||
|
else:
|
||||||
|
expected_author = template_dir.parent.name
|
||||||
|
author_part, _, _ = mid.partition("/")
|
||||||
|
if author_part != expected_author:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"manifest id {mid!r} author component does not match directory "
|
||||||
|
f"({expected_author!r})"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_contents_claim(
|
||||||
|
manifest: dict,
|
||||||
|
bundle_files: set[str],
|
||||||
|
cron_job_count: int,
|
||||||
|
template_dir: Path,
|
||||||
|
errors: list[ValidationError],
|
||||||
|
) -> None:
|
||||||
|
"""Mirrors Swift `ProjectTemplateService.verifyClaims`. Rejects any
|
||||||
|
mismatch between what the manifest says and what's actually in the
|
||||||
|
bundle so the catalog site can't misrepresent a template."""
|
||||||
|
contents = manifest.get("contents", {})
|
||||||
|
|
||||||
|
for required in REQUIRED_BUNDLE_FILES:
|
||||||
|
if required not in bundle_files:
|
||||||
|
errors.append(ValidationError(template_dir, f"bundle missing required file: {required}"))
|
||||||
|
|
||||||
|
# Optional instructions/ dir — claim must match presence exactly.
|
||||||
|
claimed_instructions = contents.get("instructions") or []
|
||||||
|
claimed_full = {f"instructions/{p}" for p in claimed_instructions}
|
||||||
|
present_instructions = {f for f in bundle_files if f.startswith("instructions/")}
|
||||||
|
for claim in claimed_full:
|
||||||
|
if claim not in bundle_files:
|
||||||
|
errors.append(ValidationError(template_dir, f"contents.instructions claims {claim} but file is missing"))
|
||||||
|
for present in present_instructions - claimed_full:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"bundle has {present} but it's not listed in contents.instructions"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Skills — each claimed skill name must exist as a subdir with at least
|
||||||
|
# one file; extra skill dirs not listed are rejected.
|
||||||
|
claimed_skills = set(contents.get("skills") or [])
|
||||||
|
present_skills = set()
|
||||||
|
for f in bundle_files:
|
||||||
|
if f.startswith("skills/"):
|
||||||
|
rest = f[len("skills/"):]
|
||||||
|
if "/" in rest:
|
||||||
|
present_skills.add(rest.split("/", 1)[0])
|
||||||
|
for skill in claimed_skills:
|
||||||
|
if not any(f.startswith(f"skills/{skill}/") for f in bundle_files):
|
||||||
|
errors.append(ValidationError(template_dir, f"contents.skills claims {skill!r} but skills/{skill}/ is empty"))
|
||||||
|
for extra in present_skills - claimed_skills:
|
||||||
|
errors.append(ValidationError(template_dir, f"bundle has skills/{extra}/ not listed in contents.skills"))
|
||||||
|
|
||||||
|
# Cron — numeric count must match bundle.
|
||||||
|
claimed_cron = int(contents.get("cron") or 0)
|
||||||
|
if claimed_cron != cron_job_count:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"contents.cron={claimed_cron} but bundle contains {cron_job_count} cron jobs"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Memory appendix — claim must match file presence.
|
||||||
|
claimed_memory = bool((contents.get("memory") or {}).get("append"))
|
||||||
|
has_memory_file = "memory/append.md" in bundle_files
|
||||||
|
if claimed_memory != has_memory_file:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"contents.memory.append={claimed_memory} disagrees with memory/append.md presence={has_memory_file}"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Config (schemaVersion 2+) — claim field-count must match schema
|
||||||
|
# field count. `None`/`0` on both sides means schema-less, which is
|
||||||
|
# always legal.
|
||||||
|
claimed_config = int(contents.get("config") or 0)
|
||||||
|
schema = manifest.get("config")
|
||||||
|
schema_field_count = len((schema or {}).get("schema") or []) if schema else 0
|
||||||
|
if claimed_config != schema_field_count:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"contents.config={claimed_config} but config.schema has {schema_field_count} field(s)"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config_schema(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||||
|
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
|
||||||
|
invariants only — user-value validation happens in the app at
|
||||||
|
commit time, not at catalog-build time."""
|
||||||
|
schema = manifest.get("config")
|
||||||
|
if schema is None:
|
||||||
|
return
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
errors.append(ValidationError(template_dir, "manifest.config must be an object"))
|
||||||
|
return
|
||||||
|
fields = schema.get("schema")
|
||||||
|
if not isinstance(fields, list):
|
||||||
|
errors.append(ValidationError(template_dir, "manifest.config.schema must be a list"))
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
for i, field in enumerate(fields):
|
||||||
|
if not isinstance(field, dict):
|
||||||
|
errors.append(ValidationError(template_dir, f"config.schema[{i}] must be an object"))
|
||||||
|
continue
|
||||||
|
key = field.get("key")
|
||||||
|
ftype = field.get("type")
|
||||||
|
label = field.get("label")
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
errors.append(ValidationError(template_dir, f"config.schema[{i}] missing/empty key"))
|
||||||
|
continue
|
||||||
|
if key in seen_keys:
|
||||||
|
errors.append(ValidationError(template_dir, f"config.schema has duplicate key: {key!r}"))
|
||||||
|
continue
|
||||||
|
seen_keys.add(key)
|
||||||
|
if not isinstance(label, str) or not label:
|
||||||
|
errors.append(ValidationError(template_dir, f"config.schema[{key}] missing/empty label"))
|
||||||
|
if ftype not in SUPPORTED_CONFIG_FIELD_TYPES:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] uses unsupported type {ftype!r} "
|
||||||
|
f"(supported: {sorted(SUPPORTED_CONFIG_FIELD_TYPES)})"
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
# Type-specific rules.
|
||||||
|
if ftype == "enum":
|
||||||
|
options = field.get("options") or []
|
||||||
|
if not isinstance(options, list) or not options:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] (enum) must declare at least one option"
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
seen_values: set[str] = set()
|
||||||
|
for opt in options:
|
||||||
|
if not isinstance(opt, dict):
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] option must be an object"
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
val = opt.get("value")
|
||||||
|
if not isinstance(val, str) or not val:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] option missing/empty value"
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
if val in seen_values:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] has duplicate option value: {val!r}"
|
||||||
|
))
|
||||||
|
seen_values.add(val)
|
||||||
|
elif ftype == "list":
|
||||||
|
item_type = field.get("itemType", "string")
|
||||||
|
if item_type not in SUPPORTED_CONFIG_LIST_ITEM_TYPES:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] (list) uses unsupported itemType {item_type!r}"
|
||||||
|
))
|
||||||
|
elif ftype == "secret":
|
||||||
|
if "default" in field:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"config.schema[{key}] is a secret field and must not declare a default"
|
||||||
|
))
|
||||||
|
# modelRecommendation — preferred must be non-empty when present.
|
||||||
|
rec = schema.get("modelRecommendation")
|
||||||
|
if rec is not None:
|
||||||
|
if not isinstance(rec, dict):
|
||||||
|
errors.append(ValidationError(template_dir, "config.modelRecommendation must be an object"))
|
||||||
|
else:
|
||||||
|
preferred = rec.get("preferred")
|
||||||
|
if not isinstance(preferred, str) or not preferred.strip():
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
"config.modelRecommendation.preferred must be a non-empty string"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||||
|
"""Decode dashboard.json against the widget-type vocabulary the Swift
|
||||||
|
renderer knows. An unknown widget type means the app will render an
|
||||||
|
'unknown widget' placeholder — that's a bad catalog experience."""
|
||||||
|
try:
|
||||||
|
dashboard = json.loads(zf.read("dashboard.json"))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(ValidationError(template_dir, f"dashboard.json failed to parse: {e}"))
|
||||||
|
return
|
||||||
|
if dashboard.get("version") != 1:
|
||||||
|
errors.append(ValidationError(template_dir, f"dashboard.version must be 1, got {dashboard.get('version')}"))
|
||||||
|
sections = dashboard.get("sections") or []
|
||||||
|
if not isinstance(sections, list):
|
||||||
|
errors.append(ValidationError(template_dir, "dashboard.sections must be a list"))
|
||||||
|
return
|
||||||
|
for section in sections:
|
||||||
|
for widget in section.get("widgets") or []:
|
||||||
|
widget_type = widget.get("type")
|
||||||
|
if widget_type not in SUPPORTED_WIDGET_TYPES:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"dashboard widget {widget.get('title')!r} has unknown type {widget_type!r}"
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_for_secrets(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
|
||||||
|
"""Refuse bundles containing obvious secret patterns. Conservative —
|
||||||
|
matches only high-confidence substrings (no keyword-only warnings)."""
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir() or info.file_size > 256 * 1024:
|
||||||
|
continue # skip big binaries
|
||||||
|
try:
|
||||||
|
data = zf.read(info.filename).decode("utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for pattern, label in SECRET_PATTERNS:
|
||||||
|
if pattern.search(data):
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"bundle file {info.filename} matches {label} pattern — refusing"
|
||||||
|
))
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cron_jobs(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> int:
|
||||||
|
"""Parse cron/jobs.json if present; return the job count. Logs a
|
||||||
|
validation error on a malformed file."""
|
||||||
|
if "cron/jobs.json" not in set(zf.namelist()):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
data = json.loads(zf.read("cron/jobs.json"))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(ValidationError(template_dir, f"cron/jobs.json failed to parse: {e}"))
|
||||||
|
return 0
|
||||||
|
if not isinstance(data, list):
|
||||||
|
errors.append(ValidationError(template_dir, "cron/jobs.json must be a JSON array"))
|
||||||
|
return 0
|
||||||
|
for i, job in enumerate(data):
|
||||||
|
if not isinstance(job, dict):
|
||||||
|
errors.append(ValidationError(template_dir, f"cron/jobs.json[{i}] must be an object"))
|
||||||
|
continue
|
||||||
|
if "name" not in job or "schedule" not in job:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"cron/jobs.json[{i}] missing required field (name, schedule)"
|
||||||
|
))
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_files(zf: zipfile.ZipFile) -> set[str]:
|
||||||
|
"""Unique regular-file paths in the bundle, excluding dir entries and
|
||||||
|
macOS __MACOSX/ metadata."""
|
||||||
|
return {
|
||||||
|
info.filename
|
||||||
|
for info in zf.infolist()
|
||||||
|
if not info.is_dir() and not info.filename.startswith("__MACOSX/")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template(template_dir: Path) -> tuple[TemplateRecord | None, list[ValidationError]]:
|
||||||
|
"""Validate one template dir and return a (record, errors) pair.
|
||||||
|
record is None when errors are fatal enough that we can't build a
|
||||||
|
catalog entry at all."""
|
||||||
|
errors: list[ValidationError] = []
|
||||||
|
|
||||||
|
# Find the bundle. By convention it's `<dir>/<dir-basename>.scarftemplate`
|
||||||
|
# or any single .scarftemplate in the dir.
|
||||||
|
bundles = sorted(template_dir.glob("*.scarftemplate"))
|
||||||
|
if not bundles:
|
||||||
|
errors.append(ValidationError(template_dir, "no .scarftemplate found in template directory"))
|
||||||
|
return None, errors
|
||||||
|
if len(bundles) > 1:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"more than one .scarftemplate present: {[b.name for b in bundles]}"
|
||||||
|
))
|
||||||
|
bundle_path = bundles[0]
|
||||||
|
|
||||||
|
bundle_size = bundle_path.stat().st_size
|
||||||
|
if bundle_size > MAX_BUNDLE_BYTES:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
template_dir,
|
||||||
|
f"bundle size {bundle_size} exceeds catalog cap of {MAX_BUNDLE_BYTES} bytes"
|
||||||
|
))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(bundle_path, "r") as zf:
|
||||||
|
bundle_files = _bundle_files(zf)
|
||||||
|
if "template.json" not in bundle_files:
|
||||||
|
errors.append(ValidationError(template_dir, "bundle is missing template.json"))
|
||||||
|
return None, errors
|
||||||
|
try:
|
||||||
|
manifest = json.loads(zf.read("template.json"))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(ValidationError(template_dir, f"template.json failed to parse: {e}"))
|
||||||
|
return None, errors
|
||||||
|
|
||||||
|
_validate_manifest(manifest, template_dir, errors)
|
||||||
|
_validate_config_schema(manifest, template_dir, errors)
|
||||||
|
cron_count = _parse_cron_jobs(zf, template_dir, errors)
|
||||||
|
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
|
||||||
|
_validate_dashboard(zf, template_dir, errors)
|
||||||
|
_scan_for_secrets(zf, template_dir, errors)
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
errors.append(ValidationError(template_dir, "bundle is not a valid zip archive"))
|
||||||
|
return None, errors
|
||||||
|
|
||||||
|
# Compute the catalog-ready record.
|
||||||
|
sha = hashlib.sha256(bundle_path.read_bytes()).hexdigest()
|
||||||
|
author = template_dir.parent.name
|
||||||
|
short_name = template_dir.name
|
||||||
|
install_url = (
|
||||||
|
"https://raw.githubusercontent.com/awizemann/scarf/main/"
|
||||||
|
f"templates/{author}/{short_name}/{bundle_path.name}"
|
||||||
|
)
|
||||||
|
detail_slug = manifest_slug(manifest.get("id", f"{author}/{short_name}"))
|
||||||
|
|
||||||
|
record = TemplateRecord(
|
||||||
|
path=template_dir,
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_path=bundle_path,
|
||||||
|
bundle_sha256=sha,
|
||||||
|
bundle_size=bundle_size,
|
||||||
|
install_url=install_url,
|
||||||
|
detail_slug=detail_slug,
|
||||||
|
)
|
||||||
|
return record, errors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Staging/bundle drift check — keeps authors honest
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _check_staging_matches_bundle(record: TemplateRecord) -> list[ValidationError]:
|
||||||
|
"""If the template dir has a staging/ source tree, rebuild the bundle
|
||||||
|
in memory and diff against the committed one. Catches the common
|
||||||
|
failure mode of an author editing staging/ but forgetting to
|
||||||
|
regenerate the .scarftemplate."""
|
||||||
|
errors: list[ValidationError] = []
|
||||||
|
staging = record.path / "staging"
|
||||||
|
if not staging.is_dir():
|
||||||
|
return errors
|
||||||
|
|
||||||
|
committed = {}
|
||||||
|
with zipfile.ZipFile(record.bundle_path, "r") as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir() or info.filename.startswith("__MACOSX/"):
|
||||||
|
continue
|
||||||
|
committed[info.filename] = zf.read(info.filename)
|
||||||
|
|
||||||
|
source = {}
|
||||||
|
for path in staging.rglob("*"):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
rel = path.relative_to(staging).as_posix()
|
||||||
|
if rel.startswith(".") or "/.DS_Store" in rel or rel.endswith("/.DS_Store") or rel == ".DS_Store":
|
||||||
|
continue
|
||||||
|
source[rel] = path.read_bytes()
|
||||||
|
|
||||||
|
missing_in_bundle = sorted(set(source) - set(committed))
|
||||||
|
if missing_in_bundle:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
record.path,
|
||||||
|
f"staging has files not in the built bundle: {missing_in_bundle} "
|
||||||
|
"(rebuild with `zip -qq -r <name>.scarftemplate .` from staging/)"
|
||||||
|
))
|
||||||
|
missing_in_source = sorted(set(committed) - set(source))
|
||||||
|
if missing_in_source:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
record.path,
|
||||||
|
f"bundle has files not in staging/: {missing_in_source} "
|
||||||
|
"(commit them to staging/ or rebuild the bundle from staging/)"
|
||||||
|
))
|
||||||
|
diff = [name for name, data in source.items() if name in committed and committed[name] != data]
|
||||||
|
if diff:
|
||||||
|
errors.append(ValidationError(
|
||||||
|
record.path,
|
||||||
|
f"staging content differs from built bundle: {diff} "
|
||||||
|
"(rebuild the bundle from staging/)"
|
||||||
|
))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Build: write catalog.json (site rendering comes in a later commit)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
|
||||||
|
catalog = {
|
||||||
|
# The aggregate catalog itself is versioned independently of
|
||||||
|
# individual bundle manifests — bumping template manifest schema
|
||||||
|
# from 1 → 2 doesn't change the catalog.json shape.
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"generated": True, # human reminder; a timestamp would churn the diff every run
|
||||||
|
"templates": [r.to_catalog_entry() for r in records],
|
||||||
|
}
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
out_path.write_text(json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
group = parser.add_mutually_exclusive_group(required=True)
|
||||||
|
group.add_argument("--check", action="store_true", help="validate every template; don't write output")
|
||||||
|
group.add_argument("--build", action="store_true", help="validate + write catalog.json")
|
||||||
|
group.add_argument("--preview", metavar="DIR", help="render a self-contained site preview into DIR")
|
||||||
|
parser.add_argument("--only", metavar="PATH", action="append", default=[],
|
||||||
|
help="validate only the given template dir (may repeat); useful for PR-diff runs")
|
||||||
|
parser.add_argument("--repo", metavar="PATH", default=str(REPO_ROOT),
|
||||||
|
help="repo root to operate on (default: auto-detect)")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
repo_root = Path(args.repo).resolve()
|
||||||
|
template_dirs = list(_iter_templates(repo_root))
|
||||||
|
if args.only:
|
||||||
|
only = {Path(p).resolve() for p in args.only}
|
||||||
|
template_dirs = [t for t in template_dirs if t.resolve() in only]
|
||||||
|
|
||||||
|
if not template_dirs:
|
||||||
|
if args.only:
|
||||||
|
print(f"no templates matched --only filter", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print("no templates found under templates/ — nothing to do", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
records: list[TemplateRecord] = []
|
||||||
|
all_errors: list[ValidationError] = []
|
||||||
|
for tdir in template_dirs:
|
||||||
|
record, errors = validate_template(tdir)
|
||||||
|
all_errors.extend(errors)
|
||||||
|
if record is not None:
|
||||||
|
all_errors.extend(_check_staging_matches_bundle(record))
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
print(f"✗ {len(all_errors)} validation error(s):", file=sys.stderr)
|
||||||
|
for err in all_errors:
|
||||||
|
print(f" {err}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(f"✓ {len(records)} template(s) validated", file=sys.stderr)
|
||||||
|
for r in records:
|
||||||
|
rel = r.path.relative_to(repo_root)
|
||||||
|
print(f" {rel} — {r.manifest['id']} v{r.manifest['version']}")
|
||||||
|
|
||||||
|
if args.check:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
catalog_path = repo_root / "templates" / "catalog.json"
|
||||||
|
write_catalog_json(records, catalog_path)
|
||||||
|
print(f"wrote {catalog_path.relative_to(repo_root)}", file=sys.stderr)
|
||||||
|
|
||||||
|
if args.preview:
|
||||||
|
preview_dir = Path(args.preview).resolve()
|
||||||
|
render_site(records, preview_dir, repo_root)
|
||||||
|
print(f"preview site rendered to {preview_dir}", file=sys.stderr)
|
||||||
|
|
||||||
|
if args.build:
|
||||||
|
# --build renders into .gh-pages-worktree/templates/ so the
|
||||||
|
# maintainer's publish step just has to commit + push gh-pages.
|
||||||
|
gh_pages = repo_root / ".gh-pages-worktree" / "templates"
|
||||||
|
render_site(records, gh_pages, repo_root)
|
||||||
|
print(f"site rendered to {gh_pages.relative_to(repo_root)}", file=sys.stderr)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def render_site(records: list[TemplateRecord], out_dir: Path, repo_root: Path) -> None:
|
||||||
|
"""Render the catalog site. Defined here as a stub so --build and
|
||||||
|
--preview both have a landing spot; the real HTML templates ship in
|
||||||
|
the next commit (Phase 3)."""
|
||||||
|
site_src = repo_root / "site"
|
||||||
|
if not site_src.is_dir():
|
||||||
|
# Phase 2: no site/ yet. Write just catalog.json into out_dir so
|
||||||
|
# the preview mode is still demonstrable (and --build stays
|
||||||
|
# idempotent).
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_catalog_json(records, out_dir / "catalog.json")
|
||||||
|
return
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
index_tmpl = (site_src / "index.html.tmpl").read_text(encoding="utf-8")
|
||||||
|
template_tmpl = (site_src / "template.html.tmpl").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Copy static site assets (widgets.js, styles.css, assets/).
|
||||||
|
for name in ("widgets.js", "styles.css"):
|
||||||
|
src = site_src / name
|
||||||
|
if src.exists():
|
||||||
|
shutil.copy2(src, out_dir / name)
|
||||||
|
assets_src = site_src / "assets"
|
||||||
|
if assets_src.is_dir():
|
||||||
|
assets_dst = out_dir / "assets"
|
||||||
|
if assets_dst.exists():
|
||||||
|
shutil.rmtree(assets_dst)
|
||||||
|
shutil.copytree(assets_src, assets_dst)
|
||||||
|
|
||||||
|
# Catalog index
|
||||||
|
(out_dir / "index.html").write_text(
|
||||||
|
render_index(index_tmpl, records),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-template detail pages + dashboard.json copies
|
||||||
|
for r in records:
|
||||||
|
detail_dir = out_dir / r.detail_slug
|
||||||
|
detail_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(detail_dir / "index.html").write_text(
|
||||||
|
render_detail(template_tmpl, r),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
# Copy the unpacked dashboard.json, README.md, and template.json
|
||||||
|
# (as manifest.json so the site can fetch the config schema for
|
||||||
|
# the Configuration section without conflicting with any file
|
||||||
|
# named `template.json` somewhere else in the served tree).
|
||||||
|
with zipfile.ZipFile(r.bundle_path, "r") as zf:
|
||||||
|
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
|
||||||
|
if "README.md" in zf.namelist():
|
||||||
|
(detail_dir / "README.md").write_bytes(zf.read("README.md"))
|
||||||
|
# Only copy the manifest when the template has a config
|
||||||
|
# schema — avoids bloating the served tree for schema-less
|
||||||
|
# templates and makes the 404 fallback in widgets.js a
|
||||||
|
# meaningful signal ("no config to show here").
|
||||||
|
if r.manifest.get("config"):
|
||||||
|
(detail_dir / "manifest.json").write_bytes(zf.read("template.json"))
|
||||||
|
|
||||||
|
# The aggregate catalog.json is copied in so the frontend can fetch
|
||||||
|
# /templates/catalog.json without reaching back into the repo.
|
||||||
|
write_catalog_json(records, out_dir / "catalog.json")
|
||||||
|
|
||||||
|
|
||||||
|
def render_index(tmpl: str, records: list[TemplateRecord]) -> str:
|
||||||
|
"""Very light string substitution — the site's JS does most of the
|
||||||
|
rendering from catalog.json at page load."""
|
||||||
|
cards = []
|
||||||
|
for r in records:
|
||||||
|
m = r.manifest
|
||||||
|
author = (m.get("author") or {}).get("name", "")
|
||||||
|
tags_html = "".join(f'<span class="tag">{t}</span>' for t in (m.get("tags") or []))
|
||||||
|
cards.append(
|
||||||
|
'<a class="card" href="{slug}/">'
|
||||||
|
'<h3>{name}</h3>'
|
||||||
|
'<p class="desc">{desc}</p>'
|
||||||
|
'<div class="meta"><span class="author">{author}</span>'
|
||||||
|
'<span class="version">v{version}</span></div>'
|
||||||
|
'<div class="tags">{tags}</div>'
|
||||||
|
'</a>'.format(
|
||||||
|
slug=_html_escape(r.detail_slug),
|
||||||
|
name=_html_escape(m["name"]),
|
||||||
|
desc=_html_escape(m["description"]),
|
||||||
|
author=_html_escape(author),
|
||||||
|
version=_html_escape(m["version"]),
|
||||||
|
tags=tags_html,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = len(records)
|
||||||
|
return (
|
||||||
|
tmpl.replace("{{CARDS}}", "\n".join(cards))
|
||||||
|
.replace("{{COUNT}}", str(count))
|
||||||
|
.replace("{{COUNT_PLURAL}}", "" if count == 1 else "s")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_detail(tmpl: str, record: TemplateRecord) -> str:
|
||||||
|
m = record.manifest
|
||||||
|
author = m.get("author") or {}
|
||||||
|
author_html = _html_escape(author.get("name", ""))
|
||||||
|
author_url = author.get("url") or ""
|
||||||
|
if author_url:
|
||||||
|
author_html = f'<a href="{_html_escape(author_url)}">{author_html}</a>'
|
||||||
|
tags_html = "".join(f'<span class="tag">{_html_escape(t)}</span>' for t in (m.get("tags") or []))
|
||||||
|
install_url = record.install_url
|
||||||
|
tokens = {
|
||||||
|
"ID": m["id"],
|
||||||
|
"NAME": m["name"],
|
||||||
|
"VERSION": m["version"],
|
||||||
|
"DESC": m["description"],
|
||||||
|
"AUTHOR_HTML": author_html,
|
||||||
|
"CATEGORY": m.get("category") or "",
|
||||||
|
"TAGS_HTML": tags_html,
|
||||||
|
"INSTALL_URL_ENCODED": install_url,
|
||||||
|
"SCARF_INSTALL_URL": f"scarf://install?url={install_url}",
|
||||||
|
}
|
||||||
|
out = tmpl
|
||||||
|
for k, v in tokens.items():
|
||||||
|
out = out.replace("{{" + k + "}}", _html_escape(v) if k != "TAGS_HTML" and k != "AUTHOR_HTML" else v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _html_escape(s: str) -> str:
|
||||||
|
return (
|
||||||
|
s.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace("'", "'")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
"""Unit tests for tools/build-catalog.py.
|
||||||
|
|
||||||
|
Run with: python3 -m unittest tools.test_build_catalog
|
||||||
|
Or just: python3 tools/test_build_catalog.py
|
||||||
|
|
||||||
|
Covers the validator's invariants against synthetic template directories
|
||||||
|
created under a temp dir — no network, no global state, no dependency on
|
||||||
|
the repo's actual templates/. A separate test at the bottom exercises the
|
||||||
|
real shipped `templates/awizemann/site-status-checker` bundle to catch
|
||||||
|
drift between validator + installer.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# Import tools/build-catalog.py via spec-loader (the dash in the filename
|
||||||
|
# would otherwise make a plain `import` ugly). Register the module in
|
||||||
|
# sys.modules BEFORE exec — Python 3.9's dataclass inspection reads
|
||||||
|
# `sys.modules[cls.__module__].__dict__` and blows up if the module isn't
|
||||||
|
# there yet (fixed in 3.10+, still matters on system-Python Macs).
|
||||||
|
_SPEC_PATH = Path(__file__).resolve().parent / "build-catalog.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("build_catalog", _SPEC_PATH)
|
||||||
|
build_catalog = importlib.util.module_from_spec(_spec)
|
||||||
|
sys.modules["build_catalog"] = build_catalog
|
||||||
|
_spec.loader.exec_module(build_catalog)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixture builders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
MINIMAL_DASHBOARD = {
|
||||||
|
"version": 1,
|
||||||
|
"title": "Test",
|
||||||
|
"description": "test",
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Current Status",
|
||||||
|
"columns": 3,
|
||||||
|
"widgets": [
|
||||||
|
{"type": "stat", "title": "Sites Up", "value": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_fake_repo(tmp_root: Path) -> Path:
|
||||||
|
"""Create a repo layout: <tmp>/templates/ and (optionally) fake
|
||||||
|
site/ dirs on demand. Returns the repo root."""
|
||||||
|
(tmp_root / "templates").mkdir(parents=True)
|
||||||
|
return tmp_root
|
||||||
|
|
||||||
|
|
||||||
|
def make_template_dir(
|
||||||
|
repo: Path,
|
||||||
|
author: str,
|
||||||
|
name: str,
|
||||||
|
manifest: dict | None = None,
|
||||||
|
bundle_files: dict[str, bytes] | None = None,
|
||||||
|
include_staging: bool = True,
|
||||||
|
bundle_name: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Create a template dir under <repo>/templates/<author>/<name>/
|
||||||
|
with a built bundle and (optionally) a staging dir whose contents
|
||||||
|
match the bundle byte-for-byte. Returns the template dir."""
|
||||||
|
template_dir = repo / "templates" / author / name
|
||||||
|
(template_dir / "staging").mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
manifest = manifest or {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": f"{author}/{name}",
|
||||||
|
"name": name.replace("-", " ").title(),
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "test description",
|
||||||
|
"contents": {
|
||||||
|
"dashboard": True,
|
||||||
|
"agentsMd": True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
files = bundle_files or {
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# readme\n",
|
||||||
|
"AGENTS.md": b"# agents\n",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Write staging/ source tree so the drift check passes by default.
|
||||||
|
if include_staging:
|
||||||
|
for path, data in files.items():
|
||||||
|
full = template_dir / "staging" / path
|
||||||
|
full.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
full.write_bytes(data)
|
||||||
|
|
||||||
|
# Write the zipped bundle.
|
||||||
|
bundle_name = bundle_name or f"{name}.scarftemplate"
|
||||||
|
with zipfile.ZipFile(template_dir / bundle_name, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for path, data in files.items():
|
||||||
|
zf.writestr(path, data)
|
||||||
|
|
||||||
|
return template_dir
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestSlugTests(unittest.TestCase):
|
||||||
|
"""Mirrors the Swift test of the same name so the two
|
||||||
|
implementations stay in sync."""
|
||||||
|
|
||||||
|
def test_sanitizes_punctuation(self):
|
||||||
|
self.assertEqual(build_catalog.manifest_slug("alan@w/focus dashboard!"), "alan-w-focus-dashboard")
|
||||||
|
|
||||||
|
def test_falls_back_to_placeholder(self):
|
||||||
|
self.assertEqual(build_catalog.manifest_slug("////"), "template")
|
||||||
|
|
||||||
|
def test_preserves_letters_numbers_dash_underscore(self):
|
||||||
|
self.assertEqual(build_catalog.manifest_slug("user_1/name-2"), "user_1-name-2")
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationTests(unittest.TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.repo = make_fake_repo(Path(self._dir.name))
|
||||||
|
self.addCleanup(self._dir.cleanup)
|
||||||
|
|
||||||
|
def test_accepts_minimal_valid_template(self):
|
||||||
|
make_template_dir(self.repo, "tester", "minimal")
|
||||||
|
records, errors = self._validate_all()
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(len(records), 1)
|
||||||
|
self.assertEqual(records[0].manifest["id"], "tester/minimal")
|
||||||
|
|
||||||
|
def test_rejects_missing_agents_md(self):
|
||||||
|
# Build a bundle that lacks AGENTS.md.
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/bad",
|
||||||
|
"name": "Bad",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "missing AGENTS.md",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "bad",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# readme",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("AGENTS.md" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_content_claim_mismatch(self):
|
||||||
|
# Manifest claims cron: 2, bundle ships zero cron jobs.
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/claims",
|
||||||
|
"name": "Claims",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "claim mismatch",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True, "cron": 2},
|
||||||
|
}
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "claims",
|
||||||
|
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"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("contents.cron=2" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_manifest_author_mismatch(self):
|
||||||
|
# Template lives under /tester/ but manifest id says /other/.
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "other/name",
|
||||||
|
"name": "Mismatch",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "author mismatch",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "name",
|
||||||
|
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"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("author component" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_oversized_bundle(self):
|
||||||
|
# Synthetic bundle > 5MB cap.
|
||||||
|
template_dir = self.repo / "templates" / "tester" / "huge"
|
||||||
|
(template_dir / "staging").mkdir(parents=True)
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/huge",
|
||||||
|
"name": "Huge",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "oversized",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
payload = b"x" * (6 * 1024 * 1024)
|
||||||
|
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"),
|
||||||
|
"ballast.bin": payload,
|
||||||
|
}
|
||||||
|
with zipfile.ZipFile(template_dir / "huge.scarftemplate", "w", zipfile.ZIP_STORED) as zf:
|
||||||
|
for p, data in files.items():
|
||||||
|
zf.writestr(p, data)
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("exceeds catalog cap" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_unknown_widget_type(self):
|
||||||
|
bad_dashboard = {
|
||||||
|
"version": 1,
|
||||||
|
"title": "Bad",
|
||||||
|
"sections": [{"title": "x", "columns": 1, "widgets": [{"type": "hologram", "title": "huh"}]}],
|
||||||
|
}
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/weird",
|
||||||
|
"name": "Weird",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "unknown widget",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "weird",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# readme",
|
||||||
|
"AGENTS.md": b"# agents",
|
||||||
|
"dashboard.json": json.dumps(bad_dashboard).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("unknown type" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_secret_in_bundle(self):
|
||||||
|
leaky = b"config:\n github_token: ghp_" + b"A" * 40 + b"\n"
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/leaky",
|
||||||
|
"name": "Leaky",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "has a secret",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "leaky",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": leaky,
|
||||||
|
"AGENTS.md": b"# agents",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("github" in str(e).lower() for e in errors), errors)
|
||||||
|
|
||||||
|
def test_detects_staging_vs_bundle_drift(self):
|
||||||
|
# Bundle ships an old README; staging/ has an edited one — should fail.
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/drift",
|
||||||
|
"name": "Drift",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "staging ahead of bundle",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
template_dir = make_template_dir(
|
||||||
|
self.repo, "tester", "drift",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# old",
|
||||||
|
"AGENTS.md": b"# agents",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Edit staging/ AFTER building the bundle.
|
||||||
|
(template_dir / "staging" / "README.md").write_bytes(b"# new")
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("differs from built bundle" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_missing_bundle(self):
|
||||||
|
template_dir = self.repo / "templates" / "tester" / "bare"
|
||||||
|
(template_dir / "staging").mkdir(parents=True)
|
||||||
|
# No .scarftemplate in the dir.
|
||||||
|
_, errors = self._validate_all()
|
||||||
|
self.assertTrue(any("no .scarftemplate found" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
# --- helpers --------------------------------------------------------
|
||||||
|
|
||||||
|
def _validate_all(self) -> tuple[list, list]:
|
||||||
|
records = []
|
||||||
|
errors = []
|
||||||
|
for tdir in build_catalog._iter_templates(self.repo):
|
||||||
|
record, errs = build_catalog.validate_template(tdir)
|
||||||
|
errors.extend(errs)
|
||||||
|
if record is not None:
|
||||||
|
errors.extend(build_catalog._check_staging_matches_bundle(record))
|
||||||
|
records.append(record)
|
||||||
|
return records, errors
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigSchemaValidationTests(unittest.TestCase):
|
||||||
|
"""Mirrors the Swift `ProjectConfigServiceTests` schema-validation
|
||||||
|
suite. Every rule enforced on the Swift side must be enforced on
|
||||||
|
the Python side — schema drift is a catastrophic failure for the
|
||||||
|
catalog (CI would accept bundles the app later refuses at install)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self._dir = tempfile.TemporaryDirectory()
|
||||||
|
self.repo = make_fake_repo(Path(self._dir.name))
|
||||||
|
self.addCleanup(self._dir.cleanup)
|
||||||
|
|
||||||
|
def _make_schema_manifest(self, fields, cron: int = 0):
|
||||||
|
"""Convenience — build a v2 manifest with the given config fields."""
|
||||||
|
return {
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"id": "tester/configured",
|
||||||
|
"name": "Configured",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "test",
|
||||||
|
"contents": {
|
||||||
|
"dashboard": True,
|
||||||
|
"agentsMd": True,
|
||||||
|
"cron": cron,
|
||||||
|
"config": len(fields),
|
||||||
|
},
|
||||||
|
"config": {"schema": fields},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_accepts_schemaful_bundle(self):
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{"key": "name", "type": "string", "label": "Name", "required": True},
|
||||||
|
{"key": "enabled", "type": "bool", "label": "Enabled"},
|
||||||
|
])
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "configured",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# readme",
|
||||||
|
"AGENTS.md": b"# agents",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
records = []
|
||||||
|
errors = []
|
||||||
|
for tdir in build_catalog._iter_templates(self.repo):
|
||||||
|
rec, errs = build_catalog.validate_template(tdir)
|
||||||
|
errors.extend(errs)
|
||||||
|
if rec is not None:
|
||||||
|
records.append(rec)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
self.assertEqual(len(records), 1)
|
||||||
|
self.assertEqual(records[0].manifest["schemaVersion"], 2)
|
||||||
|
|
||||||
|
def test_rejects_duplicate_keys(self):
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{"key": "same", "type": "string", "label": "A"},
|
||||||
|
{"key": "same", "type": "bool", "label": "B"},
|
||||||
|
])
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "dup",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertTrue(any("duplicate key" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_secret_with_default(self):
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{
|
||||||
|
"key": "api_key", "type": "secret", "label": "API Key",
|
||||||
|
"required": True, "default": "sk-leaked-in-template"
|
||||||
|
},
|
||||||
|
])
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "secret-default",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertTrue(any("must not declare a default" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_enum_without_options(self):
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{"key": "choice", "type": "enum", "label": "Choice", "options": []},
|
||||||
|
])
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "enum-empty",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertTrue(any("at least one option" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_unsupported_field_type(self):
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{"key": "wat", "type": "hologram", "label": "W"},
|
||||||
|
])
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "bad-type",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertTrue(any("unsupported type" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_contents_config_count_mismatch(self):
|
||||||
|
# Schema has 1 field; contents.config claims 2.
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{"key": "only", "type": "string", "label": "Only"},
|
||||||
|
])
|
||||||
|
manifest["contents"]["config"] = 2
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "mismatch",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertTrue(any("contents.config=2" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_rejects_unsupported_list_item_type(self):
|
||||||
|
manifest = self._make_schema_manifest([
|
||||||
|
{"key": "items", "type": "list", "label": "Items", "itemType": "number"},
|
||||||
|
])
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "list-type",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertTrue(any("unsupported itemType" in str(e) for e in errors), errors)
|
||||||
|
|
||||||
|
def test_accepts_schemaless_v1_manifest_unchanged(self):
|
||||||
|
# Pre-v2.3 bundles without any config block should keep working.
|
||||||
|
manifest = {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"id": "tester/legacy",
|
||||||
|
"name": "Legacy",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "no config",
|
||||||
|
"contents": {"dashboard": True, "agentsMd": True},
|
||||||
|
}
|
||||||
|
make_template_dir(
|
||||||
|
self.repo, "tester", "legacy",
|
||||||
|
manifest=manifest,
|
||||||
|
bundle_files={
|
||||||
|
"template.json": json.dumps(manifest).encode("utf-8"),
|
||||||
|
"README.md": b"# r", "AGENTS.md": b"# a",
|
||||||
|
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
errors = self._collect_errors()
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
|
||||||
|
def _collect_errors(self):
|
||||||
|
errors = []
|
||||||
|
for tdir in build_catalog._iter_templates(self.repo):
|
||||||
|
rec, errs = build_catalog.validate_template(tdir)
|
||||||
|
errors.extend(errs)
|
||||||
|
if rec is not None:
|
||||||
|
errors.extend(build_catalog._check_staging_matches_bundle(rec))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogJsonTests(unittest.TestCase):
|
||||||
|
"""Shape of the emitted catalog.json must stay stable — the site's
|
||||||
|
widgets.js reads these fields by name."""
|
||||||
|
|
||||||
|
def test_catalog_json_shape(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = make_fake_repo(Path(tmp))
|
||||||
|
make_template_dir(repo, "tester", "shape")
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for tdir in build_catalog._iter_templates(repo):
|
||||||
|
record, errors = build_catalog.validate_template(tdir)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
records.append(record)
|
||||||
|
|
||||||
|
out = Path(tmp) / "catalog.json"
|
||||||
|
build_catalog.write_catalog_json(records, out)
|
||||||
|
data = json.loads(out.read_text())
|
||||||
|
|
||||||
|
self.assertEqual(data["schemaVersion"], 1)
|
||||||
|
self.assertEqual(len(data["templates"]), 1)
|
||||||
|
entry = data["templates"][0]
|
||||||
|
for required in ["id", "name", "version", "description", "contents",
|
||||||
|
"installUrl", "detailSlug", "bundleSha256", "bundleSize"]:
|
||||||
|
self.assertIn(required, entry)
|
||||||
|
self.assertTrue(entry["installUrl"].startswith("https://raw.githubusercontent.com/"))
|
||||||
|
self.assertEqual(entry["detailSlug"], "tester-shape")
|
||||||
|
|
||||||
|
|
||||||
|
class SiteRenderingTests(unittest.TestCase):
|
||||||
|
"""Verify the regenerator produces usable HTML + copies dashboard.json
|
||||||
|
+ README.md into each detail dir for widgets.js to fetch. No browser
|
||||||
|
automation — just shape checks so we catch silly breakages
|
||||||
|
(missing tokens, stale templates, broken copy)."""
|
||||||
|
|
||||||
|
def test_render_site_end_to_end(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = make_fake_repo(Path(tmp))
|
||||||
|
# Build a couple templates so the grid has more than one card.
|
||||||
|
make_template_dir(repo, "alice", "alpha")
|
||||||
|
make_template_dir(repo, "bob", "beta")
|
||||||
|
|
||||||
|
# Give the fake repo a site/ dir so render_site produces HTML.
|
||||||
|
site_src = repo / "site"
|
||||||
|
site_src.mkdir()
|
||||||
|
(site_src / "index.html.tmpl").write_text(
|
||||||
|
"<h1>Catalog ({{COUNT}} template{{COUNT_PLURAL}})</h1>{{CARDS}}"
|
||||||
|
)
|
||||||
|
(site_src / "template.html.tmpl").write_text(
|
||||||
|
"<h1>{{NAME}}</h1><p>{{DESC}}</p>"
|
||||||
|
"<a href=\"{{SCARF_INSTALL_URL}}\">install</a>"
|
||||||
|
"<a href=\"{{INSTALL_URL_ENCODED}}\">download</a>"
|
||||||
|
)
|
||||||
|
(site_src / "widgets.js").write_text("/* test widgets */")
|
||||||
|
(site_src / "styles.css").write_text("/* test styles */")
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for tdir in build_catalog._iter_templates(repo):
|
||||||
|
r, errors = build_catalog.validate_template(tdir)
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
records.append(r)
|
||||||
|
|
||||||
|
out = Path(tmp) / "out"
|
||||||
|
build_catalog.render_site(records, out, repo)
|
||||||
|
|
||||||
|
# Index: both cards present, plural form flipped for count=2.
|
||||||
|
idx = (out / "index.html").read_text()
|
||||||
|
self.assertIn("Catalog (2 templates)", idx)
|
||||||
|
self.assertIn("alice-alpha/", idx)
|
||||||
|
self.assertIn("bob-beta/", idx)
|
||||||
|
|
||||||
|
# Static assets copied.
|
||||||
|
self.assertTrue((out / "widgets.js").exists())
|
||||||
|
self.assertTrue((out / "styles.css").exists())
|
||||||
|
self.assertTrue((out / "catalog.json").exists())
|
||||||
|
|
||||||
|
# Each detail dir has index.html + dashboard.json + README.md.
|
||||||
|
alpha = out / "alice-alpha"
|
||||||
|
self.assertTrue((alpha / "index.html").exists())
|
||||||
|
self.assertTrue((alpha / "dashboard.json").exists())
|
||||||
|
self.assertTrue((alpha / "README.md").exists())
|
||||||
|
|
||||||
|
alpha_html = (alpha / "index.html").read_text()
|
||||||
|
# Install URL wires through the scarf:// scheme + raw GH URL.
|
||||||
|
self.assertIn("scarf://install?url=https://raw.githubusercontent.com/", alpha_html)
|
||||||
|
|
||||||
|
def test_render_index_singular_form_for_one_template(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
repo = make_fake_repo(Path(tmp))
|
||||||
|
make_template_dir(repo, "alice", "alpha")
|
||||||
|
records = []
|
||||||
|
for tdir in build_catalog._iter_templates(repo):
|
||||||
|
r, _ = build_catalog.validate_template(tdir)
|
||||||
|
records.append(r)
|
||||||
|
html = build_catalog.render_index("{{COUNT}} template{{COUNT_PLURAL}}", records)
|
||||||
|
self.assertEqual(html, "1 template")
|
||||||
|
|
||||||
|
|
||||||
|
class RealBundleTest(unittest.TestCase):
|
||||||
|
"""Run the validator against the actual shipped Site Status Checker
|
||||||
|
bundle. Catches drift between validator + real-world author
|
||||||
|
conventions. Skipped if run outside the repo tree."""
|
||||||
|
|
||||||
|
def test_site_status_checker_passes(self):
|
||||||
|
repo_root = Path(__file__).resolve().parent.parent
|
||||||
|
template = repo_root / "templates" / "awizemann" / "site-status-checker"
|
||||||
|
if not template.exists():
|
||||||
|
self.skipTest("site-status-checker not present (running outside repo?)")
|
||||||
|
record, errors = build_catalog.validate_template(template)
|
||||||
|
self.assertIsNotNone(record)
|
||||||
|
drift = build_catalog._check_staging_matches_bundle(record)
|
||||||
|
self.assertEqual(errors + drift, [], f"errors: {errors}, drift: {drift}")
|
||||||
|
self.assertEqual(record.manifest["id"], "awizemann/site-status-checker")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user