Compare commits

...

5 Commits

Author SHA1 Message Date
Alan Wizemann 55229a2f91 feat(templates): upgrade site-status-checker to v1.1.0 with config schema
First real exercise of the v2.3 configuration feature. The template no
longer asks the agent to bootstrap sites.txt on first run — instead,
users enter their list of URLs through the Configure form during
install, and change them later via the dashboard's Configuration
button. This makes the template a complete round-trip test of the
new feature end-to-end.

Schema (manifest.config.schema):
- `sites` — list<string>, required, 1–25 items, default two example
  URLs. This is the list the cron job hits.
- `timeout_seconds` — number, 1–60, default 10. Per-URL HTTP timeout.
- `modelRecommendation.preferred = claude-haiku-4` — rationale: simple
  tool-use task, Haiku is cost-effective for daily cron.

Manifest bumped: schemaVersion 1 → 2, version 1.0.0 → 1.1.0,
minScarfVersion 2.2.0 → 2.3.0, contents.config = 2.

AGENTS.md rewritten for the config-driven flow:
- Reads values from `.scarf/config.json` at run time (values.sites +
  values.timeout_seconds). No more sites.txt bootstrap.
- "Add a site" / "Remove a site" no longer mean the agent edits a
  file — they mean "open the Configuration button on the dashboard."
  The agent points the user there rather than trying to mutate
  config.json itself. A future Scarf release may expose a tool for
  agents to write config programmatically; until then, config is
  strictly a user action.
- First-run bootstrap now only creates status-log.md (if absent).

README.md rewritten to walk users through the new form-based flow,
explain the Configuration button, and document the model
recommendation. Uninstall instructions point at the right-click
Uninstall Template action rather than manual steps.

Cron prompt updated to reference config.json (values.sites,
values.timeout_seconds) instead of sites.txt.

ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
extended with v2-specific assertions: manifest.schemaVersion == 2,
contents.config == 2, schema.fields.count == 2, per-field
constraints (sites type/itemType/minItems/maxItems, timeout
min/max), modelRecommendation.preferred, plan.configSchema +
plan.manifestCachePath are populated, plan.projectFiles includes
both config.json + manifest.json destinations. Cron-prompt assertion
swapped from sites.txt to config.json/values.sites.

Three suites that touch ~/.hermes/scarf/projects.json now carry
.serialized — the new Phase B install-with-config tests stressed the
parallel-execution race in the snapshot/restore helpers. Serializing
within each suite deflakes without any architectural change.

Swift 50/50, Python 24/24, catalog validator accepts the upgraded
bundle. Site detail page now has manifest.json for renderConfigSchema
to pick up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:21:14 +02:00
Alan Wizemann 99859c06fd docs: CLAUDE.md — add Template Configuration section
Documents the v2.3 configuration feature for future agent sessions:
manifest schemaVersion 2 shape, supported field types, Keychain storage
conventions (service/account naming with project-path hash suffix), the
uninstaller's config-items cleanup path, exporter behaviour (schema
forwarded, values stripped), and the catalog site's schema display.

Includes the "Schema is Swift-primary" note so future edits to
TemplateConfigField.FieldType go through the right order of updates —
Swift first, then Python mirror, then widgets.js, then UI controls,
then tests on both sides. Schema drift between Swift + Python
validator would accept bundles the app later refuses at install
time, which is a catastrophic UX failure for the catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:03:12 +02:00
Alan Wizemann 9f3600ae01 feat(catalog-config): mirror manifest v2 schema in validator + site
Phase D of v2.3 template configuration — closes the loop between the
Swift app and the catalog pipeline. Authors can now ship schemaful
bundles; the Python validator enforces the same invariants the Swift
installer does; the catalog site displays the schema so visitors see
what they'll need to configure before installing.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 02:00:34 +02:00
Alan Wizemann b34f432f00 feat(config): configure-step UI + post-install Configuration editor
Adds the user-facing side of v2.3 template configuration. Install-time
flow: templates with a non-empty config.schema get a Configure step
between the parent-directory pick and the preview sheet. Post-install
flow: a Configuration button on the dashboard header + a context-menu
entry on the project list opens the same form pre-filled with current
values.

New files:
- Features/Templates/ViewModels/TemplateConfigViewModel.swift — drives
  the form. Keeps freshly-entered secret bytes in `pendingSecrets`
  in-memory until commit() succeeds, then calls
  ProjectConfigService.storeSecret for each one. Cancelling never
  leaves orphan Keychain entries — the form is transactional.
  Validates via ProjectConfigService.validateValues on commit and
  populates per-field `errors` the sheet surfaces inline. Two modes:
  .install (needs a project passed at commit time) and
  .edit(project:) (VM already holds the target).
- Features/Templates/Views/TemplateConfigSheet.swift — the form. One
  row per field with a control dispatched by type: TextField (string),
  TextEditor (text), number input, Toggle (bool), segmented/dropdown
  Picker (enum, picks form by option count), add/remove list editor,
  SecureField with show/hide toggle (secret). Required-field asterisk
  + per-field error display. Optional modelRecommendation panel at
  the bottom — informational badge; no auto-switch.
- Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift —
  loads <project>/.scarf/manifest.json + config.json, hands a
  TemplateConfigViewModel to the sheet, writes edited values back on
  commit. Has a .notConfigurable stage for projects without a
  manifest cache (hand-added projects, schema-less templates).
- Features/Templates/Views/ConfigEditorSheet.swift — thin wrapper
  that owns the editor VM and routes its stages to loading / form /
  saving / success / error / not-configurable views.

Wiring:
- TemplateInstallerViewModel gains an .awaitingConfig stage between
  .awaitingParentDirectory and .planned. pickParentDirectory() now
  inspects plan.configSchema and either routes to .awaitingConfig
  (non-empty schema) or straight to .planned (schema-less). New
  submitConfig(values:) stashes finalized values in plan.configValues
  and advances; cancelConfig() returns to .awaitingParentDirectory.
- TemplateInstallSheet renders a new `configureView` that inlines
  TemplateConfigSheet into the install flow for .awaitingConfig.
  The existing preview (.planned) gains a new "Configuration" section
  listing each field + its display value (secrets shown as "••••••
  (Keychain)", lists shown as "first + N more", "(not set)" for
  missing values).
