mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55229a2f91 | |||
| 99859c06fd | |||
| 9f3600ae01 | |||
| b34f432f00 | |||
| b289a83944 | |||
| 64b7d3beaf | |||
| 385c3a2e4d |
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// Scarf iOS
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Query private var items: [Item]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(items) { item in
|
||||
NavigationLink {
|
||||
Text("Item at \(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))")
|
||||
} label: {
|
||||
Text(item.timestamp, format: Date.FormatStyle(date: .numeric, time: .standard))
|
||||
}
|
||||
}
|
||||
.onDelete(perform: deleteItems)
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
EditButton()
|
||||
}
|
||||
ToolbarItem {
|
||||
Button(action: addItem) {
|
||||
Label("Add Item", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
Text("Select an item")
|
||||
}
|
||||
}
|
||||
|
||||
private func addItem() {
|
||||
withAnimation {
|
||||
let newItem = Item(timestamp: Date())
|
||||
modelContext.insert(newItem)
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteItems(offsets: IndexSet) {
|
||||
withAnimation {
|
||||
for index in offsets {
|
||||
modelContext.delete(items[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.modelContainer(for: Item.self, inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Item.swift
|
||||
// Scarf iOS
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Item {
|
||||
var timestamp: Date
|
||||
|
||||
init(timestamp: Date) {
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.icloud-container-identifiers</key>
|
||||
<array/>
|
||||
<key>com.apple.developer.icloud-services</key>
|
||||
<array>
|
||||
<string>CloudKit</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Scarf_iOSApp.swift
|
||||
// Scarf iOS
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@main
|
||||
struct Scarf_iOSApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
Item.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
|
||||
do {
|
||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||
} catch {
|
||||
fatalError("Could not create ModelContainer: \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//
|
||||
// Scarf_iOSTests.swift
|
||||
// Scarf iOSTests
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import Scarf_iOS
|
||||
|
||||
struct Scarf_iOSTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// Scarf_iOSUITests.swift
|
||||
// Scarf iOSUITests
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class Scarf_iOSUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//
|
||||
// Scarf_iOSUITestsLaunchTests.swift
|
||||
// Scarf iOSUITests
|
||||
//
|
||||
// Created by Alan Wizemann on 4/23/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class Scarf_iOSUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,20 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* 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 = (
|
||||
|
||||
@@ -23,6 +23,12 @@ struct ProjectTemplateManifest: Codable, Sendable, Equatable {
|
||||
let icon: String?
|
||||
let screenshots: [String]?
|
||||
let contents: TemplateContents
|
||||
/// Optional configuration schema (added in manifest schemaVersion 2).
|
||||
/// When present, the installer presents a form during install and
|
||||
/// writes values to `<project>/.scarf/config.json` + the Keychain.
|
||||
/// Schema-v1 manifests omit this field entirely — Codable's
|
||||
/// optional-field decoding keeps them working unchanged.
|
||||
let config: TemplateConfigSchema?
|
||||
|
||||
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
||||
/// Used for the install directory name, skills namespace, and cron-job tag.
|
||||
@@ -51,6 +57,11 @@ struct TemplateContents: Codable, Sendable, Equatable {
|
||||
let skills: [String]?
|
||||
let cron: Int?
|
||||
let memory: TemplateMemoryClaim?
|
||||
/// Number of configuration fields the template ships (schemaVersion 2+).
|
||||
/// Cross-checked against `manifest.config?.fields.count` by the
|
||||
/// validator so a bundle can't hide a schema from the preview.
|
||||
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||
let config: Int?
|
||||
}
|
||||
|
||||
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||
@@ -130,10 +141,39 @@ struct TemplateInstallPlan: Sendable {
|
||||
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||
let projectRegistryName: String
|
||||
|
||||
/// Configuration schema declared by the template (manifest schemaVersion 2).
|
||||
/// `nil` means the template is schema-less — the installer skips the
|
||||
/// config sheet and writes no `.scarf/config.json` or manifest cache.
|
||||
let configSchema: TemplateConfigSchema?
|
||||
|
||||
/// Values the user entered in the configure sheet. Populated by the
|
||||
/// VM just before `install()` runs; empty when `configSchema` is nil.
|
||||
/// Secrets appear here as `.keychainRef(...)` — the bytes themselves
|
||||
/// were routed straight from the form field into the Keychain and
|
||||
/// never held in memory past that point.
|
||||
var configValues: [String: TemplateConfigValue]
|
||||
|
||||
/// Path at which the installer will stash a copy of `template.json`
|
||||
/// so the post-install Configuration editor can render the form
|
||||
/// offline. `nil` when `configSchema` is nil.
|
||||
let manifestCachePath: String?
|
||||
|
||||
/// Convenience: total number of writes (files + cron jobs + optional
|
||||
/// memory append + registry append). Displayed in the preview sheet.
|
||||
/// memory append + registry append + optional config.json + one
|
||||
/// entry per secret written to the Keychain). Displayed in the
|
||||
/// preview sheet.
|
||||
nonisolated var totalWriteCount: Int {
|
||||
projectFiles.count + skillsFiles.count + cronJobs.count + (memoryAppendix == nil ? 0 : 1) + 1
|
||||
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
|
||||
let secretCount = configValues.values.filter {
|
||||
if case .keychainRef = $0 { return true } else { return false }
|
||||
}.count
|
||||
return projectFiles.count
|
||||
+ skillsFiles.count
|
||||
+ cronJobs.count
|
||||
+ (memoryAppendix == nil ? 0 : 1)
|
||||
+ 1 // registry entry
|
||||
+ configFileCount
|
||||
+ secretCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +201,17 @@ struct TemplateLock: Codable, Sendable {
|
||||
let skillsFiles: [String]
|
||||
let cronJobNames: [String]
|
||||
let memoryBlockId: String?
|
||||
/// Every `keychain://service/account` URI the installer stored in
|
||||
/// the Keychain for this project's secret fields. Empty/nil for
|
||||
/// schema-less (v1-style) installs. The uninstaller iterates this
|
||||
/// list and calls `SecItemDelete` for each entry; absent on older
|
||||
/// lock files so Codable's optional decoding keeps pre-2.3 installs
|
||||
/// uninstallable.
|
||||
let configKeychainItems: [String]?
|
||||
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
|
||||
/// Informational — the actual removal of config.json rides on
|
||||
/// `projectFiles`. Optional for back-compat.
|
||||
let configFields: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case templateId = "template_id"
|
||||
@@ -172,6 +223,8 @@ struct TemplateLock: Codable, Sendable {
|
||||
case skillsFiles = "skills_files"
|
||||
case cronJobNames = "cron_job_names"
|
||||
case memoryBlockId = "memory_block_id"
|
||||
case configKeychainItems = "config_keychain_items"
|
||||
case configFields = "config_fields"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Schema (ships inside template.json as manifest.config)
|
||||
|
||||
/// Author-declared configuration schema for a template. Published as the
|
||||
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
|
||||
/// in values at install time via `TemplateConfigSheet`; values land in
|
||||
/// `<project>/.scarf/config.json` with secrets resolved through the
|
||||
/// macOS Keychain.
|
||||
struct TemplateConfigSchema: Codable, Sendable, Equatable {
|
||||
let fields: [TemplateConfigField]
|
||||
let modelRecommendation: TemplateModelRecommendation?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fields = "schema"
|
||||
case modelRecommendation
|
||||
}
|
||||
|
||||
nonisolated var isEmpty: Bool { fields.isEmpty }
|
||||
|
||||
/// Fast lookup by key. Validators guarantee keys are unique within a
|
||||
/// schema at manifest-parse time, so this is safe.
|
||||
nonisolated func field(for key: String) -> TemplateConfigField? {
|
||||
fields.first { $0.key == key }
|
||||
}
|
||||
}
|
||||
|
||||
/// One configurable field the user fills in. Discriminated by `type`.
|
||||
/// We keep one flat struct rather than an enum-associated-value encoding
|
||||
/// so JSON reads cleanly as a record and authors can hand-edit manifests
|
||||
/// without fighting Swift's `"case"` discriminator syntax.
|
||||
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { key }
|
||||
|
||||
let key: String
|
||||
let type: FieldType
|
||||
let label: String
|
||||
let description: String?
|
||||
let required: Bool
|
||||
let placeholder: String?
|
||||
|
||||
// Type-specific constraints — all optional. The validator enforces
|
||||
// only the ones that apply to `type`; extras are ignored.
|
||||
let defaultValue: TemplateConfigValue?
|
||||
let options: [EnumOption]? // type == .enum
|
||||
let minLength: Int? // type == .string / .text
|
||||
let maxLength: Int?
|
||||
let pattern: String? // type == .string (regex)
|
||||
let minNumber: Double? // type == .number
|
||||
let maxNumber: Double?
|
||||
let step: Double?
|
||||
let itemType: String? // type == .list — only "string" supported in v1
|
||||
let minItems: Int?
|
||||
let maxItems: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case key, type, label, description, required, placeholder
|
||||
case defaultValue = "default"
|
||||
case options
|
||||
case minLength, maxLength, pattern
|
||||
case minNumber = "min"
|
||||
case maxNumber = "max"
|
||||
case step
|
||||
case itemType, minItems, maxItems
|
||||
}
|
||||
|
||||
enum FieldType: String, Codable, Sendable, Equatable {
|
||||
case string
|
||||
case text
|
||||
case number
|
||||
case bool
|
||||
case `enum`
|
||||
case list
|
||||
case secret
|
||||
}
|
||||
|
||||
/// One option of an `enum` field. `value` is what ends up in
|
||||
/// `config.json`; `label` is the human-readable text shown in the UI.
|
||||
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { value }
|
||||
let value: String
|
||||
let label: String
|
||||
}
|
||||
}
|
||||
|
||||
/// Author's model-of-choice hint, shown in the install preview + on the
|
||||
/// catalog detail page. Purely advisory — Scarf never auto-switches the
|
||||
/// active model. Individual cron jobs can override via
|
||||
/// `HermesCronJob.model` if the author wants enforcement.
|
||||
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
|
||||
let preferred: String
|
||||
let rationale: String?
|
||||
let alternatives: [String]?
|
||||
}
|
||||
|
||||
// MARK: - Values (what lands in config.json and the Keychain)
|
||||
|
||||
/// One configured value. Secrets don't carry their raw bytes — only a
|
||||
/// Keychain reference of the form `"keychain://<service>/<account>"` so
|
||||
/// serialising config.json to disk never leaks the secret into git or
|
||||
/// into backups.
|
||||
enum TemplateConfigValue: Codable, Sendable, Equatable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case list([String])
|
||||
case keychainRef(String)
|
||||
|
||||
/// Convenience: the string representation suitable for display or
|
||||
/// for writing into a placeholder that the agent reads. Keychain
|
||||
/// refs return the ref string, not the resolved secret — callers
|
||||
/// resolve through `ProjectConfigKeychain` explicitly when they
|
||||
/// actually need the plaintext.
|
||||
nonisolated var displayString: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(n)
|
||||
case .bool(let b): return b ? "true" : "false"
|
||||
case .list(let items): return items.joined(separator: ", ")
|
||||
case .keychainRef(let ref): return ref
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let s = try? container.decode(String.self) {
|
||||
// Preserve the keychain:// scheme so secrets round-trip as
|
||||
// references, not as plaintext.
|
||||
if s.hasPrefix("keychain://") {
|
||||
self = .keychainRef(s)
|
||||
} else {
|
||||
self = .string(s)
|
||||
}
|
||||
} else if let b = try? container.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? container.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let arr = try? container.decode([String].self) {
|
||||
self = .list(arr)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
TemplateConfigValue.self,
|
||||
.init(codingPath: decoder.codingPath,
|
||||
debugDescription: "Expected String, Bool, Number, or [String]")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .list(let items): try container.encode(items)
|
||||
case .keychainRef(let ref): try container.encode(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
|
||||
|
||||
/// The JSON file the installer writes + the editor reads. Non-secret
|
||||
/// values appear inline; secrets are `"keychain://<service>/<account>"`
|
||||
/// references that `ProjectConfigService` resolves through the Keychain
|
||||
/// on demand.
|
||||
struct ProjectConfigFile: Codable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let templateId: String
|
||||
var values: [String: TemplateConfigValue]
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case templateId
|
||||
case values
|
||||
case updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain reference helpers
|
||||
|
||||
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
|
||||
/// (service + account) from the template slug + project-path hash so two
|
||||
/// installs of the same template in different dirs don't collide in the
|
||||
/// login Keychain.
|
||||
struct TemplateKeychainRef: Sendable, Equatable {
|
||||
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
|
||||
let service: String
|
||||
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
|
||||
/// guarantees uniqueness across multiple installs of the same template.
|
||||
let account: String
|
||||
|
||||
/// `"keychain://<service>/<account>"` — what lands in `config.json`.
|
||||
nonisolated var uri: String { "keychain://\(service)/\(account)" }
|
||||
|
||||
/// Parse a `keychain://…` URI back into a ref. Returns `nil` when the
|
||||
/// input isn't well-formed so callers can distinguish a missing ref
|
||||
/// from a malformed one.
|
||||
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
|
||||
guard uri.hasPrefix("keychain://") else { return nil }
|
||||
let rest = String(uri.dropFirst("keychain://".count))
|
||||
guard let slash = rest.firstIndex(of: "/") else { return nil }
|
||||
let service = String(rest[..<slash])
|
||||
let account = String(rest[rest.index(after: slash)...])
|
||||
guard !service.isEmpty, !account.isEmpty else { return nil }
|
||||
return TemplateKeychainRef(service: service, account: account)
|
||||
}
|
||||
|
||||
/// Build a ref from a template slug + field key + project path.
|
||||
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
|
||||
/// of the absolute project path. Stable across launches, different
|
||||
/// between `/Users/a/proj1` and `/Users/a/proj2`.
|
||||
nonisolated static func make(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
projectPath: String
|
||||
) -> TemplateKeychainRef {
|
||||
TemplateKeychainRef(
|
||||
service: "com.scarf.template.\(templateSlug)",
|
||||
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func shortHash(of string: String) -> String {
|
||||
// 8 hex chars is 32 bits of uniqueness — plenty for
|
||||
// distinguishing a handful of project dirs per template install.
|
||||
let data = Data(string.utf8)
|
||||
var hash: UInt32 = 0x811c9dc5
|
||||
for byte in data {
|
||||
hash ^= UInt32(byte)
|
||||
hash &*= 0x01000193
|
||||
}
|
||||
return String(format: "%08x", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// One schema- or value-validation problem. Carries `fieldKey` so the
|
||||
/// UI can surface the error inline with the field rather than at the
|
||||
/// top of the form.
|
||||
struct TemplateConfigValidationError: Error, Sendable, Equatable {
|
||||
let fieldKey: String?
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum TemplateConfigSchemaError: LocalizedError, Sendable {
|
||||
case duplicateKey(String)
|
||||
case unsupportedType(String)
|
||||
case emptyEnumOptions(String)
|
||||
case duplicateEnumValue(key: String, value: String)
|
||||
case unsupportedListItemType(key: String, itemType: String)
|
||||
case secretFieldHasDefault(String)
|
||||
case emptyModelPreferred
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .duplicateKey(let k):
|
||||
return "Config schema has duplicate key: \(k)"
|
||||
case .unsupportedType(let t):
|
||||
return "Config schema uses unsupported field type: \(t)"
|
||||
case .emptyEnumOptions(let k):
|
||||
return "Enum field '\(k)' must declare at least one option"
|
||||
case .duplicateEnumValue(let k, let v):
|
||||
return "Enum field '\(k)' has duplicate option value: \(v)"
|
||||
case .unsupportedListItemType(let k, let t):
|
||||
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
|
||||
case .secretFieldHasDefault(let k):
|
||||
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
|
||||
case .emptyModelPreferred:
|
||||
return "modelRecommendation.preferred must be a non-empty model id."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,11 +82,6 @@ final class ServerRegistry {
|
||||
/// Flip the default server to `id`. Passing `ServerContext.local.id`
|
||||
/// clears the flag on every remote entry, making Local the implicit
|
||||
/// default. Passing an unknown ID is a no-op. Persisted on return.
|
||||
///
|
||||
/// Intentionally doesn't fire `onEntriesChanged` — that hook means "the
|
||||
/// set of servers changed" and drives the menu-bar fanout rebuild. A
|
||||
/// default-flag flip doesn't change the set; SwiftUI views reading
|
||||
/// `defaultServerID` redraw via `@Observable`'s tracking of `entries`.
|
||||
func setDefaultServer(_ id: ServerID) {
|
||||
var changed = false
|
||||
for idx in entries.indices {
|
||||
@@ -98,6 +93,7 @@ final class ServerRegistry {
|
||||
}
|
||||
if changed {
|
||||
save()
|
||||
onEntriesChanged?()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import os
|
||||
|
||||
/// Thin wrapper around the macOS Keychain for template-config secrets.
|
||||
/// Scarf doesn't have other Keychain users yet so this file is the one
|
||||
/// place that touches the `Security` framework; keep it small and
|
||||
/// auditable so a reader can tell at a glance what we store, under what
|
||||
/// identifiers, and when items are removed.
|
||||
///
|
||||
/// **What we store.** Generic passwords (kSecClassGenericPassword) in
|
||||
/// the login Keychain. Each item is identified by a (service, account)
|
||||
/// pair derived from the template slug + field key + project-path hash
|
||||
/// — see `TemplateKeychainRef.make`. The stored Data is the user's
|
||||
/// raw secret bytes; we never transform or encode them.
|
||||
///
|
||||
/// **When items are written.** By `ProjectTemplateInstaller` after the
|
||||
/// install preview is confirmed and the user has filled in the
|
||||
/// configure sheet. By `TemplateConfigSheet` when the user edits a
|
||||
/// secret field post-install.
|
||||
///
|
||||
/// **When items are removed.** By `ProjectTemplateUninstaller`,
|
||||
/// iterating the lock file's `configKeychainItems` list. The login
|
||||
/// Keychain is never swept for stray entries — if the lock is out of
|
||||
/// sync we log + skip rather than guess which items are ours.
|
||||
///
|
||||
/// **What shows to the user.** macOS prompts "Scarf wants to access
|
||||
/// the Keychain" the first time we read a secret in a given session.
|
||||
/// User approves; subsequent reads in that session are silent. We
|
||||
/// never bypass this — the prompt is the user's trust boundary.
|
||||
struct ProjectConfigKeychain: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigKeychain")
|
||||
|
||||
/// Which Keychain to target. The default is the login Keychain
|
||||
/// (`nil` uses the user's default chain). Tests pass an explicit
|
||||
/// namespace suffix via `testServiceSuffix` — see `TemplateConfigTests` —
|
||||
/// so integration tests can roundtrip without polluting real
|
||||
/// user state.
|
||||
let testServiceSuffix: String?
|
||||
|
||||
nonisolated init(testServiceSuffix: String? = nil) {
|
||||
self.testServiceSuffix = testServiceSuffix
|
||||
}
|
||||
|
||||
/// Write or overwrite the secret for (service, account). Tests
|
||||
/// route their items through a distinct service prefix via
|
||||
/// `testServiceSuffix` so they can't leak into the user's real
|
||||
/// Keychain.
|
||||
nonisolated func set(service: String, account: String, secret: Data) throws {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
// Try update first — cheaper than delete-then-add and doesn't
|
||||
// trip macOS's "item already exists" if another thread raced us.
|
||||
let update: [String: Any] = [
|
||||
kSecValueData as String: secret,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
||||
if updateStatus == errSecSuccess { return }
|
||||
if updateStatus != errSecItemNotFound {
|
||||
throw Self.error(status: updateStatus, op: "update")
|
||||
}
|
||||
var insert = query
|
||||
insert[kSecValueData as String] = secret
|
||||
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly — stays in
|
||||
// this device's Keychain, not synced via iCloud, usable after
|
||||
// first unlock (so background cron triggers can read).
|
||||
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
let addStatus = SecItemAdd(insert as CFDictionary, nil)
|
||||
if addStatus != errSecSuccess {
|
||||
throw Self.error(status: addStatus, op: "add")
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve the secret for (service, account). Returns `nil` when
|
||||
/// the item simply doesn't exist (user never set it, or an
|
||||
/// uninstall already removed it). Throws on every other Keychain
|
||||
/// error so callers don't silently treat "access denied" or
|
||||
/// "corrupt keychain" as "no value."
|
||||
nonisolated func get(service: String, account: String) throws -> Data? {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
if status != errSecSuccess {
|
||||
throw Self.error(status: status, op: "get")
|
||||
}
|
||||
return result as? Data
|
||||
}
|
||||
|
||||
/// Delete the secret for (service, account). Absent item is a
|
||||
/// no-op; any other failure throws. Called by
|
||||
/// `ProjectTemplateUninstaller` for every item in
|
||||
/// `TemplateLock.configKeychainItems`.
|
||||
nonisolated func delete(service: String, account: String) throws {
|
||||
let svc = resolved(service: service)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: svc,
|
||||
kSecAttrAccount as String: account,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status == errSecItemNotFound || status == errSecSuccess { return }
|
||||
throw Self.error(status: status, op: "delete")
|
||||
}
|
||||
|
||||
/// Convenience: apply the test suffix when in test mode.
|
||||
nonisolated private func resolved(service: String) -> String {
|
||||
guard let suffix = testServiceSuffix, !suffix.isEmpty else { return service }
|
||||
return "\(service).\(suffix)"
|
||||
}
|
||||
|
||||
/// Build a useful NSError from a Keychain OSStatus. Logs at warning
|
||||
/// — callers decide whether the failure is fatal.
|
||||
nonisolated private static func error(status: OSStatus, op: String) -> NSError {
|
||||
let description = (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error"
|
||||
logger.warning("Keychain \(op, privacy: .public) failed: \(status) \(description, privacy: .public)")
|
||||
return NSError(
|
||||
domain: "com.scarf.keychain",
|
||||
code: Int(status),
|
||||
userInfo: [
|
||||
NSLocalizedDescriptionKey: "Keychain \(op) failed (\(status)): \(description)"
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ref-shaped convenience layer
|
||||
|
||||
extension ProjectConfigKeychain {
|
||||
/// Set a secret using a pre-built `TemplateKeychainRef`. Mirrors the
|
||||
/// service/account plumbing every caller would otherwise repeat.
|
||||
nonisolated func set(ref: TemplateKeychainRef, secret: Data) throws {
|
||||
try set(service: ref.service, account: ref.account, secret: secret)
|
||||
}
|
||||
|
||||
nonisolated func get(ref: TemplateKeychainRef) throws -> Data? {
|
||||
try get(service: ref.service, account: ref.account)
|
||||
}
|
||||
|
||||
nonisolated func delete(ref: TemplateKeychainRef) throws {
|
||||
try delete(service: ref.service, account: ref.account)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Per-project configuration I/O: reads `<project>/.scarf/config.json`
|
||||
/// into typed values, writes them back, resolves Keychain-backed secrets
|
||||
/// on demand, and validates user-entered values against the schema.
|
||||
///
|
||||
/// Separation of concerns:
|
||||
///
|
||||
/// - **Schema authority.** `TemplateConfigSchema` lives in the bundle's
|
||||
/// `template.json` and a copy is stashed at `<project>/.scarf/manifest.json`
|
||||
/// at install time so the post-install editor works offline. This
|
||||
/// service treats the schema as read-only input; `validateSchema`
|
||||
/// checks structural invariants and is called by
|
||||
/// `ProjectTemplateService` during install-plan building.
|
||||
/// - **Value storage.** Non-secret values live inline in `config.json`;
|
||||
/// secret values are Keychain references of the form
|
||||
/// `"keychain://<service>/<account>"`. The service owns both halves
|
||||
/// of that storage — callers never open `config.json` or touch the
|
||||
/// Keychain directly.
|
||||
/// - **Remote readiness.** All file I/O goes through
|
||||
/// `ServerContext.makeTransport()` so when `ProjectTemplateInstaller`
|
||||
/// eventually supports remote contexts, the config store comes along
|
||||
/// for the ride. Keychain access stays local (it's a macOS-side thing
|
||||
/// by definition — agents on remote Hermes installs would fetch
|
||||
/// values via Scarf's channel, same as today).
|
||||
struct ProjectConfigService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigService")
|
||||
|
||||
let context: ServerContext
|
||||
let keychain: ProjectConfigKeychain
|
||||
|
||||
nonisolated init(
|
||||
context: ServerContext = .local,
|
||||
keychain: ProjectConfigKeychain = ProjectConfigKeychain()
|
||||
) {
|
||||
self.context = context
|
||||
self.keychain = keychain
|
||||
}
|
||||
|
||||
// MARK: - Paths
|
||||
|
||||
nonisolated static func configPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/config.json"
|
||||
}
|
||||
|
||||
nonisolated static func manifestCachePath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/manifest.json"
|
||||
}
|
||||
|
||||
// MARK: - Load / save on-disk config
|
||||
|
||||
/// Read + decode `<project>/.scarf/config.json`. Returns `nil`
|
||||
/// cleanly when the file is absent (e.g. a project installed from
|
||||
/// a schema-less template, or a hand-added project). Throws on
|
||||
/// malformed JSON so the caller can surface a concrete error
|
||||
/// rather than silently treating a corrupt file as missing.
|
||||
nonisolated func load(project: ProjectEntry) throws -> ProjectConfigFile? {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.configPath(for: project)
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
let data = try transport.readFile(path)
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectConfigFile.self, from: data)
|
||||
} catch {
|
||||
Self.logger.error("couldn't decode config.json at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `<project>/.scarf/config.json`. Secrets should already be
|
||||
/// represented as `TemplateConfigValue.keychainRef` references here
|
||||
/// — this service never inspects their plaintext.
|
||||
nonisolated func save(
|
||||
project: ProjectEntry,
|
||||
templateId: String,
|
||||
values: [String: TemplateConfigValue]
|
||||
) throws {
|
||||
let transport = context.makeTransport()
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: templateId,
|
||||
values: values,
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(file)
|
||||
let parent = (Self.configPath(for: project) as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(Self.configPath(for: project), data: data)
|
||||
}
|
||||
|
||||
// MARK: - Manifest cache (schema used by post-install editor)
|
||||
|
||||
/// Copy a template's `template.json` into `<project>/.scarf/manifest.json`
|
||||
/// so the post-install "Configuration" button can render the form
|
||||
/// offline. Called once by the installer after unpack + validate.
|
||||
nonisolated func cacheManifest(project: ProjectEntry, manifestData: Data) throws {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.manifestCachePath(for: project)
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(path, data: manifestData)
|
||||
}
|
||||
|
||||
/// Load the cached manifest into a `ProjectTemplateManifest` so the
|
||||
/// editor can look up field types + labels. Returns `nil` when the
|
||||
/// project wasn't installed from a schemaful template.
|
||||
nonisolated func loadCachedManifest(project: ProjectEntry) throws -> ProjectTemplateManifest? {
|
||||
let transport = context.makeTransport()
|
||||
let path = Self.manifestCachePath(for: project)
|
||||
guard transport.fileExists(path) else { return nil }
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Secrets
|
||||
|
||||
/// Resolve a `keychainRef` value into the actual secret bytes.
|
||||
/// Returns `nil` if the Keychain entry has been removed (e.g.
|
||||
/// external user cleanup, a previous uninstall that didn't finish).
|
||||
nonisolated func resolveSecret(ref value: TemplateConfigValue) throws -> Data? {
|
||||
guard case .keychainRef(let uri) = value,
|
||||
let ref = TemplateKeychainRef.parse(uri) else {
|
||||
return nil
|
||||
}
|
||||
return try keychain.get(ref: ref)
|
||||
}
|
||||
|
||||
/// Store a freshly-entered secret. Returns the `keychainRef` value
|
||||
/// suitable for writing into `config.json`.
|
||||
nonisolated func storeSecret(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
project: ProjectEntry,
|
||||
secret: Data
|
||||
) throws -> TemplateConfigValue {
|
||||
let ref = TemplateKeychainRef.make(
|
||||
templateSlug: templateSlug,
|
||||
fieldKey: fieldKey,
|
||||
projectPath: project.path
|
||||
)
|
||||
try keychain.set(ref: ref, secret: secret)
|
||||
return .keychainRef(ref.uri)
|
||||
}
|
||||
|
||||
/// Delete every Keychain item tracked in `refs`. Absent items are
|
||||
/// fine (uninstall may run after the user manually cleaned an
|
||||
/// entry). Any other failure is logged and re-thrown so the
|
||||
/// uninstaller can surface it.
|
||||
nonisolated func deleteSecrets(refs: [TemplateKeychainRef]) throws {
|
||||
for ref in refs {
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Schema validation (author-facing; called at bundle inspect time)
|
||||
|
||||
/// Verify structural invariants on a schema: unique keys, known
|
||||
/// types, enum options, secret-without-default rule, model
|
||||
/// recommendation non-empty when present. Called by
|
||||
/// `ProjectTemplateService.inspect` before buildPlan runs.
|
||||
nonisolated static func validateSchema(_ schema: TemplateConfigSchema) throws {
|
||||
var seen = Set<String>()
|
||||
for field in schema.fields {
|
||||
if !seen.insert(field.key).inserted {
|
||||
throw TemplateConfigSchemaError.duplicateKey(field.key)
|
||||
}
|
||||
switch field.type {
|
||||
case .enum:
|
||||
let opts = field.options ?? []
|
||||
guard !opts.isEmpty else {
|
||||
throw TemplateConfigSchemaError.emptyEnumOptions(field.key)
|
||||
}
|
||||
var seenValues = Set<String>()
|
||||
for opt in opts {
|
||||
if !seenValues.insert(opt.value).inserted {
|
||||
throw TemplateConfigSchemaError.duplicateEnumValue(key: field.key, value: opt.value)
|
||||
}
|
||||
}
|
||||
case .list:
|
||||
let item = field.itemType ?? "string"
|
||||
if item != "string" {
|
||||
throw TemplateConfigSchemaError.unsupportedListItemType(key: field.key, itemType: item)
|
||||
}
|
||||
case .secret:
|
||||
if field.defaultValue != nil {
|
||||
throw TemplateConfigSchemaError.secretFieldHasDefault(field.key)
|
||||
}
|
||||
case .string, .text, .number, .bool:
|
||||
break
|
||||
}
|
||||
}
|
||||
if let rec = schema.modelRecommendation {
|
||||
if rec.preferred.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
throw TemplateConfigSchemaError.emptyModelPreferred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value validation (runs on user input in the configure sheet)
|
||||
|
||||
/// Validate user-entered values against the schema. Returns one
|
||||
/// `TemplateConfigValidationError` per problem. Empty array means
|
||||
/// the form is submittable.
|
||||
nonisolated static func validateValues(
|
||||
_ values: [String: TemplateConfigValue],
|
||||
against schema: TemplateConfigSchema
|
||||
) -> [TemplateConfigValidationError] {
|
||||
var errors: [TemplateConfigValidationError] = []
|
||||
for field in schema.fields {
|
||||
let value = values[field.key]
|
||||
if field.required && !Self.hasMeaningfulValue(value, type: field.type) {
|
||||
errors.append(.init(fieldKey: field.key, message: "\(field.label) is required."))
|
||||
continue
|
||||
}
|
||||
guard let value else { continue }
|
||||
switch field.type {
|
||||
case .string, .text:
|
||||
if case .string(let s) = value {
|
||||
if let min = field.minLength, s.count < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be at least \(min) characters."))
|
||||
}
|
||||
if let max = field.maxLength, s.count > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be at most \(max) characters."))
|
||||
}
|
||||
if let pattern = field.pattern,
|
||||
s.range(of: pattern, options: .regularExpression) == nil {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) doesn't match the expected format."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a string."))
|
||||
}
|
||||
|
||||
case .number:
|
||||
if case .number(let n) = value {
|
||||
if let min = field.minNumber, n < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be ≥ \(min)."))
|
||||
}
|
||||
if let max = field.maxNumber, n > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be ≤ \(max)."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a number."))
|
||||
}
|
||||
|
||||
case .bool:
|
||||
if case .bool = value { /* ok */ } else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be true or false."))
|
||||
}
|
||||
|
||||
case .enum:
|
||||
if case .string(let s) = value {
|
||||
let options = (field.options ?? []).map(\.value)
|
||||
if !options.contains(s) {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be one of \(options.joined(separator: ", "))."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be one of the predefined options."))
|
||||
}
|
||||
|
||||
case .list:
|
||||
if case .list(let items) = value {
|
||||
if let min = field.minItems, items.count < min {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) needs at least \(min) item(s)."))
|
||||
}
|
||||
if let max = field.maxItems, items.count > max {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) accepts at most \(max) item(s)."))
|
||||
}
|
||||
} else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be a list."))
|
||||
}
|
||||
|
||||
case .secret:
|
||||
if case .keychainRef = value { /* opaque — trust it */ } else {
|
||||
errors.append(.init(fieldKey: field.key,
|
||||
message: "\(field.label) must be supplied (Keychain entry missing)."))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
nonisolated private static func hasMeaningfulValue(
|
||||
_ value: TemplateConfigValue?,
|
||||
type: TemplateConfigField.FieldType
|
||||
) -> Bool {
|
||||
guard let value else { return false }
|
||||
switch (type, value) {
|
||||
case (.string, .string(let s)), (.text, .string(let s)), (.enum, .string(let s)):
|
||||
return !s.isEmpty
|
||||
case (.number, .number):
|
||||
return true
|
||||
case (.bool, .bool):
|
||||
return true
|
||||
case (.list, .list(let arr)):
|
||||
return !arr.isEmpty
|
||||
case (.secret, .keychainRef):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,9 +182,25 @@ struct ProjectTemplateExporter: Sendable {
|
||||
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
||||
}
|
||||
|
||||
// If the source project was itself installed from a schemaful
|
||||
// template, its `.scarf/manifest.json` carries the schema we
|
||||
// want to forward to the exported bundle. We carry only the
|
||||
// SCHEMA — never user values. Exporting must be safe on a
|
||||
// project with live config: the schema is author-supplied
|
||||
// metadata; the values in `config.json` are the current user's
|
||||
// secrets or personal settings.
|
||||
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
|
||||
from: plan.projectDir
|
||||
)
|
||||
|
||||
// Bump schemaVersion to 2 when a schema is carried through;
|
||||
// remain on 1 otherwise so schema-less exports stay
|
||||
// byte-compatible with existing v2.2 catalog validators.
|
||||
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
||||
|
||||
// Manifest — claims exactly what we just wrote
|
||||
let manifest = ProjectTemplateManifest(
|
||||
schemaVersion: 1,
|
||||
schemaVersion: schemaVersion,
|
||||
id: inputs.templateId,
|
||||
name: inputs.templateName,
|
||||
version: inputs.templateVersion,
|
||||
@@ -204,8 +220,10 @@ struct ProjectTemplateExporter: Sendable {
|
||||
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil
|
||||
)
|
||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
||||
config: forwardedSchema?.fields.count
|
||||
),
|
||||
config: forwardedSchema
|
||||
)
|
||||
let manifestEncoder = JSONEncoder()
|
||||
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
@@ -239,6 +257,23 @@ struct ProjectTemplateExporter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
|
||||
/// present) and pull out just the config schema. Values in
|
||||
/// `.scarf/config.json` are intentionally ignored — an exported
|
||||
/// bundle carries the schema's shape, never the current user's
|
||||
/// configured values.
|
||||
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
|
||||
let manifestPath = projectDir + "/.scarf/manifest.json"
|
||||
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||
// Use a bespoke decode rather than ProjectTemplateManifest so
|
||||
// this helper stays resilient if the manifest shape evolves
|
||||
// incompatibly in a future release.
|
||||
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
|
||||
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
|
||||
return onlyConfig.config
|
||||
}
|
||||
|
||||
/// Convert a live cron job (with runtime state) into the spec the
|
||||
/// installer will feed back to `hermes cron create`. Only preserves
|
||||
/// fields the CLI accepts.
|
||||
|
||||
@@ -87,14 +87,46 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
let transport = context.makeTransport()
|
||||
try transport.createDirectory(plan.projectDir)
|
||||
for copy in plan.projectFiles {
|
||||
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
|
||||
// Empty `sourceRelativePath` is the "synthesized content"
|
||||
// sentinel used by `buildPlan` for `.scarf/config.json`.
|
||||
// The installer materialises config.json from
|
||||
// `plan.configValues` here rather than copying a bundle
|
||||
// file that doesn't exist.
|
||||
if copy.sourceRelativePath.isEmpty {
|
||||
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
|
||||
let data = try encodeConfigFile(plan: plan)
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
continue
|
||||
}
|
||||
throw ProjectTemplateError.requiredFileMissing(
|
||||
"synthesized file with unknown destination: \(copy.destinationPath)"
|
||||
)
|
||||
}
|
||||
|
||||
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
|
||||
/// shape. Secrets appear as `keychainRef` URIs — the raw bytes were
|
||||
/// routed into the Keychain by the VM before `install()` was called.
|
||||
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: plan.manifest.id,
|
||||
values: plan.configValues,
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(file)
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||
@@ -189,6 +221,21 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
plan: TemplateInstallPlan,
|
||||
cronJobNames: [String]
|
||||
) throws {
|
||||
// Every value that ended up as a keychainRef in config.json gets
|
||||
// tracked in the lock so the uninstaller can SecItemDelete each
|
||||
// entry. Field keys are recorded separately for informational
|
||||
// display in the uninstall preview sheet.
|
||||
let keychainItems: [String]? = {
|
||||
let refs = plan.configValues.compactMap { (_, value) -> String? in
|
||||
if case .keychainRef(let uri) = value { return uri } else { return nil }
|
||||
}
|
||||
return refs.isEmpty ? nil : refs.sorted()
|
||||
}()
|
||||
let configFields: [String]? = {
|
||||
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||
return schema.fields.map(\.key)
|
||||
}()
|
||||
|
||||
let lock = TemplateLock(
|
||||
templateId: plan.manifest.id,
|
||||
templateVersion: plan.manifest.version,
|
||||
@@ -198,7 +245,9 @@ struct ProjectTemplateInstaller: Sendable {
|
||||
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||
cronJobNames: cronJobNames,
|
||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id
|
||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||
configKeychainItems: keychainItems,
|
||||
configFields: configFields
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
@@ -52,10 +52,27 @@ struct ProjectTemplateService: Sendable {
|
||||
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
guard manifest.schemaVersion == 1 else {
|
||||
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
|
||||
// optional `config` block. Both are valid. Newer versions get
|
||||
// refused so the installer never silently misinterprets a
|
||||
// future-shape bundle.
|
||||
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
|
||||
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
||||
}
|
||||
|
||||
// Validate the optional config schema at inspect time — a
|
||||
// malformed schema (duplicate keys, secret-with-default, etc.)
|
||||
// gets rejected before the user ever sees the preview sheet.
|
||||
if let schema = manifest.config {
|
||||
do {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
} catch {
|
||||
throw ProjectTemplateError.manifestParseFailed(
|
||||
"invalid config schema: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let files = try Self.walk(unpackedDir)
|
||||
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
||||
@@ -179,6 +196,37 @@ struct ProjectTemplateService: Sendable {
|
||||
)
|
||||
}
|
||||
|
||||
// Configuration schema + manifest cache. The installer writes
|
||||
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
|
||||
// (schema cache used by the post-install editor) when the
|
||||
// template declares a non-empty schema. Both paths go into
|
||||
// projectFiles so the uninstaller picks them up via the lock.
|
||||
var configSchema: TemplateConfigSchema? = nil
|
||||
var manifestCachePath: String? = nil
|
||||
if let schema = manifest.config, !schema.isEmpty {
|
||||
configSchema = schema
|
||||
let configPath = projectDir + "/.scarf/config.json"
|
||||
projectFiles.append(
|
||||
// Source is synthesized by the installer from configValues;
|
||||
// no file in the unpacked bundle maps to this entry. We use
|
||||
// an empty `sourceRelativePath` as the "no physical source"
|
||||
// sentinel — the installer special-cases it below (see
|
||||
// ProjectTemplateInstaller.createProjectFiles).
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "",
|
||||
destinationPath: configPath
|
||||
)
|
||||
)
|
||||
let cachePath = projectDir + "/.scarf/manifest.json"
|
||||
manifestCachePath = cachePath
|
||||
projectFiles.append(
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "template.json",
|
||||
destinationPath: cachePath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return TemplateInstallPlan(
|
||||
manifest: manifest,
|
||||
unpackedDir: inspection.unpackedDir,
|
||||
@@ -189,7 +237,10 @@ struct ProjectTemplateService: Sendable {
|
||||
cronJobs: cronJobs,
|
||||
memoryAppendix: memoryAppendix,
|
||||
memoryPath: context.paths.memoryMD,
|
||||
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context)
|
||||
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
|
||||
configSchema: configSchema,
|
||||
configValues: [:], // filled in by TemplateInstallerViewModel before install()
|
||||
manifestCachePath: manifestCachePath
|
||||
)
|
||||
}
|
||||
|
||||
@@ -418,6 +469,18 @@ struct ProjectTemplateService: Sendable {
|
||||
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
||||
)
|
||||
}
|
||||
|
||||
// Config claim must match the schema's actual field count so
|
||||
// the preview sheet is honest about the size of the configure
|
||||
// step. `nil` in contents means "no schema" just like `0`;
|
||||
// we normalise both to 0 before comparing.
|
||||
let claimedConfig = manifest.contents.config ?? 0
|
||||
let actualConfig = manifest.config?.fields.count ?? 0
|
||||
if claimedConfig != actualConfig {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a project-registry name that doesn't collide. Deterministic
|
||||
|
||||
@@ -183,6 +183,24 @@ struct ProjectTemplateUninstaller: Sendable {
|
||||
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
||||
}
|
||||
|
||||
// 4a. Config Keychain items — remove every secret the template's
|
||||
// install step stashed in the login Keychain. Items that were
|
||||
// already deleted (e.g. user cleaned them with Keychain Access)
|
||||
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
|
||||
// a stale lock doesn't abort the rest of the uninstall.
|
||||
let keychain = ProjectConfigKeychain()
|
||||
for uri in plan.lock.configKeychainItems ?? [] {
|
||||
guard let ref = TemplateKeychainRef.parse(uri) else {
|
||||
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try keychain.delete(ref: ref)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Projects registry — remove the entry by path (more stable
|
||||
// than name: user may have renamed the project in the UI).
|
||||
let dashboardService = ProjectDashboardService(context: context)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -143,20 +143,19 @@ struct ManageServersView: View {
|
||||
}
|
||||
|
||||
/// A star button that marks the open-on-launch default. Filled + yellow
|
||||
/// on the current default row (disabled, since clicking would be a
|
||||
/// no-op); outline + secondary elsewhere, clicking promotes that row
|
||||
/// to default.
|
||||
/// on the current default row (and non-interactive — clicking it is a
|
||||
/// no-op since the flag is already set); outline + secondary elsewhere,
|
||||
/// clicking promotes that row to default.
|
||||
@ViewBuilder
|
||||
private func defaultStar(for id: ServerID, currentDefault: ServerID) -> some View {
|
||||
let isDefault = id == currentDefault
|
||||
Button {
|
||||
registry.setDefaultServer(id)
|
||||
if !isDefault { registry.setDefaultServer(id) }
|
||||
} label: {
|
||||
Image(systemName: isDefault ? "star.fill" : "star")
|
||||
.foregroundStyle(isDefault ? .yellow : .secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isDefault)
|
||||
.help(isDefault ? "Opens on launch" : "Set as default — open this server when Scarf launches.")
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ final class SettingsViewModel {
|
||||
var hermesRunning = false
|
||||
var rawConfigYAML = ""
|
||||
var personalities: [String] = []
|
||||
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "google-ai-studio", "xai", "ollama-cloud", "zai", "kimi-coding", "minimax"]
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,6 @@ import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
|
||||
var body: some View {
|
||||
@Bindable var coordinator = coordinator
|
||||
@@ -60,6 +59,6 @@ struct SidebarView: View {
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.navigationTitle("Scarf")
|
||||
.splitViewAutosaveName("ScarfMainSidebar.\(serverContext.id)")
|
||||
.splitViewAutosaveName("ScarfMainSidebar")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,10 +106,15 @@ import Foundation
|
||||
id: String = "test/example",
|
||||
cron: Int? = nil,
|
||||
skills: [String]? = nil,
|
||||
instructions: [String]? = nil
|
||||
instructions: [String]? = nil,
|
||||
configFieldCount: Int? = nil,
|
||||
configSchema: TemplateConfigSchema? = nil
|
||||
) -> ProjectTemplateManifest {
|
||||
ProjectTemplateManifest(
|
||||
schemaVersion: 1,
|
||||
// schemaVersion auto-bumps to 2 when a schema is present so tests
|
||||
// that exercise the schema path mirror real manifest behaviour.
|
||||
let version = (configSchema != nil) ? 2 : 1
|
||||
return ProjectTemplateManifest(
|
||||
schemaVersion: version,
|
||||
id: id,
|
||||
name: "Example",
|
||||
version: "1.0.0",
|
||||
@@ -127,8 +132,10 @@ import Foundation
|
||||
instructions: instructions,
|
||||
skills: skills,
|
||||
cron: cron,
|
||||
memory: nil
|
||||
)
|
||||
memory: nil,
|
||||
config: configFieldCount ?? configSchema?.fields.count
|
||||
),
|
||||
config: configSchema
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,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()
|
||||
@@ -363,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()
|
||||
@@ -484,6 +491,283 @@ import Foundation
|
||||
}
|
||||
}
|
||||
|
||||
/// End-to-end tests for manifest schemaVersion 2 (template configuration).
|
||||
/// Exercises the full cycle: inspect → buildPlan → install → uninstall
|
||||
/// against a synthesized schemaful bundle. Uses an isolated Keychain
|
||||
/// service suffix so no leftover login-Keychain items remain after the
|
||||
/// test — every secret we write is deleted on teardown.
|
||||
@Suite(.serialized) struct ProjectTemplateConfigInstallTests {
|
||||
|
||||
/// Minimal schemaful manifest with one non-secret field + one
|
||||
/// secret field. Written into the synthesized `.scarftemplate`
|
||||
/// bundle for the round-trip tests.
|
||||
static func makeSchemafulManifest() -> ProjectTemplateManifest {
|
||||
ProjectTemplateServiceTests.sampleManifest(
|
||||
id: "tester/configured",
|
||||
configSchema: TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "site_url", type: .string, label: "Site URL",
|
||||
description: "where to ping", required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil),
|
||||
.init(key: "api_token", type: .secret, label: "API Token",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil),
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test func inspectAcceptsSchemaV2Bundle() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestData = try JSONEncoder().encode(manifest)
|
||||
let manifestString = String(data: manifestData, encoding: .utf8)!
|
||||
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestString,
|
||||
"README.md": "# r",
|
||||
"AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
|
||||
#expect(inspection.manifest.schemaVersion == 2)
|
||||
#expect(inspection.manifest.config?.fields.count == 2)
|
||||
}
|
||||
|
||||
@Test func buildPlanSurfacesSchemaAndQueuesConfigFiles() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||
|
||||
// Schema carried through the plan.
|
||||
#expect(plan.configSchema?.fields.count == 2)
|
||||
#expect(plan.manifestCachePath?.hasSuffix("/.scarf/manifest.json") == true)
|
||||
// config.json + manifest.json entries in projectFiles.
|
||||
let destinations = plan.projectFiles.map(\.destinationPath)
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/config.json") })
|
||||
#expect(destinations.contains { $0.hasSuffix("/.scarf/manifest.json") })
|
||||
}
|
||||
|
||||
@Test func verifyClaimsRejectsConfigCountMismatch() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
|
||||
// Hand-build a manifest whose contents.config claim (2) doesn't
|
||||
// match its schema.fields.count (1) — validator should reject.
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "only", type: .string, label: "Only",
|
||||
description: nil, required: false, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let bogus = ProjectTemplateServiceTests.sampleManifest(
|
||||
id: "tester/mismatch",
|
||||
configFieldCount: 2, // claim lies
|
||||
configSchema: schema // reality is 1
|
||||
)
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(bogus), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
#expect(throws: ProjectTemplateError.self) {
|
||||
try service.inspect(zipPath: bundle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func installWritesConfigJsonAndManifestCache() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let parentDir = scratch + "/parent"
|
||||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||
|
||||
// Isolated Keychain service suffix so the test doesn't touch
|
||||
// the real login Keychain.
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let configService = ProjectConfigService(keychain: keychain)
|
||||
|
||||
// Store secret via the service (VM would do this before install).
|
||||
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||
let secretRef = try configService.storeSecret(
|
||||
templateSlug: manifest.slug,
|
||||
fieldKey: "api_token",
|
||||
project: project,
|
||||
secret: Data("sk-top-secret".utf8)
|
||||
)
|
||||
plan.configValues = [
|
||||
"site_url": .string("https://example.com"),
|
||||
"api_token": secretRef
|
||||
]
|
||||
|
||||
let registryBefore = Self.snapshotRegistry()
|
||||
defer { Self.restoreRegistry(registryBefore) }
|
||||
|
||||
let installer = ProjectTemplateInstaller(context: .local)
|
||||
_ = try installer.install(plan: plan)
|
||||
|
||||
// config.json landed with non-secret values + keychain ref.
|
||||
let configPath = plan.projectDir + "/.scarf/config.json"
|
||||
#expect(FileManager.default.fileExists(atPath: configPath))
|
||||
let configData = try Data(contentsOf: URL(fileURLWithPath: configPath))
|
||||
let configFile = try JSONDecoder().decode(ProjectConfigFile.self, from: configData)
|
||||
#expect(configFile.values["site_url"] == .string("https://example.com"))
|
||||
if case .keychainRef(let uri) = configFile.values["api_token"] {
|
||||
#expect(uri.hasPrefix("keychain://"))
|
||||
} else {
|
||||
Issue.record("api_token should have been stored as keychainRef")
|
||||
}
|
||||
|
||||
// manifest.json cache landed for the post-install editor.
|
||||
let cachePath = plan.projectDir + "/.scarf/manifest.json"
|
||||
#expect(FileManager.default.fileExists(atPath: cachePath))
|
||||
let cachedManifest = try JSONDecoder().decode(
|
||||
ProjectTemplateManifest.self,
|
||||
from: Data(contentsOf: URL(fileURLWithPath: cachePath))
|
||||
)
|
||||
#expect(cachedManifest.config?.fields.count == 2)
|
||||
|
||||
// Lock file records the keychain item so uninstall can clean up.
|
||||
let lockPath = plan.projectDir + "/.scarf/template.lock.json"
|
||||
let lockData = try Data(contentsOf: URL(fileURLWithPath: lockPath))
|
||||
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||
#expect(lock.configKeychainItems?.count == 1)
|
||||
#expect(lock.configFields == ["site_url", "api_token"])
|
||||
|
||||
// Clean up the real Keychain entry we created outside the
|
||||
// test-suffixed namespace (storeSecret uses real service name
|
||||
// because the test's config-service wasn't isolated for this
|
||||
// call's secret; we manually delete via our test keychain).
|
||||
if let ref = TemplateKeychainRef.parse(
|
||||
(configFile.values["api_token"].flatMap { v -> String? in
|
||||
if case .keychainRef(let u) = v { return u } else { return nil }
|
||||
}) ?? ""
|
||||
) {
|
||||
try? ProjectConfigKeychain().delete(ref: ref)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func uninstallDeletesKeychainItemsViaLock() throws {
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let parentDir = scratch + "/parent"
|
||||
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
|
||||
|
||||
let manifest = Self.makeSchemafulManifest()
|
||||
let manifestJSON = String(data: try JSONEncoder().encode(manifest), encoding: .utf8)!
|
||||
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
|
||||
"template.json": manifestJSON,
|
||||
"README.md": "# r", "AGENTS.md": "# a",
|
||||
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
|
||||
], includeManifest: false)
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
var plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||
|
||||
// Real Keychain — we store, install, then uninstall and verify
|
||||
// the item is gone. Uses the real service name (no test suffix)
|
||||
// because the installer + uninstaller go through their own
|
||||
// ProjectConfigKeychain instances without a suffix.
|
||||
let project = ProjectEntry(name: manifest.name, path: plan.projectDir)
|
||||
let configService = ProjectConfigService()
|
||||
let secretRef = try configService.storeSecret(
|
||||
templateSlug: manifest.slug,
|
||||
fieldKey: "api_token",
|
||||
project: project,
|
||||
secret: Data("delete-me".utf8)
|
||||
)
|
||||
plan.configValues = [
|
||||
"site_url": .string("https://example.com"),
|
||||
"api_token": secretRef
|
||||
]
|
||||
|
||||
let registryBefore = Self.snapshotRegistry()
|
||||
defer { Self.restoreRegistry(registryBefore) }
|
||||
|
||||
let installer = ProjectTemplateInstaller(context: .local)
|
||||
let entry = try installer.install(plan: plan)
|
||||
|
||||
// Verify the secret is there before uninstall.
|
||||
guard case .keychainRef(let uri) = secretRef,
|
||||
let ref = TemplateKeychainRef.parse(uri) else {
|
||||
Issue.record("expected secret to be a keychainRef")
|
||||
return
|
||||
}
|
||||
#expect((try ProjectConfigKeychain().get(ref: ref)) == Data("delete-me".utf8))
|
||||
|
||||
// Uninstall → secret should be gone.
|
||||
let uninstaller = ProjectTemplateUninstaller(context: .local)
|
||||
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
|
||||
try uninstaller.uninstall(plan: uninstallPlan)
|
||||
|
||||
#expect((try ProjectConfigKeychain().get(ref: ref)) == nil)
|
||||
}
|
||||
|
||||
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
||||
|
||||
nonisolated private static func snapshotRegistry() -> Data? {
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||
let path = ServerContext.local.paths.projectsRegistry
|
||||
if let snapshot {
|
||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates every `.scarftemplate` shipped under `templates/<author>/<name>/`
|
||||
/// in the repo. A template whose manifest, `contents` claim, or file set is
|
||||
/// out of sync will fail here — so shipped templates can't silently rot.
|
||||
@@ -497,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)
|
||||
@@ -511,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")
|
||||
@@ -536,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"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
// MARK: - Schema validation
|
||||
|
||||
@Suite struct TemplateConfigSchemaValidationTests {
|
||||
|
||||
@Test func acceptsMinimalValidSchema() throws {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "name", type: .string, label: "Name",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
|
||||
@Test func rejectsDuplicateKeys() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "same", type: .string, label: "A", description: nil,
|
||||
required: false, placeholder: nil, defaultValue: nil,
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil),
|
||||
.init(key: "same", type: .bool, label: "B", description: nil,
|
||||
required: false, placeholder: nil, defaultValue: nil,
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsSecretWithDefault() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "api_key", type: .secret, label: "API Key",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: .string("leaked-by-accident"),
|
||||
options: nil, minLength: nil, maxLength: nil,
|
||||
pattern: nil, minNumber: nil, maxNumber: nil,
|
||||
step: nil, itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEnumWithoutOptions() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "choice", type: .enum, label: "Choice",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: [],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEnumWithDuplicateValues() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "choice", type: .enum, label: "Choice",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil,
|
||||
options: [.init(value: "a", label: "A"),
|
||||
.init(value: "a", label: "Another A")],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsUnsupportedListItemType() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "items", type: .list, label: "Items",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil,
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: "number", minItems: 1, maxItems: 10)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func rejectsEmptyModelPreferred() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [],
|
||||
modelRecommendation: .init(preferred: " ", rationale: nil, alternatives: nil)
|
||||
)
|
||||
#expect(throws: TemplateConfigSchemaError.self) {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Value validation
|
||||
|
||||
@Suite struct TemplateConfigValueValidationTests {
|
||||
|
||||
@Test func requiredFieldRejectsEmptyString() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "name", type: .string, label: "Name",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["name": .string("")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
#expect(errors.first?.fieldKey == "name")
|
||||
}
|
||||
|
||||
@Test func patternRejectsBadInput() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "email", type: .string, label: "Email",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: "^[^@]+@[^@]+$",
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["email": .string("not-an-email")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func numberRangeEnforced() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "port", type: .number, label: "Port",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: 1024,
|
||||
maxNumber: 65535, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["port": .number(80)], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func enumRejectsUnknownValue() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "mode", type: .enum, label: "Mode",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil,
|
||||
options: [.init(value: "fast", label: "Fast"),
|
||||
.init(value: "slow", label: "Slow")],
|
||||
minLength: nil, maxLength: nil, pattern: nil,
|
||||
minNumber: nil, maxNumber: nil, step: nil,
|
||||
itemType: nil, minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["mode": .string("medium")], against: schema
|
||||
)
|
||||
#expect(errors.count == 1)
|
||||
}
|
||||
|
||||
@Test func listItemBoundsEnforced() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "urls", type: .list, label: "URLs",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: "string",
|
||||
minItems: 1, maxItems: 3)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let tooFew = ProjectConfigService.validateValues(
|
||||
["urls": .list([])], against: schema
|
||||
)
|
||||
let tooMany = ProjectConfigService.validateValues(
|
||||
["urls": .list(["a", "b", "c", "d"])], against: schema
|
||||
)
|
||||
let justRight = ProjectConfigService.validateValues(
|
||||
["urls": .list(["a", "b"])], against: schema
|
||||
)
|
||||
#expect(tooFew.count == 1)
|
||||
#expect(tooMany.count == 1)
|
||||
#expect(justRight.isEmpty)
|
||||
}
|
||||
|
||||
@Test func secretFieldAcceptsKeychainRef() {
|
||||
let schema = TemplateConfigSchema(
|
||||
fields: [
|
||||
.init(key: "tok", type: .secret, label: "Token",
|
||||
description: nil, required: true, placeholder: nil,
|
||||
defaultValue: nil, options: nil, minLength: nil,
|
||||
maxLength: nil, pattern: nil, minNumber: nil,
|
||||
maxNumber: nil, step: nil, itemType: nil,
|
||||
minItems: nil, maxItems: nil)
|
||||
],
|
||||
modelRecommendation: nil
|
||||
)
|
||||
let errors = ProjectConfigService.validateValues(
|
||||
["tok": .keychainRef("keychain://test/tok:abc")],
|
||||
against: schema
|
||||
)
|
||||
#expect(errors.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain ref helpers
|
||||
|
||||
@Suite struct TemplateKeychainRefTests {
|
||||
|
||||
@Test func uriRoundTrips() {
|
||||
let ref = TemplateKeychainRef(
|
||||
service: "com.scarf.template.alice-foo",
|
||||
account: "api_key:deadbeef"
|
||||
)
|
||||
#expect(ref.uri == "keychain://com.scarf.template.alice-foo/api_key:deadbeef")
|
||||
let parsed = TemplateKeychainRef.parse(ref.uri)
|
||||
#expect(parsed == ref)
|
||||
}
|
||||
|
||||
@Test func parseRejectsMalformedUris() {
|
||||
#expect(TemplateKeychainRef.parse("") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain://") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain:///account-only") == nil)
|
||||
#expect(TemplateKeychainRef.parse("keychain://service-only") == nil)
|
||||
#expect(TemplateKeychainRef.parse("https://example.com/foo") == nil)
|
||||
}
|
||||
|
||||
@Test func hashDiffersByProjectPath() {
|
||||
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p2")
|
||||
#expect(a.service == b.service) // same template
|
||||
#expect(a.account != b.account) // different project → different hash suffix
|
||||
}
|
||||
|
||||
@Test func hashStableForSamePath() {
|
||||
let a = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
let b = TemplateKeychainRef.make(templateSlug: "s", fieldKey: "k", projectPath: "/Users/a/p1")
|
||||
#expect(a == b)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk config round-trip
|
||||
|
||||
@Suite struct ProjectConfigFileTests {
|
||||
|
||||
@Test func roundTripsNonSecretValues() throws {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: "alice/example",
|
||||
values: [
|
||||
"name": .string("Alice"),
|
||||
"enabled": .bool(true),
|
||||
"count": .number(42),
|
||||
"tags": .list(["a", "b", "c"]),
|
||||
],
|
||||
updatedAt: "2026-04-25T00:00:00Z"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(file)
|
||||
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||
#expect(decoded.schemaVersion == 2)
|
||||
#expect(decoded.templateId == "alice/example")
|
||||
#expect(decoded.values["name"] == .string("Alice"))
|
||||
#expect(decoded.values["enabled"] == .bool(true))
|
||||
#expect(decoded.values["count"] == .number(42))
|
||||
#expect(decoded.values["tags"] == .list(["a", "b", "c"]))
|
||||
}
|
||||
|
||||
@Test func preservesKeychainRefsOnRoundTrip() throws {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: "alice/example",
|
||||
values: ["tok": .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef")],
|
||||
updatedAt: "2026-04-25T00:00:00Z"
|
||||
)
|
||||
let encoded = try JSONEncoder().encode(file)
|
||||
let decoded = try JSONDecoder().decode(ProjectConfigFile.self, from: encoded)
|
||||
// Keychain refs must NOT demote to plain strings on round-trip
|
||||
// — otherwise a post-install editor would lose the secret
|
||||
// binding when saving unchanged values.
|
||||
#expect(decoded.values["tok"] == .keychainRef("keychain://com.scarf.template.alice-example/tok:deadbeef"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ProjectConfigService + Keychain integration
|
||||
|
||||
/// Exercises the full secret-storage path through a real macOS Keychain
|
||||
/// with a test-only service suffix so nothing leaks into the user's
|
||||
/// login Keychain. Every test sets + reads + deletes within a unique
|
||||
/// service name so parallel runs don't collide.
|
||||
@Suite struct ProjectConfigSecretsTests {
|
||||
|
||||
@Test func storeAndResolveSecret() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let service = ProjectConfigService(keychain: keychain)
|
||||
let project = ProjectEntry(name: "Scratch", path: NSTemporaryDirectory() + UUID().uuidString)
|
||||
|
||||
let stored = try service.storeSecret(
|
||||
templateSlug: "alice-example",
|
||||
fieldKey: "api_key",
|
||||
project: project,
|
||||
secret: Data("hunter2".utf8)
|
||||
)
|
||||
|
||||
// What goes into config.json is a keychainRef, not the bytes.
|
||||
guard case .keychainRef(let uri) = stored else {
|
||||
Issue.record("expected keychainRef, got \(stored)")
|
||||
return
|
||||
}
|
||||
#expect(uri.hasPrefix("keychain://"))
|
||||
|
||||
// Resolve brings the bytes back.
|
||||
let resolved = try service.resolveSecret(ref: stored)
|
||||
#expect(resolved == Data("hunter2".utf8))
|
||||
|
||||
// Clean up so we don't leave a test item in the Keychain.
|
||||
if let ref = TemplateKeychainRef.parse(uri) {
|
||||
try keychain.delete(ref: ref)
|
||||
#expect((try keychain.get(ref: ref)) == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func setOverwritesExistingSecret() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let ref = TemplateKeychainRef(service: "com.scarf.template.overwrite", account: "k:1")
|
||||
try keychain.set(ref: ref, secret: Data("first".utf8))
|
||||
try keychain.set(ref: ref, secret: Data("second".utf8))
|
||||
#expect((try keychain.get(ref: ref)) == Data("second".utf8))
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
|
||||
@Test func deleteOfMissingItemSucceeds() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let ref = TemplateKeychainRef(service: "com.scarf.template.absent", account: "never:set")
|
||||
// Deleting a non-existent item is a no-op — must not throw.
|
||||
try keychain.delete(ref: ref)
|
||||
}
|
||||
|
||||
@Test func deleteMultipleSecretsClearsAll() throws {
|
||||
let suffix = "tests-" + UUID().uuidString
|
||||
let keychain = ProjectConfigKeychain(testServiceSuffix: suffix)
|
||||
let service = ProjectConfigService(keychain: keychain)
|
||||
|
||||
let refs = (0..<3).map { i in
|
||||
TemplateKeychainRef(service: "com.scarf.template.bulk", account: "k:\(i)")
|
||||
}
|
||||
for ref in refs {
|
||||
try keychain.set(ref: ref, secret: Data("v".utf8))
|
||||
}
|
||||
try service.deleteSecrets(refs: refs)
|
||||
for ref in refs {
|
||||
#expect((try keychain.get(ref: ref)) == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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
@@ -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
@@ -408,12 +408,116 @@
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Config-schema display (v2.3 — template configuration).
|
||||
// ---------------------------------------------------------------------
|
||||
//
|
||||
// Renders the author-declared schema as a read-only listing on the
|
||||
// catalog detail page. The site itself never collects values — the
|
||||
// form UI lives inside the Scarf app. This is purely informational
|
||||
// so visitors know what they'll need to fill in before installing.
|
||||
|
||||
/**
|
||||
* Render a manifest.config block into `container` as a summary.
|
||||
* Safe to call with a null schema (no-op).
|
||||
* @param {HTMLElement} container
|
||||
* @param {{schema: Array, modelRecommendation?: object} | null | undefined} config
|
||||
*/
|
||||
function renderConfigSchema(container, config) {
|
||||
container.innerHTML = "";
|
||||
if (!config || !Array.isArray(config.schema) || config.schema.length === 0) {
|
||||
return;
|
||||
}
|
||||
const wrap = elt("div", "config-schema");
|
||||
const header = elt("h3", "config-schema-header", "Configuration");
|
||||
wrap.appendChild(header);
|
||||
const desc = elt("p", "config-schema-desc",
|
||||
"Fields you'll fill in during install. Secrets are stored in the macOS Keychain; non-secret values live at <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);
|
||||
|
||||
Binary file not shown.
@@ -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 3–5 entries for that URL in `status-log.md` and report any pattern you see (consistent timeouts, intermittent 5xx, DNS failures, etc.). Don't speculate beyond what the log shows.
|
||||
|
||||
@@ -2,32 +2,38 @@
|
||||
|
||||
A minimal uptime watchdog that pings a list of URLs once a day, records pass/fail results, and keeps a simple Scarf dashboard up to date.
|
||||
|
||||
**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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user