- ProjectsView adds an isConfigurable(_:) check (transport.fileExists
  on .scarf/manifest.json), a new @State configEditorProject for
  sheet presentation, a new "Configuration…" context-menu entry on
  project list rows (for configurable projects), and a new
  slider.horizontal.3 button on the dashboard header next to the
  existing Uninstall button.

50/50 tests still pass. This commit is UI-only — no new Phase C tests
(sheet behaviour is hard to unit-test without UI automation and the
underlying VM logic is exercised by Phase A/B's config-round-trip
tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:46:21 +02:00
Alan Wizemann b289a83944 iOS Target Set Up
xcode mobile target creation
2026-04-23 01:42:25 +02:00
32 changed files with 2406 additions and 67 deletions
+37
View File
@@ -105,6 +105,43 @@ Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTe
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
### 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.
@@ -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
}
}
+61
View File
@@ -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)
}
+10
View File
@@ -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>
+18
View File
@@ -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
}
}
+14
View File
@@ -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>
+32
View File
@@ -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)
}
}
+17
View File
@@ -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 its 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)
}
}
+402 -1
View File
@@ -12,6 +12,20 @@
/* End PBXBuildFile 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 */ = {
isa = PBXContainerItemProxy;
containerPortal = 534959382F7B83B600BD31AD /* Project object */;
@@ -29,12 +43,22 @@
/* End PBXContainerItemProxy 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; };
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; };
/* End PBXFileReference 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 */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -45,6 +69,24 @@
/* End PBXFileSystemSynchronizedBuildFileExceptionSet 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 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
@@ -66,6 +108,27 @@
/* End PBXFileSystemSynchronizedRootGroup 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 */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -98,6 +161,9 @@
534959422F7B83B600BD31AD /* scarf */,
534959522F7B83B700BD31AD /* scarfTests */,
5349595C2F7B83B700BD31AD /* scarfUITests */,
4EAC232A2F99930000654F42 /* Scarf iOS */,
4EAC233C2F99930100654F42 /* Scarf iOSTests */,
4EAC23462F99930100654F42 /* Scarf iOSUITests */,
534959412F7B83B600BD31AD /* Products */,
);
sourceTree = "<group>";
@@ -108,6 +174,9 @@
534959402F7B83B600BD31AD /* scarf.app */,
5349594F2F7B83B700BD31AD /* scarfTests.xctest */,
534959592F7B83B700BD31AD /* scarfUITests.xctest */,
4EAC23292F99930000654F42 /* scarf mobile.app */,
4EAC23392F99930100654F42 /* Scarf iOSTests.xctest */,
4EAC23432F99930100654F42 /* Scarf iOSUITests.xctest */,
);
name = Products;
sourceTree = "<group>";
@@ -115,6 +184,74 @@
/* End PBXGroup 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 */ = {
isa = PBXNativeTarget;
buildConfigurationList = 534959632F7B83B700BD31AD /* Build configuration list for PBXNativeTarget "scarf" */;
@@ -192,9 +329,20 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 2630;
LastSwiftUpdateCheck = 2620;
LastUpgradeCheck = 2630;
TargetAttributes = {
4EAC23282F99930000654F42 = {
CreatedOnToolsVersion = 26.2;
};
4EAC23382F99930100654F42 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 4EAC23282F99930000654F42;
};
4EAC23422F99930100654F42 = {
CreatedOnToolsVersion = 26.2;
TestTargetID = 4EAC23282F99930000654F42;
};
5349593F2F7B83B600BD31AD = {
CreatedOnToolsVersion = 26.3;
};
@@ -235,11 +383,35 @@
5349593F2F7B83B600BD31AD /* scarf */,
5349594E2F7B83B700BD31AD /* scarfTests */,
534959582F7B83B700BD31AD /* scarfUITests */,
4EAC23282F99930000654F42 /* scarf mobile */,
4EAC23382F99930100654F42 /* Scarf iOSTests */,
4EAC23422F99930100654F42 /* Scarf iOSUITests */,
);
};
/* End PBXProject 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 */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -264,6 +436,27 @@
/* End PBXResourcesBuildPhase 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 */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -288,6 +481,16 @@
/* End PBXSourcesBuildPhase 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 */ = {
isa = PBXTargetDependency;
target = 5349593F2F7B83B600BD31AD /* scarf */;
@@ -301,6 +504,175 @@
/* End PBXTargetDependency 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 */ = {
isa = XCBuildConfiguration;
buildSettings = {
@@ -444,6 +816,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -479,6 +852,7 @@
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@@ -584,6 +958,33 @@
/* End XCBuildConfiguration 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" */ = {
isa = XCConfigurationList;
buildConfigurations = (
@@ -26,6 +26,7 @@ struct ProjectsView: View {
@State private var showingInstallURLPrompt = false
@State private var installURLInput = ""
@State private var showingUninstallSheet = false
@State private var configEditorProject: ProjectEntry?
private let uninstaller: ProjectTemplateUninstaller
@@ -36,6 +37,14 @@ struct ProjectsView: View {
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
var body: some View {
@@ -106,6 +115,12 @@ struct ProjectsView: View {
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
}
}
.sheet(item: $configEditorProject) { project in
ConfigEditorSheet(
context: serverContext,
project: project
)
}
}
// MARK: - Toolbar
@@ -221,6 +236,11 @@ struct ProjectsView: View {
}
.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)
@@ -383,6 +403,15 @@ struct ProjectsView: View {
Image(systemName: "folder")
}
.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)
@@ -0,0 +1,118 @@
import Foundation
import Observation
import os
/// Drives the post-install "Configuration" button on the project
/// dashboard. Loads `<project>/.scarf/manifest.json` + `config.json`,
/// hands a `TemplateConfigViewModel` seeded with current values to the
/// sheet, then writes the edited values back on commit.
///
/// Smaller surface than `TemplateInstallerViewModel` no unzipping,
/// no parent-dir picking, no cron CLI. Just: read edit save.
@Observable
@MainActor
final class TemplateConfigEditorViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel")
enum Stage: Sendable {
case idle
case loading
/// Manifest + config loaded; the sheet is displaying the form.
case editing
case saving
case succeeded
case failed(String)
/// Project wasn't installed from a schemaful template no
/// manifest cache on disk. The dashboard button is hidden in
/// this case so we shouldn't hit this stage normally.
case notConfigurable
}
let context: ServerContext
let project: ProjectEntry
private let configService: ProjectConfigService
init(context: ServerContext, project: ProjectEntry) {
self.context = context
self.project = project
self.configService = ProjectConfigService(context: context)
}
var stage: Stage = .idle
var manifest: ProjectTemplateManifest?
var currentValues: [String: TemplateConfigValue] = [:]
/// Non-nil while `.editing`; used to construct the sheet's VM.
var formViewModel: TemplateConfigViewModel?
/// Load the cached manifest + current config values, then move to
/// `.editing` so the sheet can render the form.
func begin() {
stage = .loading
let service = configService
let project = project
Task.detached { [weak self] in
do {
guard let cachedManifest = try service.loadCachedManifest(project: project),
let schema = cachedManifest.config,
!schema.isEmpty else {
await MainActor.run { [weak self] in
self?.stage = .notConfigurable
}
return
}
let configFile = try service.load(project: project)
await MainActor.run { [weak self] in
guard let self else { return }
self.manifest = cachedManifest
self.currentValues = configFile?.values ?? [:]
self.formViewModel = TemplateConfigViewModel(
schema: schema,
templateId: cachedManifest.id,
templateSlug: cachedManifest.slug,
initialValues: self.currentValues,
mode: .edit(project: project)
)
self.stage = .editing
}
} catch {
Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
/// Called when the sheet's commit succeeded. Persists the edited
/// values to `<project>/.scarf/config.json`. Secrets are already
/// in the Keychain the VM's commit step wrote them.
func save(values: [String: TemplateConfigValue]) {
guard let manifest else { return }
stage = .saving
let service = configService
let project = project
Task.detached { [weak self] in
do {
try service.save(
project: project,
templateId: manifest.id,
values: values
)
await MainActor.run { [weak self] in
self?.stage = .succeeded
}
} catch {
Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func cancel() {
stage = .idle
formViewModel = nil
}
}
@@ -0,0 +1,198 @@
import Foundation
import Observation
import os
/// Drives the configure form for template install + post-install editing.
///
/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes
/// in-memory (`pendingSecrets`) until the user clicks the commit button.
/// Only then does `commit()` push each secret through
/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI.
/// This means cancelling the sheet never leaves an orphan Keychain
/// entry behind the form is transactional from the user's POV.
///
/// **Validation.** Runs via `ProjectConfigService.validateValues` every
/// time the user attempts to commit. Per-field errors are tracked in
/// `errors` so the sheet can surface them inline with the offending field.
/// No live validation on every keystroke that creates a messy
/// "error appears the moment you start typing" UX.
@Observable
@MainActor
final class TemplateConfigViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel")
enum Mode: Sendable {
/// User is filling in values for the first time as part of the
/// install flow. Secrets will be written to the Keychain when
/// `commit` succeeds.
case install
/// User is editing values for an already-installed project.
/// Existing keychain refs are preserved for fields the user
/// doesn't touch; only secrets the user actually changes get
/// re-written to the Keychain.
case edit(project: ProjectEntry)
}
let schema: TemplateConfigSchema
let templateId: String
let templateSlug: String
let mode: Mode
private let configService: ProjectConfigService
/// Current form values, keyed by field key. Non-secret values live
/// here directly; secret fields either hold a `.keychainRef(...)`
/// (existing, untouched in edit mode) or nothing at all (user
/// hasn't entered a secret yet, or they just cleared it).
var values: [String: TemplateConfigValue] = [:]
/// Raw secret bytes waiting to be written to the Keychain on
/// `commit()`. Indexed by field key. `values[key]` stays as its
/// current `.keychainRef(...)` (for edit mode) or missing (for
/// install mode) until commit swaps it for the freshly-written
/// ref URI.
var pendingSecrets: [String: Data] = [:]
/// One error per field with a problem. Populated by `commit()` on
/// validation failure; the sheet surfaces the message inline below
/// the offending control.
var errors: [String: String] = [:]
init(
schema: TemplateConfigSchema,
templateId: String,
templateSlug: String,
initialValues: [String: TemplateConfigValue] = [:],
mode: Mode,
configService: ProjectConfigService = ProjectConfigService()
) {
self.schema = schema
self.templateId = templateId
self.templateSlug = templateSlug
self.mode = mode
self.configService = configService
self.values = Self.applyDefaults(schema: schema, initial: initialValues)
}
// MARK: - Field setters (the sheet calls these as controls change)
func setString(_ key: String, _ value: String) {
values[key] = .string(value)
errors.removeValue(forKey: key)
}
func setNumber(_ key: String, _ value: Double) {
values[key] = .number(value)
errors.removeValue(forKey: key)
}
func setBool(_ key: String, _ value: Bool) {
values[key] = .bool(value)
errors.removeValue(forKey: key)
}
func setList(_ key: String, _ items: [String]) {
values[key] = .list(items)
errors.removeValue(forKey: key)
}
/// Stage a new secret value. Doesn't hit the Keychain until
/// `commit()`. An empty `value` clears both the pending secret and
/// the field's stored keychainRef only valid in edit mode, where
/// "empty" means "I want to remove this secret."
func setSecret(_ key: String, _ value: String) {
if value.isEmpty {
pendingSecrets.removeValue(forKey: key)
values.removeValue(forKey: key)
} else {
pendingSecrets[key] = Data(value.utf8)
// Keep any existing ref around; the sheet can display
// "(changed)" while the ref is still the old one. commit()
// overwrites on disk.
}
errors.removeValue(forKey: key)
}
// MARK: - Commit
/// Validate, persist secrets to the Keychain, and hand back the
/// final values dictionary. On validation failure, `errors` is
/// populated and the method returns `nil` without touching the
/// Keychain the form is transactional.
///
/// In install mode, `project` is required (secrets need a path
/// hash for their Keychain account). In edit mode it falls out of
/// the `.edit(project:)` associated value.
func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? {
// Build the value set we're about to validate. For secrets
// that have a pending update, we treat them as present (we'll
// write them in a moment); for secrets already stored as
// keychainRef, we treat them as present too. Only a completely
// empty secret field is "missing."
var candidate = values
for key in pendingSecrets.keys {
// The field is about to have a fresh keychainRef for
// validation purposes, use a placeholder ref so the type
// check passes. The real ref replaces it below.
candidate[key] = .keychainRef("pending://\(key)")
}
let validationErrors = ProjectConfigService.validateValues(candidate, against: schema)
guard validationErrors.isEmpty else {
var byField: [String: String] = [:]
for err in validationErrors {
guard let key = err.fieldKey else { continue }
byField[key] = err.message
}
self.errors = byField
return nil
}
// Validation passed write the pending secrets to the Keychain.
let targetProject: ProjectEntry
switch mode {
case .install:
guard let project else {
Self.logger.error("commit(project:) called in install mode without a project")
return nil
}
targetProject = project
case .edit(let proj):
targetProject = proj
}
for (key, secret) in pendingSecrets {
do {
let ref = try configService.storeSecret(
templateSlug: templateSlug,
fieldKey: key,
project: targetProject,
secret: secret
)
values[key] = ref
} catch {
Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)")
errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)"
return nil
}
}
pendingSecrets.removeAll()
errors.removeAll()
return values
}
// MARK: - Helpers
/// Seed the form with any author-supplied defaults for fields that
/// don't already have an initial value (from a saved config.json).
nonisolated private static func applyDefaults(
schema: TemplateConfigSchema,
initial: [String: TemplateConfigValue]
) -> [String: TemplateConfigValue] {
var out = initial
for field in schema.fields where out[field.key] == nil {
if let def = field.defaultValue {
out[field.key] = def
}
}
return out
}
}
@@ -18,6 +18,10 @@ final class TemplateInstallerViewModel {
case fetching(sourceDescription: String)
case 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)
@@ -139,14 +143,20 @@ final class TemplateInstallerViewModel {
guard let inspection else { return }
chosenParentDirectory = parentDir
let service = templateService
let context = context
Task.detached { [weak self] in
do {
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
_ = context
await MainActor.run { [weak self] in
self?.plan = plan
self?.stage = .planned
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
@@ -156,6 +166,26 @@ final class TemplateInstallerViewModel {
}
}
/// Called by `TemplateInstallSheet` once the user has filled in
/// the configure form and `TemplateConfigViewModel.commit()`
/// succeeded. Stashes the values in the plan and advances to the
/// preview stage (`.planned`). Secrets in `values` are already
/// `.keychainRef(...)` the VM's commit step wrote them to the
/// Keychain.
func submitConfig(values: [String: TemplateConfigValue]) {
guard var plan else { return }
plan.configValues = values
self.plan = plan
stage = .planned
}
/// Called when the user cancels out of the configure step without
/// committing. Returns to `.awaitingParentDirectory` so they can
/// try again (or dismiss the whole sheet).
func cancelConfig() {
stage = .awaitingParentDirectory
}
func confirmInstall() {
guard let plan else { return }
stage = .installing
@@ -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)
}
}
}
}
@@ -21,6 +21,8 @@ struct TemplateInstallSheet: View {
progress("Inspecting template…")
case .awaitingParentDirectory:
pickParentView
case .awaitingConfig:
configureView
case .planned:
if let plan = viewModel.plan {
plannedView(plan: plan)
@@ -85,6 +87,39 @@ struct TemplateInstallSheet: View {
}
}
/// Configure step for schemaful templates. Inlines
/// `TemplateConfigSheet` into the install flow rather than pushing
/// a second sheet on top keeps the user in one window. The
/// nested VM is created freshly each time `.awaitingConfig` is
/// entered so a Cancel + retry doesn't carry stale form state.
@ViewBuilder
private var configureView: some View {
if let plan = viewModel.plan,
let schema = plan.configSchema,
let manifest = viewModel.inspection?.manifest {
TemplateConfigSheet(
viewModel: TemplateConfigViewModel(
schema: schema,
templateId: manifest.id,
templateSlug: manifest.slug,
initialValues: plan.configValues,
mode: .install
),
title: "Configure \(manifest.name)",
commitLabel: "Continue",
project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir),
onCommit: { values in
viewModel.submitConfig(values: values)
},
onCancel: {
viewModel.cancelConfig()
}
)
} else {
progress("Preparing…")
}
}
private func plannedView(plan: TemplateInstallPlan) -> some View {
VStack(alignment: .leading, spacing: 0) {
manifestHeader(plan.manifest)
@@ -102,6 +137,9 @@ struct TemplateInstallSheet: View {
if plan.memoryAppendix != nil {
memorySection(plan: plan)
}
if let schema = plan.configSchema, !schema.isEmpty {
configurationSection(plan: plan, schema: schema)
}
readmeSection
}
.padding(.vertical)
@@ -213,6 +251,50 @@ struct TemplateInstallSheet: View {
}
}
/// Configuration values the user entered in the configure step.
/// Secrets display masked so the preview never echoes a freshly
/// typed API key back on screen.
private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View {
section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") {
VStack(alignment: .leading, spacing: 4) {
ForEach(schema.fields) { field in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(field.key)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.frame(minWidth: 120, alignment: .leading)
Text(displayValue(for: field, in: plan.configValues))
.font(.caption)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
}
}
/// One-line display form for a value in the preview. Secrets are
/// always masked; lists show a count + first entry; strings are
/// truncated by `.lineLimit(1)` at the view level.
private func displayValue(
for field: TemplateConfigField,
in values: [String: TemplateConfigValue]
) -> String {
switch field.type {
case .secret:
return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)"
case .list:
if case .list(let items) = values[field.key] {
if items.isEmpty { return "(none)" }
if items.count == 1 { return items[0] }
return "\(items[0]) + \(items.count - 1) more"
}
return "(none)"
default:
return values[field.key]?.displayString ?? "(not set)"
}
}
private var readmeSection: some View {
Group {
// The body is preloaded in the VM off MainActor when inspection
+33 -6
View File
@@ -253,7 +253,7 @@ import Foundation
/// 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 struct ProjectTemplateInstallerTests {
@Suite(.serialized) struct ProjectTemplateInstallerTests {
@Test func installsMinimalBundleAndWritesLockFile() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
@@ -370,7 +370,7 @@ import Foundation
/// 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 struct ProjectTemplateUninstallerTests {
@Suite(.serialized) struct ProjectTemplateUninstallerTests {
@Test func roundTripsInstallThenUninstall() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
@@ -496,7 +496,7 @@ import Foundation
/// 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 struct ProjectTemplateConfigInstallTests {
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
/// Minimal schemaful manifest with one non-secret field + one
/// secret field. Written into the synthesized `.scarftemplate`
@@ -781,13 +781,31 @@ import Foundation
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)
@@ -795,6 +813,12 @@ import Foundation
#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")
@@ -820,10 +844,13 @@ import Foundation
#expect(statTitles.contains("Sites Down"))
#expect(statTitles.contains("Last Checked"))
// The cron prompt mentions sites.txt and dashboard.json if it
// ever stops doing that, the agent won't know what files to touch.
// 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("sites.txt"))
#expect(cronPrompt.contains("config.json"))
#expect(cronPrompt.contains("values.sites"))
#expect(cronPrompt.contains("dashboard.json"))
#expect(cronPrompt.contains("status-log.md"))
}
+100
View File
@@ -233,6 +233,106 @@ h1, h2, h3 { line-height: 1.25; }
padding: 24px;
}
/* ---------- config schema panel (v2.3) ---------- */
.detail-config { margin-bottom: 32px; }
.detail-config:empty, .detail-config > div:empty { display: none; }
.config-schema {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 24px;
}
.config-schema-header { margin-top: 0; }
.config-schema-desc {
color: var(--fg-muted);
font-size: 13px;
margin-top: 4px;
margin-bottom: 16px;
}
.config-schema-list {
margin: 0;
padding: 0;
display: grid;
grid-template-columns: 1fr;
gap: 12px;
}
.config-field-header {
display: flex;
align-items: baseline;
gap: 8px;
margin-top: 4px;
font-weight: 500;
}
.config-field-key { font-family: var(--mono); font-size: 13px; }
.config-field-type {
font-family: var(--mono);
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(0,0,0,0.08);
color: var(--fg-muted);
}
.config-field-required {
font-size: 11px;
color: var(--red);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 6px;
border-radius: 10px;
background: rgba(217,83,79,0.12);
}
.config-field-body {
margin: 0 0 4px 0;
padding-left: 0;
font-size: 14px;
}
.config-field-label {
font-size: 14px;
margin-bottom: 2px;
}
.config-field-description {
color: var(--fg-muted);
font-size: 13px;
margin-bottom: 4px;
}
.config-field-constraint {
font-size: 12px;
color: var(--fg-muted);
font-style: italic;
}
.config-model-rec {
margin-top: 20px;
padding: 14px 16px;
border-radius: var(--radius);
background: rgba(42,168,118,0.08);
border: 1px solid rgba(42,168,118,0.2);
}
.config-model-label {
font-size: 11px;
color: var(--accent-dark);
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
margin-bottom: 4px;
}
.config-model-preferred {
font-family: var(--mono);
font-size: 14px;
margin-bottom: 4px;
}
.config-model-rationale {
color: var(--fg-muted);
font-size: 13px;
}
.config-model-alternatives {
color: var(--fg-muted);
font-size: 12px;
margin-top: 4px;
}
/* ---------- dashboard preview ---------- */
.dashboard-header h1.dashboard-title { margin: 0 0 4px; font-size: 22px; }
+20 -2
View File
@@ -48,6 +48,10 @@
<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>
@@ -63,11 +67,14 @@
<script src="../widgets.js"></script>
<script>
// Fetch + render dashboard + README at page load. Both files live
// alongside index.html in this template's detail dir.
// 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);
@@ -80,6 +87,17 @@
} 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>
+105 -1
View File
@@ -408,12 +408,116 @@
.replace(/'/g, "&#39;");
}
// ---------------------------------------------------------------------
// 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, // exposed for the template detail page's README block
renderMarkdown, // used by the detail page's README block
renderConfigSchema, // used by the detail page's Configuration block
};
})(typeof window !== "undefined" ? window : this);
@@ -1,24 +1,30 @@
# Site Status Checker — Agent Instructions
This project maintains a daily uptime check for a short list of URLs. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
This project maintains a daily uptime check for a list of URLs the user configured during install. The same instructions apply whether you're Hermes, Claude Code, Cursor, Codex, Aider, or any other agent that reads `AGENTS.md`.
## Project layout
- `sites.txt` — one URL per line. Lines starting with `#` are comments. This is the source of truth for what to check. **Not shipped with the template** — created on first run (see below).
- `status-log.md` — append-only markdown log. Newest run at the top. Each run is a section with the ISO-8601 timestamp as the heading. Also created on first run.
- `.scarf/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 `sites.txt` doesn't exist in the project root, create it with this starter content and tell the user you did:
```
# One URL per line. Lines starting with # are comments.
# Replace these placeholders with the sites you want to watch.
https://example.com
https://example.org
```
If `status-log.md` doesn't exist, create it with a one-line header:
```
@@ -27,17 +33,19 @@ If `status-log.md` doesn't exist, create it with a one-line header:
Newest run at the top. Each section is a single check.
```
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 `sites.txt` in the project root. Ignore empty lines and `#`-prefixed comments. Expect plain URLs; be tolerant of whitespace around them.
2. For each URL, make an HTTP GET request with a 10-second timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
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 |
@@ -53,14 +61,14 @@ The cron job runs this project's "Check site status" prompt. When invoked:
## 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. If `sites.txt` is empty or missing, leave the dashboard untouched and write a single `status-log.md` entry noting "no sites configured."
- Don't invent URLs or pull them from anywhere other than `values.sites`.
- Don't run browsers or headless Chrome. Plain HTTP GET is sufficient.
## 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" — append the URL to `sites.txt` on its own line. Don't sort or reorder existing entries. Confirm back to the user which URL you added.
- "Remove a site" — delete the matching line from `sites.txt`. If multiple match, ask before choosing.
- "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.
- "Why is [site] down?" — read the last 35 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
@@ -2,32 +2,38 @@
A minimal uptime watchdog that pings a list of URLs once a day, records pass/fail results, and keeps a simple Scarf dashboard up to date.
**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
- **`sites.txt`** — one URL per line. This is the source of truth for what the cron job checks. Edit it to add or remove sites.
- **`status-log.md`** — the agent's append-only log of check results. New runs append a section at the top.
- **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. The prompt tells the agent to read `sites.txt`, check each URL, write results to `status-log.md`, and update the stat widgets in `dashboard.json`.
- **Cron job `Check site status`** — registered (paused) by the installer; tag `[tmpl:awizemann/site-status-checker]`. Runs daily at 9:00 AM when enabled. Reads your configured sites + timeout, hits each URL, writes results to `status-log.md`, and updates the dashboard.
## First steps
1. Open the **Cron** sidebar and enable the `[tmpl:awizemann/site-status-checker] Check site status` job. It's paused on install so nothing runs without your explicit say-so.
2. Edit `sites.txt` in your project root — replace the two placeholder URLs with the sites you actually want to watch.
3. From the project's dashboard, ask your agent to run the job now: "Run the site status check and update the dashboard."
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 HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
- **Change what "down" means.** By default the agent treats any non-2xx/3xx HTTP response as down. If you want to check for specific strings in the body (e.g. "Maintenance"), tell the agent in `AGENTS.md` and it will adapt.
- **Add alerting.** Set a `deliver` target on the cron job (Discord, Slack, Telegram) — the agent will post the run summary there instead of just writing to `status-log.md`.
## 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
Templates don't auto-uninstall in Scarf 2.2. To remove this one by hand:
1. Delete this project directory (removes the dashboard, AGENTS.md, sites.txt, status-log.md).
2. Remove the project entry from the Scarf sidebar (click the `` next to the project name).
3. Delete the `[tmpl:awizemann/site-status-checker] Check site status` cron job from the Cron sidebar.
No memory appendix or skills were installed, so nothing else needs cleanup.
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.
@@ -2,6 +2,6 @@
{
"name": "Check site status",
"schedule": "0 9 * * *",
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read sites.txt, HTTP GET each URL, prepend a results section to status-log.md, and update the three stat widgets plus the Watched Sites list items in .scarf/dashboard.json. When done, reply with a one-line summary like '3 up, 1 down — example.com timed out'."
"prompt": "Run the site status check for 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'."
}
]
@@ -1,20 +1,50 @@
{
"schemaVersion": 1,
"schemaVersion": 2,
"id": "awizemann/site-status-checker",
"name": "Site Status Checker",
"version": "1.0.0",
"minScarfVersion": "2.2.0",
"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 short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
"description": "A daily uptime check for a list of URLs you configure on install. Writes status to status-log.md and updates the dashboard with current counts.",
"category": "monitoring",
"tags": ["monitoring", "uptime", "cron", "starter"],
"tags": ["monitoring", "uptime", "cron", "starter", "configurable"],
"contents": {
"dashboard": true,
"agentsMd": true,
"cron": 1
"cron": 1,
"config": 2
},
"config": {
"schema": [
{
"key": "sites",
"type": "list",
"itemType": "string",
"label": "Sites to Watch",
"description": "One URL per item. HTTP or HTTPS. You can add and remove entries after install via the Configuration button on the dashboard.",
"required": true,
"minItems": 1,
"maxItems": 25,
"default": ["https://example.com", "https://example.org"]
},
{
"key": "timeout_seconds",
"type": "number",
"label": "Request Timeout (seconds)",
"description": "How long to wait for each URL before giving up.",
"required": false,
"min": 1,
"max": 60,
"default": 10
}
],
"modelRecommendation": {
"preferred": "claude-haiku-4",
"rationale": "Simple tool-use task — HTTP GETs + a short summary. Haiku is plenty and keeps cost low when the cron runs daily."
}
}
}
+40 -6
View File
@@ -7,28 +7,62 @@
"name": "Alan Wizemann",
"url": "https://github.com/awizemann/scarf"
},
"bundleSha256": "32b8c12706de8596be63dcdda32d46fc5bf478d5b9f7c1fc4c6d96ced251186a",
"bundleSize": 5410,
"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 short list of URLs. Writes status to status-log.md and updates the dashboard with current counts.",
"description": "A daily uptime check for a list of URLs you configure on install. Writes status to status-log.md and updates the dashboard with current counts.",
"detailSlug": "awizemann-site-status-checker",
"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.2.0",
"minScarfVersion": "2.3.0",
"name": "Site Status Checker",
"tags": [
"monitoring",
"uptime",
"cron",
"starter"
"starter",
"configurable"
],
"version": "1.0.0"
"version": "1.1.0"
}
]
}
+141 -7
View File
@@ -45,11 +45,18 @@ from typing import Iterable
# Schema + invariants
# ---------------------------------------------------------------------------
SCHEMA_VERSION = 1
SCHEMA_VERSION_V1 = 1 # original v2.2 bundle
SCHEMA_VERSION_V2 = 2 # v2.3 — adds optional manifest.config block
SUPPORTED_SCHEMA_VERSIONS = {SCHEMA_VERSION_V1, SCHEMA_VERSION_V2}
MAX_BUNDLE_BYTES = 5 * 1024 * 1024 # 5 MB cap on submissions; installer is 50 MB
REQUIRED_BUNDLE_FILES = ("template.json", "README.md", "AGENTS.md", "dashboard.json")
SUPPORTED_WIDGET_TYPES = {"stat", "progress", "text", "table", "chart", "list", "webview"}
# Mirror of Swift's TemplateConfigField.FieldType. Order matters only
# for error messages that echo this set.
SUPPORTED_CONFIG_FIELD_TYPES = {"string", "text", "number", "bool", "enum", "list", "secret"}
SUPPORTED_CONFIG_LIST_ITEM_TYPES = {"string"}
# Common secret patterns — keep in sync with `scripts/wiki.sh` and reuse a
# conservative subset. The validator rejects hard matches; the site's
# CONTRIBUTING guide covers the rest.
@@ -100,7 +107,9 @@ class TemplateRecord:
def to_catalog_entry(self) -> dict:
"""Subset suitable for catalog.json. Keep fields stable — the
site's widgets.js reads this shape."""
site's widgets.js reads this shape. The optional `config` key
mirrors the manifest's `config` block so the site can render
the Configuration section on the detail page."""
m = self.manifest
return {
"id": m["id"],
@@ -111,6 +120,7 @@ class TemplateRecord:
"category": m.get("category"),
"tags": m.get("tags") or [],
"contents": m["contents"],
"config": m.get("config"), # None for schema-less
"installUrl": self.install_url,
"detailSlug": self.detail_slug,
"bundleSha256": self.bundle_sha256,
@@ -154,8 +164,12 @@ def _validate_manifest(manifest: dict, template_dir: Path, errors: list[Validati
for field in required:
if field not in manifest:
errors.append(ValidationError(template_dir, f"manifest missing required field: {field}"))
if manifest.get("schemaVersion") != SCHEMA_VERSION:
errors.append(ValidationError(template_dir, f"unsupported schemaVersion: {manifest.get('schemaVersion')}"))
if manifest.get("schemaVersion") not in SUPPORTED_SCHEMA_VERSIONS:
errors.append(ValidationError(
template_dir,
f"unsupported schemaVersion: {manifest.get('schemaVersion')} "
f"(supported: {sorted(SUPPORTED_SCHEMA_VERSIONS)})"
))
# Manifest id must match the directory layout.
mid = manifest.get("id", "")
if "/" not in mid:
@@ -232,6 +246,114 @@ def _validate_contents_claim(
f"contents.memory.append={claimed_memory} disagrees with memory/append.md presence={has_memory_file}"
))
# Config (schemaVersion 2+) — claim field-count must match schema
# field count. `None`/`0` on both sides means schema-less, which is
# always legal.
claimed_config = int(contents.get("config") or 0)
schema = manifest.get("config")
schema_field_count = len((schema or {}).get("schema") or []) if schema else 0
if claimed_config != schema_field_count:
errors.append(ValidationError(
template_dir,
f"contents.config={claimed_config} but config.schema has {schema_field_count} field(s)"
))
def _validate_config_schema(manifest: dict, template_dir: Path, errors: list[ValidationError]) -> None:
"""Mirrors Swift `ProjectConfigService.validateSchema`. Structural
invariants only — user-value validation happens in the app at
commit time, not at catalog-build time."""
schema = manifest.get("config")
if schema is None:
return
if not isinstance(schema, dict):
errors.append(ValidationError(template_dir, "manifest.config must be an object"))
return
fields = schema.get("schema")
if not isinstance(fields, list):
errors.append(ValidationError(template_dir, "manifest.config.schema must be a list"))
return
seen_keys: set[str] = set()
for i, field in enumerate(fields):
if not isinstance(field, dict):
errors.append(ValidationError(template_dir, f"config.schema[{i}] must be an object"))
continue
key = field.get("key")
ftype = field.get("type")
label = field.get("label")
if not isinstance(key, str) or not key:
errors.append(ValidationError(template_dir, f"config.schema[{i}] missing/empty key"))
continue
if key in seen_keys:
errors.append(ValidationError(template_dir, f"config.schema has duplicate key: {key!r}"))
continue
seen_keys.add(key)
if not isinstance(label, str) or not label:
errors.append(ValidationError(template_dir, f"config.schema[{key}] missing/empty label"))
if ftype not in SUPPORTED_CONFIG_FIELD_TYPES:
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] uses unsupported type {ftype!r} "
f"(supported: {sorted(SUPPORTED_CONFIG_FIELD_TYPES)})"
))
continue
# Type-specific rules.
if ftype == "enum":
options = field.get("options") or []
if not isinstance(options, list) or not options:
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] (enum) must declare at least one option"
))
else:
seen_values: set[str] = set()
for opt in options:
if not isinstance(opt, dict):
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] option must be an object"
))
continue
val = opt.get("value")
if not isinstance(val, str) or not val:
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] option missing/empty value"
))
continue
if val in seen_values:
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] has duplicate option value: {val!r}"
))
seen_values.add(val)
elif ftype == "list":
item_type = field.get("itemType", "string")
if item_type not in SUPPORTED_CONFIG_LIST_ITEM_TYPES:
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] (list) uses unsupported itemType {item_type!r}"
))
elif ftype == "secret":
if "default" in field:
errors.append(ValidationError(
template_dir,
f"config.schema[{key}] is a secret field and must not declare a default"
))
# modelRecommendation — preferred must be non-empty when present.
rec = schema.get("modelRecommendation")
if rec is not None:
if not isinstance(rec, dict):
errors.append(ValidationError(template_dir, "config.modelRecommendation must be an object"))
else:
preferred = rec.get("preferred")
if not isinstance(preferred, str) or not preferred.strip():
errors.append(ValidationError(
template_dir,
"config.modelRecommendation.preferred must be a non-empty string"
))
def _validate_dashboard(zf: zipfile.ZipFile, template_dir: Path, errors: list[ValidationError]) -> None:
"""Decode dashboard.json against the widget-type vocabulary the Swift
@@ -351,6 +473,7 @@ def validate_template(template_dir: Path) -> tuple[TemplateRecord | None, list[V
return None, errors
_validate_manifest(manifest, template_dir, errors)
_validate_config_schema(manifest, template_dir, errors)
cron_count = _parse_cron_jobs(zf, template_dir, errors)
_validate_contents_claim(manifest, bundle_files, cron_count, template_dir, errors)
_validate_dashboard(zf, template_dir, errors)
@@ -443,7 +566,10 @@ def _check_staging_matches_bundle(record: TemplateRecord) -> list[ValidationErro
def write_catalog_json(records: list[TemplateRecord], out_path: Path) -> None:
catalog = {
"schemaVersion": SCHEMA_VERSION,
# The aggregate catalog itself is versioned independently of
# individual bundle manifests — bumping template manifest schema
# from 1 → 2 doesn't change the catalog.json shape.
"schemaVersion": 1,
"generated": True, # human reminder; a timestamp would churn the diff every run
"templates": [r.to_catalog_entry() for r in records],
}
@@ -567,12 +693,20 @@ def render_site(records: list[TemplateRecord], out_dir: Path, repo_root: Path) -
render_detail(template_tmpl, r),
encoding="utf-8",
)
# Copy the unpacked dashboard.json so widgets.js can fetch it
# without cross-directory relative paths.
# Copy the unpacked dashboard.json, README.md, and template.json
# (as manifest.json so the site can fetch the config schema for
# the Configuration section without conflicting with any file
# named `template.json` somewhere else in the served tree).
with zipfile.ZipFile(r.bundle_path, "r") as zf:
(detail_dir / "dashboard.json").write_bytes(zf.read("dashboard.json"))
if "README.md" in zf.namelist():
(detail_dir / "README.md").write_bytes(zf.read("README.md"))
# Only copy the manifest when the template has a config
# schema — avoids bloating the served tree for schema-less
# templates and makes the 404 fallback in widgets.js a
# meaningful signal ("no config to show here").
if r.manifest.get("config"):
(detail_dir / "manifest.json").write_bytes(zf.read("template.json"))
# The aggregate catalog.json is copied in so the frontend can fetch
# /templates/catalog.json without reaching back into the repo.
+188
View File
@@ -335,6 +335,194 @@ class ValidationTests(unittest.TestCase):
return records, errors
class ConfigSchemaValidationTests(unittest.TestCase):
"""Mirrors the Swift `ProjectConfigServiceTests` schema-validation
suite. Every rule enforced on the Swift side must be enforced on
the Python side schema drift is a catastrophic failure for the
catalog (CI would accept bundles the app later refuses at install)."""
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.repo = make_fake_repo(Path(self._dir.name))
self.addCleanup(self._dir.cleanup)
def _make_schema_manifest(self, fields, cron: int = 0):
"""Convenience — build a v2 manifest with the given config fields."""
return {
"schemaVersion": 2,
"id": "tester/configured",
"name": "Configured",
"version": "1.0.0",
"description": "test",
"contents": {
"dashboard": True,
"agentsMd": True,
"cron": cron,
"config": len(fields),
},
"config": {"schema": fields},
}
def test_accepts_schemaful_bundle(self):
manifest = self._make_schema_manifest([
{"key": "name", "type": "string", "label": "Name", "required": True},
{"key": "enabled", "type": "bool", "label": "Enabled"},
])
make_template_dir(
self.repo, "tester", "configured",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# readme",
"AGENTS.md": b"# agents",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
records = []
errors = []
for tdir in build_catalog._iter_templates(self.repo):
rec, errs = build_catalog.validate_template(tdir)
errors.extend(errs)
if rec is not None:
records.append(rec)
self.assertEqual(errors, [])
self.assertEqual(len(records), 1)
self.assertEqual(records[0].manifest["schemaVersion"], 2)
def test_rejects_duplicate_keys(self):
manifest = self._make_schema_manifest([
{"key": "same", "type": "string", "label": "A"},
{"key": "same", "type": "bool", "label": "B"},
])
make_template_dir(
self.repo, "tester", "dup",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("duplicate key" in str(e) for e in errors), errors)
def test_rejects_secret_with_default(self):
manifest = self._make_schema_manifest([
{
"key": "api_key", "type": "secret", "label": "API Key",
"required": True, "default": "sk-leaked-in-template"
},
])
make_template_dir(
self.repo, "tester", "secret-default",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("must not declare a default" in str(e) for e in errors), errors)
def test_rejects_enum_without_options(self):
manifest = self._make_schema_manifest([
{"key": "choice", "type": "enum", "label": "Choice", "options": []},
])
make_template_dir(
self.repo, "tester", "enum-empty",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("at least one option" in str(e) for e in errors), errors)
def test_rejects_unsupported_field_type(self):
manifest = self._make_schema_manifest([
{"key": "wat", "type": "hologram", "label": "W"},
])
make_template_dir(
self.repo, "tester", "bad-type",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("unsupported type" in str(e) for e in errors), errors)
def test_rejects_contents_config_count_mismatch(self):
# Schema has 1 field; contents.config claims 2.
manifest = self._make_schema_manifest([
{"key": "only", "type": "string", "label": "Only"},
])
manifest["contents"]["config"] = 2
make_template_dir(
self.repo, "tester", "mismatch",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("contents.config=2" in str(e) for e in errors), errors)
def test_rejects_unsupported_list_item_type(self):
manifest = self._make_schema_manifest([
{"key": "items", "type": "list", "label": "Items", "itemType": "number"},
])
make_template_dir(
self.repo, "tester", "list-type",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertTrue(any("unsupported itemType" in str(e) for e in errors), errors)
def test_accepts_schemaless_v1_manifest_unchanged(self):
# Pre-v2.3 bundles without any config block should keep working.
manifest = {
"schemaVersion": 1,
"id": "tester/legacy",
"name": "Legacy",
"version": "1.0.0",
"description": "no config",
"contents": {"dashboard": True, "agentsMd": True},
}
make_template_dir(
self.repo, "tester", "legacy",
manifest=manifest,
bundle_files={
"template.json": json.dumps(manifest).encode("utf-8"),
"README.md": b"# r", "AGENTS.md": b"# a",
"dashboard.json": json.dumps(MINIMAL_DASHBOARD).encode("utf-8"),
},
)
errors = self._collect_errors()
self.assertEqual(errors, [])
def _collect_errors(self):
errors = []
for tdir in build_catalog._iter_templates(self.repo):
rec, errs = build_catalog.validate_template(tdir)
errors.extend(errs)
if rec is not None:
errors.extend(build_catalog._check_staging_matches_bundle(rec))
return errors
class CatalogJsonTests(unittest.TestCase):
"""Shape of the emitted catalog.json must stay stable — the site's
widgets.js reads these fields by name."""