mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Merge branch 'project-sharing': v2.2.0 — templates + configuration + catalog
Brings in 22 commits delivering the full v2.2.0 scope:
- Project Templates: .scarftemplate bundle format (install, uninstall,
export, URL router) + install preview sheet + cross-agent AGENTS.md
- Template Configuration (schemaVersion 2): typed schema with 7 field
types, Keychain-backed secrets, Configure step in install flow,
post-install Configuration editor, model recommendations
- Template Catalog: gh-pages site generated from templates/<author>/<name>/,
stdlib-only Python validator mirroring Swift invariants, PR CI gate,
install-URL hosting from raw main
- Example template: awizemann/site-status-checker (config + cron + Site
tab webview updates)
- Site tab: webview widget in any dashboard exposes a second tab
- UX: Remove from List vs. Uninstall Template clarification, preserved-
files banner, Run Now no longer blocks on long agent runs, markdown
in install sheet, install-time {{PROJECT_DIR}} token substitution
Release notes at releases/v2.2.0/RELEASE_NOTES.md (94 lines).
Wiki page at https://github.com/awizemann/scarf/wiki/Project-Templates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Manifest (what lives inside the .scarftemplate zip)
|
||||
|
||||
/// On-disk manifest for a Scarf project template. Shipped as `template.json`
|
||||
/// at the root of a `.scarftemplate` (zip) bundle.
|
||||
///
|
||||
/// The `contents` block is a claim the author makes about what the bundle
|
||||
/// ships; the installer verifies the claim against the actual unpacked files
|
||||
/// before showing the preview sheet so a malicious bundle can't hide extra
|
||||
/// files from the user.
|
||||
struct ProjectTemplateManifest: Codable, Sendable, Equatable {
|
||||
let schemaVersion: Int
|
||||
let id: String
|
||||
let name: String
|
||||
let version: String
|
||||
let minScarfVersion: String?
|
||||
let minHermesVersion: String?
|
||||
let author: TemplateAuthor?
|
||||
let description: String
|
||||
let category: String?
|
||||
let tags: [String]?
|
||||
let icon: String?
|
||||
let screenshots: [String]?
|
||||
let contents: TemplateContents
|
||||
/// Optional configuration schema (added in manifest schemaVersion 2).
|
||||
/// When present, the installer presents a form during install and
|
||||
/// writes values to `<project>/.scarf/config.json` + the Keychain.
|
||||
/// Schema-v1 manifests omit this field entirely — Codable's
|
||||
/// optional-field decoding keeps them working unchanged.
|
||||
let config: TemplateConfigSchema?
|
||||
|
||||
/// Filesystem-safe slug derived from `id` (`"owner/name"` → `"owner-name"`).
|
||||
/// Used for the install directory name, skills namespace, and cron-job tag.
|
||||
nonisolated var slug: String {
|
||||
let ascii = id.unicodeScalars.map { scalar -> Character in
|
||||
let c = Character(scalar)
|
||||
if c.isLetter || c.isNumber || c == "-" || c == "_" { return c }
|
||||
return "-"
|
||||
}
|
||||
let collapsed = String(ascii)
|
||||
.split(separator: "-", omittingEmptySubsequences: true)
|
||||
.joined(separator: "-")
|
||||
return collapsed.isEmpty ? "template" : collapsed
|
||||
}
|
||||
}
|
||||
|
||||
struct TemplateAuthor: Codable, Sendable, Equatable {
|
||||
let name: String
|
||||
let url: String?
|
||||
}
|
||||
|
||||
struct TemplateContents: Codable, Sendable, Equatable {
|
||||
let dashboard: Bool
|
||||
let agentsMd: Bool
|
||||
let instructions: [String]?
|
||||
let skills: [String]?
|
||||
let cron: Int?
|
||||
let memory: TemplateMemoryClaim?
|
||||
/// Number of configuration fields the template ships (schemaVersion 2+).
|
||||
/// Cross-checked against `manifest.config?.fields.count` by the
|
||||
/// validator so a bundle can't hide a schema from the preview.
|
||||
/// `nil` or `0` means schema-less (v1-compatible behaviour).
|
||||
let config: Int?
|
||||
}
|
||||
|
||||
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
|
||||
let append: Bool
|
||||
}
|
||||
|
||||
// MARK: - Inspection (what we learn by unpacking the zip)
|
||||
|
||||
/// Result of unpacking a `.scarftemplate` into a temp directory and validating
|
||||
/// it. Callers hand this to `buildInstallPlan` to produce the concrete
|
||||
/// filesystem plan.
|
||||
struct TemplateInspection: Sendable {
|
||||
let manifest: ProjectTemplateManifest
|
||||
/// Absolute path to the temp directory holding the unpacked bundle. The
|
||||
/// installer reads files from here; the caller is responsible for
|
||||
/// cleaning it up after install (or cancel).
|
||||
let unpackedDir: String
|
||||
/// Every file found in the unpacked dir, as paths relative to
|
||||
/// `unpackedDir`. Verified against the manifest's `contents` claim.
|
||||
let files: [String]
|
||||
/// Parsed cron jobs (may be empty even if the manifest claims some —
|
||||
/// verification catches that mismatch).
|
||||
let cronJobs: [TemplateCronJobSpec]
|
||||
}
|
||||
|
||||
/// The subset of a Hermes cron job that a template can ship. Only the fields
|
||||
/// the `hermes cron create` CLI accepts are included; runtime state
|
||||
/// (`enabled`, `state`, `next_run_at`, …) is deliberately omitted so a
|
||||
/// template can't arrive already-running.
|
||||
struct TemplateCronJobSpec: Codable, Sendable, Equatable {
|
||||
let name: String
|
||||
let schedule: String
|
||||
let prompt: String?
|
||||
let deliver: String?
|
||||
let skills: [String]?
|
||||
let repeatCount: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, schedule, prompt, deliver, skills
|
||||
case repeatCount = "repeat"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Install Plan (the preview sheet reads this)
|
||||
|
||||
/// Concrete, reviewed-before-apply filesystem operations the installer will
|
||||
/// perform. Every side effect the installer can cause is represented here so
|
||||
/// the preview sheet is an honest accounting of what's about to happen.
|
||||
struct TemplateInstallPlan: Sendable {
|
||||
let manifest: ProjectTemplateManifest
|
||||
let unpackedDir: String
|
||||
|
||||
/// Absolute path of the new project directory. Installer refuses if this
|
||||
/// already exists.
|
||||
let projectDir: String
|
||||
/// Files that will be created under `projectDir`, keyed by relative path.
|
||||
let projectFiles: [TemplateFileCopy]
|
||||
|
||||
/// Absolute path of the skills namespace dir
|
||||
/// (`~/.hermes/skills/templates/<slug>/`). Created if skills are present.
|
||||
let skillsNamespaceDir: String?
|
||||
/// Files that will be created under the skills namespace dir.
|
||||
let skillsFiles: [TemplateFileCopy]
|
||||
|
||||
/// Cron job definitions to register via `hermes cron create`. Each job's
|
||||
/// name is already prefixed with the template tag. All will be paused
|
||||
/// immediately after creation.
|
||||
let cronJobs: [TemplateCronJobSpec]
|
||||
|
||||
/// Memory appendix text (already wrapped in begin/end markers). `nil`
|
||||
/// means no memory write happens.
|
||||
let memoryAppendix: String?
|
||||
/// Target memory path (`~/.hermes/memories/MEMORY.md`). Only used when
|
||||
/// `memoryAppendix` is non-nil.
|
||||
let memoryPath: String
|
||||
|
||||
/// `ProjectEntry.name` that will be appended to the projects registry.
|
||||
let projectRegistryName: String
|
||||
|
||||
/// Configuration schema declared by the template (manifest schemaVersion 2).
|
||||
/// `nil` means the template is schema-less — the installer skips the
|
||||
/// config sheet and writes no `.scarf/config.json` or manifest cache.
|
||||
let configSchema: TemplateConfigSchema?
|
||||
|
||||
/// Values the user entered in the configure sheet. Populated by the
|
||||
/// VM just before `install()` runs; empty when `configSchema` is nil.
|
||||
/// Secrets appear here as `.keychainRef(...)` — the bytes themselves
|
||||
/// were routed straight from the form field into the Keychain and
|
||||
/// never held in memory past that point.
|
||||
var configValues: [String: TemplateConfigValue]
|
||||
|
||||
/// Path at which the installer will stash a copy of `template.json`
|
||||
/// so the post-install Configuration editor can render the form
|
||||
/// offline. `nil` when `configSchema` is nil.
|
||||
let manifestCachePath: String?
|
||||
|
||||
/// Convenience: total number of writes (files + cron jobs + optional
|
||||
/// memory append + registry append + optional config.json + one
|
||||
/// entry per secret written to the Keychain). Displayed in the
|
||||
/// preview sheet.
|
||||
nonisolated var totalWriteCount: Int {
|
||||
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
|
||||
let secretCount = configValues.values.filter {
|
||||
if case .keychainRef = $0 { return true } else { return false }
|
||||
}.count
|
||||
return projectFiles.count
|
||||
+ skillsFiles.count
|
||||
+ cronJobs.count
|
||||
+ (memoryAppendix == nil ? 0 : 1)
|
||||
+ 1 // registry entry
|
||||
+ configFileCount
|
||||
+ secretCount
|
||||
}
|
||||
}
|
||||
|
||||
/// A single file to copy from the unpacked bundle into a target directory.
|
||||
struct TemplateFileCopy: Sendable, Equatable {
|
||||
/// Path inside `unpackedDir`, e.g. `"AGENTS.md"` or
|
||||
/// `"skills/timer/SKILL.md"`.
|
||||
let sourceRelativePath: String
|
||||
/// Absolute path where the file should land.
|
||||
let destinationPath: String
|
||||
}
|
||||
|
||||
// MARK: - Lock file (uninstall manifest, dropped into <project>/.scarf/)
|
||||
|
||||
/// Dropped at `<project>/.scarf/template.lock.json` after a successful
|
||||
/// install. Records exactly what was written so a future "Uninstall Template"
|
||||
/// action can reverse it without guessing.
|
||||
struct TemplateLock: Codable, Sendable {
|
||||
let templateId: String
|
||||
let templateVersion: String
|
||||
let templateName: String
|
||||
let installedAt: String
|
||||
let projectFiles: [String]
|
||||
let skillsNamespaceDir: String?
|
||||
let skillsFiles: [String]
|
||||
let cronJobNames: [String]
|
||||
let memoryBlockId: String?
|
||||
/// Every `keychain://service/account` URI the installer stored in
|
||||
/// the Keychain for this project's secret fields. Empty/nil for
|
||||
/// schema-less (v1-style) installs. The uninstaller iterates this
|
||||
/// list and calls `SecItemDelete` for each entry; absent on older
|
||||
/// lock files so Codable's optional decoding keeps pre-2.3 installs
|
||||
/// uninstallable.
|
||||
let configKeychainItems: [String]?
|
||||
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
|
||||
/// Informational — the actual removal of config.json rides on
|
||||
/// `projectFiles`. Optional for back-compat.
|
||||
let configFields: [String]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case templateId = "template_id"
|
||||
case templateVersion = "template_version"
|
||||
case templateName = "template_name"
|
||||
case installedAt = "installed_at"
|
||||
case projectFiles = "project_files"
|
||||
case skillsNamespaceDir = "skills_namespace_dir"
|
||||
case skillsFiles = "skills_files"
|
||||
case cronJobNames = "cron_job_names"
|
||||
case memoryBlockId = "memory_block_id"
|
||||
case configKeychainItems = "config_keychain_items"
|
||||
case configFields = "config_fields"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Uninstall Plan (the uninstall-preview sheet reads this)
|
||||
|
||||
/// Symmetric with `TemplateInstallPlan` but for removal. Built from the
|
||||
/// `<project>/.scarf/template.lock.json` the installer wrote. The preview
|
||||
/// sheet lists every path the uninstall would touch; the uninstaller
|
||||
/// executes the listed ops and nothing else.
|
||||
struct TemplateUninstallPlan: Sendable {
|
||||
/// The parsed lock file that seeded this plan. Kept so the sheet can
|
||||
/// display the template id, version, and install timestamp.
|
||||
let lock: TemplateLock
|
||||
/// The registry entry that will be removed on success.
|
||||
let project: ProjectEntry
|
||||
|
||||
/// Lock-tracked files still present on disk that will be removed.
|
||||
let projectFilesToRemove: [String]
|
||||
/// Lock-tracked files that were already missing (e.g. user deleted them
|
||||
/// after install). Shown in the sheet so the user isn't surprised that
|
||||
/// a file isn't removed; uninstaller skips these.
|
||||
let projectFilesAlreadyGone: [String]
|
||||
/// User-added files/dirs in the project dir that are NOT in the lock.
|
||||
/// These are preserved — the sheet lists them so the user knows the
|
||||
/// project dir stays if any exist.
|
||||
let extraProjectEntries: [String]
|
||||
/// If `true`, the project dir ends up empty after removal and will be
|
||||
/// removed along with its files. `false` means user content lives in
|
||||
/// the dir and we leave it.
|
||||
let projectDirBecomesEmpty: Bool
|
||||
|
||||
/// Lock-recorded skills namespace dir. `nil` means the template never
|
||||
/// installed skills. Uninstaller removes the entire dir recursively.
|
||||
let skillsNamespaceDir: String?
|
||||
|
||||
/// Cron jobs that will be removed, as (id, name) pairs. Ids were looked
|
||||
/// up at plan time by matching lock names against the live cron list.
|
||||
let cronJobsToRemove: [(id: String, name: String)]
|
||||
/// Names recorded in the lock that we couldn't find in the current cron
|
||||
/// list (user-deleted, renamed, etc.). Shown in the sheet; skipped on
|
||||
/// uninstall.
|
||||
let cronJobsAlreadyGone: [String]
|
||||
|
||||
/// `true` if MEMORY.md still contains the template's begin/end markers
|
||||
/// and those bytes will be stripped on uninstall. `false` means no
|
||||
/// memory block was ever installed OR the user removed it by hand.
|
||||
let memoryBlockPresent: Bool
|
||||
/// Hermes-side path to MEMORY.md. Only touched when
|
||||
/// `memoryBlockPresent` is true.
|
||||
let memoryPath: String
|
||||
|
||||
nonisolated var totalRemoveCount: Int {
|
||||
projectFilesToRemove.count
|
||||
+ (skillsNamespaceDir == nil ? 0 : 1)
|
||||
+ cronJobsToRemove.count
|
||||
+ (memoryBlockPresent ? 1 : 0)
|
||||
+ 1 // registry entry
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum ProjectTemplateError: LocalizedError, Sendable {
|
||||
case unzipFailed(String)
|
||||
case manifestMissing
|
||||
case manifestParseFailed(String)
|
||||
case unsupportedSchemaVersion(Int)
|
||||
case requiredFileMissing(String)
|
||||
case contentClaimMismatch(String)
|
||||
case projectDirExists(String)
|
||||
case conflictingFile(String)
|
||||
case memoryBlockAlreadyExists(String)
|
||||
case cronCreateFailed(job: String, output: String)
|
||||
case unsafeZipEntry(String)
|
||||
case lockFileMissing(String)
|
||||
case lockFileParseFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .unzipFailed(let s):
|
||||
return "Couldn't unpack template archive: \(s)"
|
||||
case .manifestMissing:
|
||||
return "Template is missing template.json at the archive root."
|
||||
case .manifestParseFailed(let s):
|
||||
return "Template manifest couldn't be parsed: \(s)"
|
||||
case .unsupportedSchemaVersion(let v):
|
||||
return "Template uses schemaVersion \(v), which this version of Scarf doesn't understand."
|
||||
case .requiredFileMissing(let f):
|
||||
return "Template is missing a required file: \(f)"
|
||||
case .contentClaimMismatch(let s):
|
||||
return "Template manifest doesn't match its contents: \(s)"
|
||||
case .projectDirExists(let p):
|
||||
return "A directory already exists at \(p). Refusing to overwrite — choose a different parent folder."
|
||||
case .conflictingFile(let p):
|
||||
return "An existing file would be overwritten at \(p). Refusing to clobber."
|
||||
case .memoryBlockAlreadyExists(let id):
|
||||
return "A memory block for template '\(id)' already exists in MEMORY.md. Remove it first or install a fresh copy."
|
||||
case .cronCreateFailed(let job, let output):
|
||||
return "Failed to register cron job '\(job)': \(output)"
|
||||
case .unsafeZipEntry(let p):
|
||||
return "Template archive contains an unsafe entry: \(p)"
|
||||
case .lockFileMissing(let path):
|
||||
return "No template.lock.json found at \(path). This project wasn't installed by Scarf's template system — remove it by hand."
|
||||
case .lockFileParseFailed(let s):
|
||||
return "Couldn't read template.lock.json: \(s)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Schema (ships inside template.json as manifest.config)
|
||||
|
||||
/// Author-declared configuration schema for a template. Published as the
|
||||
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
|
||||
/// in values at install time via `TemplateConfigSheet`; values land in
|
||||
/// `<project>/.scarf/config.json` with secrets resolved through the
|
||||
/// macOS Keychain.
|
||||
struct TemplateConfigSchema: Codable, Sendable, Equatable {
|
||||
let fields: [TemplateConfigField]
|
||||
let modelRecommendation: TemplateModelRecommendation?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fields = "schema"
|
||||
case modelRecommendation
|
||||
}
|
||||
|
||||
nonisolated var isEmpty: Bool { fields.isEmpty }
|
||||
|
||||
/// Fast lookup by key. Validators guarantee keys are unique within a
|
||||
/// schema at manifest-parse time, so this is safe.
|
||||
nonisolated func field(for key: String) -> TemplateConfigField? {
|
||||
fields.first { $0.key == key }
|
||||
}
|
||||
}
|
||||
|
||||
/// One configurable field the user fills in. Discriminated by `type`.
|
||||
/// We keep one flat struct rather than an enum-associated-value encoding
|
||||
/// so JSON reads cleanly as a record and authors can hand-edit manifests
|
||||
/// without fighting Swift's `"case"` discriminator syntax.
|
||||
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { key }
|
||||
|
||||
let key: String
|
||||
let type: FieldType
|
||||
let label: String
|
||||
let description: String?
|
||||
let required: Bool
|
||||
let placeholder: String?
|
||||
|
||||
// Type-specific constraints — all optional. The validator enforces
|
||||
// only the ones that apply to `type`; extras are ignored.
|
||||
let defaultValue: TemplateConfigValue?
|
||||
let options: [EnumOption]? // type == .enum
|
||||
let minLength: Int? // type == .string / .text
|
||||
let maxLength: Int?
|
||||
let pattern: String? // type == .string (regex)
|
||||
let minNumber: Double? // type == .number
|
||||
let maxNumber: Double?
|
||||
let step: Double?
|
||||
let itemType: String? // type == .list — only "string" supported in v1
|
||||
let minItems: Int?
|
||||
let maxItems: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case key, type, label, description, required, placeholder
|
||||
case defaultValue = "default"
|
||||
case options
|
||||
case minLength, maxLength, pattern
|
||||
case minNumber = "min"
|
||||
case maxNumber = "max"
|
||||
case step
|
||||
case itemType, minItems, maxItems
|
||||
}
|
||||
|
||||
enum FieldType: String, Codable, Sendable, Equatable {
|
||||
case string
|
||||
case text
|
||||
case number
|
||||
case bool
|
||||
case `enum`
|
||||
case list
|
||||
case secret
|
||||
}
|
||||
|
||||
/// One option of an `enum` field. `value` is what ends up in
|
||||
/// `config.json`; `label` is the human-readable text shown in the UI.
|
||||
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
|
||||
nonisolated var id: String { value }
|
||||
let value: String
|
||||
let label: String
|
||||
}
|
||||
}
|
||||
|
||||
/// Author's model-of-choice hint, shown in the install preview + on the
|
||||
/// catalog detail page. Purely advisory — Scarf never auto-switches the
|
||||
/// active model. Individual cron jobs can override via
|
||||
/// `HermesCronJob.model` if the author wants enforcement.
|
||||
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
|
||||
let preferred: String
|
||||
let rationale: String?
|
||||
let alternatives: [String]?
|
||||
}
|
||||
|
||||
// MARK: - Values (what lands in config.json and the Keychain)
|
||||
|
||||
/// One configured value. Secrets don't carry their raw bytes — only a
|
||||
/// Keychain reference of the form `"keychain://<service>/<account>"` so
|
||||
/// serialising config.json to disk never leaks the secret into git or
|
||||
/// into backups.
|
||||
enum TemplateConfigValue: Codable, Sendable, Equatable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case list([String])
|
||||
case keychainRef(String)
|
||||
|
||||
/// Convenience: the string representation suitable for display or
|
||||
/// for writing into a placeholder that the agent reads. Keychain
|
||||
/// refs return the ref string, not the resolved secret — callers
|
||||
/// resolve through `ProjectConfigKeychain` explicitly when they
|
||||
/// actually need the plaintext.
|
||||
nonisolated var displayString: String {
|
||||
switch self {
|
||||
case .string(let s): return s
|
||||
case .number(let n):
|
||||
return n.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(Int(n))
|
||||
: String(n)
|
||||
case .bool(let b): return b ? "true" : "false"
|
||||
case .list(let items): return items.joined(separator: ", ")
|
||||
case .keychainRef(let ref): return ref
|
||||
}
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if let s = try? container.decode(String.self) {
|
||||
// Preserve the keychain:// scheme so secrets round-trip as
|
||||
// references, not as plaintext.
|
||||
if s.hasPrefix("keychain://") {
|
||||
self = .keychainRef(s)
|
||||
} else {
|
||||
self = .string(s)
|
||||
}
|
||||
} else if let b = try? container.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? container.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let arr = try? container.decode([String].self) {
|
||||
self = .list(arr)
|
||||
} else {
|
||||
throw DecodingError.typeMismatch(
|
||||
TemplateConfigValue.self,
|
||||
.init(codingPath: decoder.codingPath,
|
||||
debugDescription: "Expected String, Bool, Number, or [String]")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .list(let items): try container.encode(items)
|
||||
case .keychainRef(let ref): try container.encode(ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
|
||||
|
||||
/// The JSON file the installer writes + the editor reads. Non-secret
|
||||
/// values appear inline; secrets are `"keychain://<service>/<account>"`
|
||||
/// references that `ProjectConfigService` resolves through the Keychain
|
||||
/// on demand.
|
||||
struct ProjectConfigFile: Codable, Sendable {
|
||||
let schemaVersion: Int
|
||||
let templateId: String
|
||||
var values: [String: TemplateConfigValue]
|
||||
let updatedAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case schemaVersion
|
||||
case templateId
|
||||
case values
|
||||
case updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Keychain reference helpers
|
||||
|
||||
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
|
||||
/// (service + account) from the template slug + project-path hash so two
|
||||
/// installs of the same template in different dirs don't collide in the
|
||||
/// login Keychain.
|
||||
struct TemplateKeychainRef: Sendable, Equatable {
|
||||
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
|
||||
let service: String
|
||||
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
|
||||
/// guarantees uniqueness across multiple installs of the same template.
|
||||
let account: String
|
||||
|
||||
/// `"keychain://<service>/<account>"` — what lands in `config.json`.
|
||||
nonisolated var uri: String { "keychain://\(service)/\(account)" }
|
||||
|
||||
/// Parse a `keychain://…` URI back into a ref. Returns `nil` when the
|
||||
/// input isn't well-formed so callers can distinguish a missing ref
|
||||
/// from a malformed one.
|
||||
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
|
||||
guard uri.hasPrefix("keychain://") else { return nil }
|
||||
let rest = String(uri.dropFirst("keychain://".count))
|
||||
guard let slash = rest.firstIndex(of: "/") else { return nil }
|
||||
let service = String(rest[..<slash])
|
||||
let account = String(rest[rest.index(after: slash)...])
|
||||
guard !service.isEmpty, !account.isEmpty else { return nil }
|
||||
return TemplateKeychainRef(service: service, account: account)
|
||||
}
|
||||
|
||||
/// Build a ref from a template slug + field key + project path.
|
||||
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
|
||||
/// of the absolute project path. Stable across launches, different
|
||||
/// between `/Users/a/proj1` and `/Users/a/proj2`.
|
||||
nonisolated static func make(
|
||||
templateSlug: String,
|
||||
fieldKey: String,
|
||||
projectPath: String
|
||||
) -> TemplateKeychainRef {
|
||||
TemplateKeychainRef(
|
||||
service: "com.scarf.template.\(templateSlug)",
|
||||
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated static func shortHash(of string: String) -> String {
|
||||
// 8 hex chars is 32 bits of uniqueness — plenty for
|
||||
// distinguishing a handful of project dirs per template install.
|
||||
let data = Data(string.utf8)
|
||||
var hash: UInt32 = 0x811c9dc5
|
||||
for byte in data {
|
||||
hash ^= UInt32(byte)
|
||||
hash &*= 0x01000193
|
||||
}
|
||||
return String(format: "%08x", hash)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// One schema- or value-validation problem. Carries `fieldKey` so the
|
||||
/// UI can surface the error inline with the field rather than at the
|
||||
/// top of the form.
|
||||
struct TemplateConfigValidationError: Error, Sendable, Equatable {
|
||||
let fieldKey: String?
|
||||
let message: String
|
||||
}
|
||||
|
||||
enum TemplateConfigSchemaError: LocalizedError, Sendable {
|
||||
case duplicateKey(String)
|
||||
case unsupportedType(String)
|
||||
case emptyEnumOptions(String)
|
||||
case duplicateEnumValue(key: String, value: String)
|
||||
case unsupportedListItemType(key: String, itemType: String)
|
||||
case secretFieldHasDefault(String)
|
||||
case emptyModelPreferred
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .duplicateKey(let k):
|
||||
return "Config schema has duplicate key: \(k)"
|
||||
case .unsupportedType(let t):
|
||||
return "Config schema uses unsupported field type: \(t)"
|
||||
case .emptyEnumOptions(let k):
|
||||
return "Enum field '\(k)' must declare at least one option"
|
||||
case .duplicateEnumValue(let k, let v):
|
||||
return "Enum field '\(k)' has duplicate option value: \(v)"
|
||||
case .unsupportedListItemType(let k, let t):
|
||||
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
|
||||
case .secretFieldHasDefault(let k):
|
||||
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
|
||||
case .emptyModelPreferred:
|
||||
return "modelRecommendation.preferred must be a non-empty model id."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
struct ProjectDashboardService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||
|
||||
let context: ServerContext
|
||||
let transport: any ServerTransport
|
||||
@@ -19,23 +21,28 @@ struct ProjectDashboardService: Sendable {
|
||||
do {
|
||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||
} catch {
|
||||
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
||||
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||
return ProjectRegistry(projects: [])
|
||||
}
|
||||
}
|
||||
|
||||
func saveRegistry(_ registry: ProjectRegistry) {
|
||||
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
||||
///
|
||||
/// **Throws** on every non-success path — the previous version of
|
||||
/// this method silently swallowed `createDirectory` and `writeFile`
|
||||
/// failures with `try?`, which meant the installer could return a
|
||||
/// valid-looking `ProjectEntry` while the registry on disk never
|
||||
/// received the new row (project would complete install, show a
|
||||
/// success screen, then be invisible in the sidebar). Callers that
|
||||
/// want fire-and-forget behaviour can still use `try?`, but the
|
||||
/// choice is now theirs.
|
||||
func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||
let dir = context.paths.scarfDir
|
||||
if !transport.fileExists(dir) {
|
||||
do {
|
||||
try transport.createDirectory(dir)
|
||||
} catch {
|
||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
||||
// Pretty-print for readability (agents may read this file)
|
||||
let data = try JSONEncoder().encode(registry)
|
||||
// Pretty-print for readability (agents may read this file).
|
||||
let writeData: Data
|
||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||
@@ -43,7 +50,7 @@ struct ProjectDashboardService: Sendable {
|
||||
} else {
|
||||
writeData = data
|
||||
}
|
||||
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Builds a `.scarftemplate` bundle from an existing Scarf project plus the
|
||||
/// caller's selection of skills and cron jobs. Symmetric with the
|
||||
/// `ProjectTemplateService` + `ProjectTemplateInstaller` pair — the output
|
||||
/// of this exporter can be fed straight back to `inspect()` + `install()`.
|
||||
struct ProjectTemplateExporter: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateExporter")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Known filenames in the project root that map to specific agents. When
|
||||
/// the author opts to include them, each is copied verbatim into
|
||||
/// `instructions/` in the bundle.
|
||||
nonisolated static let knownInstructionFiles: [String] = [
|
||||
"CLAUDE.md",
|
||||
"GEMINI.md",
|
||||
".cursorrules",
|
||||
".github/copilot-instructions.md"
|
||||
]
|
||||
|
||||
/// Author-facing description of what `export` will do with the given
|
||||
/// selections. Shown in the export sheet so the user knows exactly
|
||||
/// what's about to go into the bundle before saving.
|
||||
struct ExportPlan: Sendable {
|
||||
let templateId: String
|
||||
let templateName: String
|
||||
let templateVersion: String
|
||||
let projectDir: String
|
||||
let dashboardPresent: Bool
|
||||
let agentsMdPresent: Bool
|
||||
let readmePresent: Bool
|
||||
let instructionFiles: [String]
|
||||
let skillIds: [String]
|
||||
let cronJobs: [HermesCronJob]
|
||||
let memoryAppendix: String?
|
||||
}
|
||||
|
||||
/// Inputs collected by the export sheet.
|
||||
struct ExportInputs: Sendable {
|
||||
let project: ProjectEntry
|
||||
let templateId: String
|
||||
let templateName: String
|
||||
let templateVersion: String
|
||||
let description: String
|
||||
let authorName: String?
|
||||
let authorUrl: String?
|
||||
let category: String?
|
||||
let tags: [String]
|
||||
let includeSkillIds: [String]
|
||||
let includeCronJobIds: [String]
|
||||
/// Raw markdown the author wants appended to installers' MEMORY.md.
|
||||
/// `nil` to skip.
|
||||
let memoryAppendix: String?
|
||||
}
|
||||
|
||||
/// Scan the project dir and report what a fresh export would include
|
||||
/// given the caller's inputs. Does not write anything.
|
||||
///
|
||||
/// Existence checks go through the context's transport — the project
|
||||
/// path comes from the registry on the active server and may be on a
|
||||
/// remote filesystem (future remote-install support), where
|
||||
/// `FileManager.default.fileExists` would silently return `false`.
|
||||
nonisolated func previewPlan(for inputs: ExportInputs) -> ExportPlan {
|
||||
let dir = inputs.project.path
|
||||
let transport = context.makeTransport()
|
||||
let dashboard = transport.fileExists(dir + "/.scarf/dashboard.json")
|
||||
let readme = transport.fileExists(dir + "/README.md")
|
||||
let agents = transport.fileExists(dir + "/AGENTS.md")
|
||||
let instructions = Self.knownInstructionFiles.filter {
|
||||
transport.fileExists(dir + "/" + $0)
|
||||
}
|
||||
let allJobs = HermesFileService(context: context).loadCronJobs()
|
||||
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
|
||||
return ExportPlan(
|
||||
templateId: inputs.templateId,
|
||||
templateName: inputs.templateName,
|
||||
templateVersion: inputs.templateVersion,
|
||||
projectDir: dir,
|
||||
dashboardPresent: dashboard,
|
||||
agentsMdPresent: agents,
|
||||
readmePresent: readme,
|
||||
instructionFiles: instructions,
|
||||
skillIds: inputs.includeSkillIds,
|
||||
cronJobs: picked,
|
||||
memoryAppendix: inputs.memoryAppendix
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the bundle and write it to `outputZipPath`. Throws if any
|
||||
/// required file is missing or the zip step fails.
|
||||
nonisolated func export(
|
||||
inputs: ExportInputs,
|
||||
outputZipPath: String
|
||||
) throws {
|
||||
let stagingDir = NSTemporaryDirectory() + "scarf-template-export-" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: stagingDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(atPath: stagingDir) }
|
||||
|
||||
let plan = previewPlan(for: inputs)
|
||||
|
||||
guard plan.dashboardPresent else {
|
||||
throw ProjectTemplateError.requiredFileMissing("dashboard.json (expected at \(plan.projectDir)/.scarf/dashboard.json)")
|
||||
}
|
||||
guard plan.readmePresent else {
|
||||
throw ProjectTemplateError.requiredFileMissing("README.md (expected at \(plan.projectDir)/README.md)")
|
||||
}
|
||||
guard plan.agentsMdPresent else {
|
||||
throw ProjectTemplateError.requiredFileMissing("AGENTS.md (expected at \(plan.projectDir)/AGENTS.md)")
|
||||
}
|
||||
|
||||
// Required files. All source reads go through the context's
|
||||
// transport — project paths come from the registry on the active
|
||||
// server and may be on a remote filesystem. Destinations are in
|
||||
// the local staging dir so Foundation writes are correct.
|
||||
let transport = context.makeTransport()
|
||||
try copyFromHermes(plan.projectDir + "/.scarf/dashboard.json", to: stagingDir + "/dashboard.json", transport: transport)
|
||||
try copyFromHermes(plan.projectDir + "/README.md", to: stagingDir + "/README.md", transport: transport)
|
||||
try copyFromHermes(plan.projectDir + "/AGENTS.md", to: stagingDir + "/AGENTS.md", transport: transport)
|
||||
|
||||
// Optional per-agent instruction shims
|
||||
for relative in plan.instructionFiles {
|
||||
let source = plan.projectDir + "/" + relative
|
||||
let destination = stagingDir + "/instructions/" + relative
|
||||
try createParent(of: destination)
|
||||
try copyFromHermes(source, to: destination, transport: transport)
|
||||
}
|
||||
|
||||
// Skills (copied from the global skills dir)
|
||||
if !plan.skillIds.isEmpty {
|
||||
let skillsRoot = stagingDir + "/skills"
|
||||
try FileManager.default.createDirectory(atPath: skillsRoot, withIntermediateDirectories: true)
|
||||
let allSkills = HermesFileService(context: context).loadSkills()
|
||||
.flatMap(\.skills)
|
||||
for skillId in plan.skillIds {
|
||||
guard let skill = allSkills.first(where: { $0.id == skillId }) else {
|
||||
throw ProjectTemplateError.requiredFileMissing("skills/" + skillId)
|
||||
}
|
||||
// The bundle uses a flat `skills/<name>/` layout (no
|
||||
// category), matching what the installer expects. If two
|
||||
// categories ship skills with the same `name`, the second
|
||||
// collides — warn by refusing rather than silently
|
||||
// overwriting.
|
||||
let targetDir = skillsRoot + "/" + skill.name
|
||||
if FileManager.default.fileExists(atPath: targetDir) {
|
||||
throw ProjectTemplateError.conflictingFile(targetDir)
|
||||
}
|
||||
try FileManager.default.createDirectory(atPath: targetDir, withIntermediateDirectories: true)
|
||||
for file in skill.files {
|
||||
try copyFromHermes(skill.path + "/" + file, to: targetDir + "/" + file, transport: transport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cron jobs (stripped to the create-CLI-shaped spec)
|
||||
if !plan.cronJobs.isEmpty {
|
||||
let specs = plan.cronJobs.map { Self.strip($0) }
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(specs)
|
||||
let cronDir = stagingDir + "/cron"
|
||||
try FileManager.default.createDirectory(atPath: cronDir, withIntermediateDirectories: true)
|
||||
try data.write(to: URL(fileURLWithPath: cronDir + "/jobs.json"))
|
||||
}
|
||||
|
||||
// Memory appendix. A write failure here would silently produce a
|
||||
// bundle whose manifest claims `memory.append = true` but ships an
|
||||
// empty/missing file — installers would then fail on
|
||||
// contentClaimMismatch with no breadcrumb pointing back at the
|
||||
// export step. Let the error propagate.
|
||||
if let appendix = plan.memoryAppendix, !appendix.isEmpty {
|
||||
let memDir = stagingDir + "/memory"
|
||||
try FileManager.default.createDirectory(atPath: memDir, withIntermediateDirectories: true)
|
||||
guard let data = appendix.data(using: .utf8) else {
|
||||
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
|
||||
}
|
||||
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
|
||||
}
|
||||
|
||||
// If the source project was itself installed from a schemaful
|
||||
// template, its `.scarf/manifest.json` carries the schema we
|
||||
// want to forward to the exported bundle. We carry only the
|
||||
// SCHEMA — never user values. Exporting must be safe on a
|
||||
// project with live config: the schema is author-supplied
|
||||
// metadata; the values in `config.json` are the current user's
|
||||
// secrets or personal settings.
|
||||
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
|
||||
from: plan.projectDir
|
||||
)
|
||||
|
||||
// Bump schemaVersion to 2 when a schema is carried through;
|
||||
// remain on 1 otherwise so schema-less exports stay
|
||||
// byte-compatible with existing v2.2 catalog validators.
|
||||
let schemaVersion = forwardedSchema == nil ? 1 : 2
|
||||
|
||||
// Manifest — claims exactly what we just wrote
|
||||
let manifest = ProjectTemplateManifest(
|
||||
schemaVersion: schemaVersion,
|
||||
id: inputs.templateId,
|
||||
name: inputs.templateName,
|
||||
version: inputs.templateVersion,
|
||||
minScarfVersion: nil,
|
||||
minHermesVersion: nil,
|
||||
author: inputs.authorName.map {
|
||||
TemplateAuthor(name: $0, url: inputs.authorUrl)
|
||||
},
|
||||
description: inputs.description,
|
||||
category: inputs.category,
|
||||
tags: inputs.tags.isEmpty ? nil : inputs.tags,
|
||||
icon: nil,
|
||||
screenshots: nil,
|
||||
contents: TemplateContents(
|
||||
dashboard: true,
|
||||
agentsMd: true,
|
||||
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
|
||||
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
|
||||
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
|
||||
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
|
||||
config: forwardedSchema?.fields.count
|
||||
),
|
||||
config: forwardedSchema
|
||||
)
|
||||
let manifestEncoder = JSONEncoder()
|
||||
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let manifestData = try manifestEncoder.encode(manifest)
|
||||
try manifestData.write(to: URL(fileURLWithPath: stagingDir + "/template.json"))
|
||||
|
||||
try zip(stagingDir: stagingDir, outputPath: outputZipPath)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Copy a file whose source lives on the Hermes side (possibly remote)
|
||||
/// into a local destination path under the staging dir. Using the
|
||||
/// transport for the read keeps the exporter remote-ready; the write
|
||||
/// goes through Foundation because the staging dir is always local to
|
||||
/// the Mac running Scarf.
|
||||
nonisolated private func copyFromHermes(
|
||||
_ source: String,
|
||||
to destination: String,
|
||||
transport: any ServerTransport
|
||||
) throws {
|
||||
let data = try transport.readFile(source)
|
||||
try createParent(of: destination)
|
||||
try data.write(to: URL(fileURLWithPath: destination))
|
||||
}
|
||||
|
||||
nonisolated private func createParent(of path: String) throws {
|
||||
let parent = (path as NSString).deletingLastPathComponent
|
||||
if !FileManager.default.fileExists(atPath: parent) {
|
||||
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
|
||||
/// present) and pull out just the config schema. Values in
|
||||
/// `.scarf/config.json` are intentionally ignored — an exported
|
||||
/// bundle carries the schema's shape, never the current user's
|
||||
/// configured values.
|
||||
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
|
||||
let manifestPath = projectDir + "/.scarf/manifest.json"
|
||||
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||
// Use a bespoke decode rather than ProjectTemplateManifest so
|
||||
// this helper stays resilient if the manifest shape evolves
|
||||
// incompatibly in a future release.
|
||||
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
|
||||
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
|
||||
return onlyConfig.config
|
||||
}
|
||||
|
||||
/// Convert a live cron job (with runtime state) into the spec the
|
||||
/// installer will feed back to `hermes cron create`. Only preserves
|
||||
/// fields the CLI accepts.
|
||||
nonisolated private static func strip(_ job: HermesCronJob) -> TemplateCronJobSpec {
|
||||
let schedule: String = {
|
||||
if let expr = job.schedule.expression, !expr.isEmpty { return expr }
|
||||
if let runAt = job.schedule.runAt, !runAt.isEmpty { return runAt }
|
||||
return job.schedule.display ?? ""
|
||||
}()
|
||||
return TemplateCronJobSpec(
|
||||
name: job.name,
|
||||
schedule: schedule,
|
||||
prompt: job.prompt.isEmpty ? nil : job.prompt,
|
||||
deliver: job.deliver?.isEmpty == false ? job.deliver : nil,
|
||||
skills: (job.skills?.isEmpty == false) ? job.skills : nil,
|
||||
repeatCount: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Shell out to `/usr/bin/zip -r` so the file ordering is deterministic
|
||||
/// and the archive is standard — Apple-provided tools (and the system
|
||||
/// `unzip` the installer uses) will read it without trouble.
|
||||
nonisolated private func zip(stagingDir: String, outputPath: String) throws {
|
||||
// `zip` writes relative paths based on the cwd it's invoked in. Chdir
|
||||
// via Process.currentDirectoryURL so entries are `template.json`,
|
||||
// `AGENTS.md`, etc., not absolute paths.
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
|
||||
process.currentDirectoryURL = URL(fileURLWithPath: stagingDir)
|
||||
process.arguments = ["-qq", "-r", outputPath, "."]
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
// Close both ends of each Pipe so we don't leak 4 fds per zip call.
|
||||
func closePipes() {
|
||||
try? outPipe.fileHandleForReading.close()
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
closePipes()
|
||||
throw ProjectTemplateError.unzipFailed("zip failed to launch: \(error.localizedDescription)")
|
||||
}
|
||||
process.waitUntilExit()
|
||||
let errData = try? errPipe.fileHandleForReading.readToEnd()
|
||||
closePipes()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Executes a `TemplateInstallPlan`. All writes happen in one pass with
|
||||
/// early-fail semantics: if any step throws, later steps don't run (but
|
||||
/// earlier ones aren't reversed — v1 doesn't ship an atomic rollback). The
|
||||
/// plan has already verified `projectDir` doesn't exist and no conflicting
|
||||
/// file exists at target paths, so by the time we start writing, the
|
||||
/// expected-error surface is small (mostly I/O failures).
|
||||
struct ProjectTemplateInstaller: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateInstaller")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// Apply the plan. On success, returns the `ProjectEntry` that was added
|
||||
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
|
||||
@discardableResult
|
||||
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||
try preflight(plan: plan)
|
||||
try createProjectFiles(plan: plan)
|
||||
try createSkillsFiles(plan: plan)
|
||||
try appendMemoryIfNeeded(plan: plan)
|
||||
let cronJobNames = try createCronJobs(plan: plan)
|
||||
let entry = try registerProject(plan: plan)
|
||||
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
|
||||
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Preflight
|
||||
|
||||
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
|
||||
// Plan was built on a recent snapshot of the filesystem; re-check the
|
||||
// invariants at install time so concurrent activity between
|
||||
// preview-and-confirm can't slip past us.
|
||||
//
|
||||
// All existence and read checks for paths that come from
|
||||
// `context.paths` go through the transport — not `FileManager` —
|
||||
// so this code works identically against a future remote
|
||||
// `ServerContext`. See the warning on `ServerContext.readText`:
|
||||
// "Foundation file APIs are LOCAL ONLY — using them with a remote
|
||||
// path silently returns nil because the remote path doesn't exist
|
||||
// on this Mac."
|
||||
let transport = context.makeTransport()
|
||||
if transport.fileExists(plan.projectDir) {
|
||||
throw ProjectTemplateError.projectDirExists(plan.projectDir)
|
||||
}
|
||||
for copy in plan.projectFiles where transport.fileExists(copy.destinationPath) {
|
||||
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
|
||||
}
|
||||
for copy in plan.skillsFiles where transport.fileExists(copy.destinationPath) {
|
||||
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
|
||||
}
|
||||
// Memory appendix collision: re-scan MEMORY.md for an existing block
|
||||
// with the same template id so two installs of v1.0.0 can't
|
||||
// double-append. A missing MEMORY.md is fine (treated as empty),
|
||||
// but any *other* read failure (permissions, bad file type) gets
|
||||
// logged + surfaced so we don't silently pretend MEMORY.md is empty
|
||||
// and append over a broken file.
|
||||
if plan.memoryAppendix != nil {
|
||||
let existing: String
|
||||
if transport.fileExists(plan.memoryPath) {
|
||||
do {
|
||||
let data = try transport.readFile(plan.memoryPath)
|
||||
existing = String(data: data, encoding: .utf8) ?? ""
|
||||
} catch {
|
||||
Self.logger.error("failed to read MEMORY.md at \(plan.memoryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
} else {
|
||||
existing = ""
|
||||
}
|
||||
let marker = ProjectTemplateService.memoryBlockBeginMarker(templateId: plan.manifest.id)
|
||||
if existing.contains(marker) {
|
||||
throw ProjectTemplateError.memoryBlockAlreadyExists(plan.manifest.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Project files
|
||||
|
||||
nonisolated private func createProjectFiles(plan: TemplateInstallPlan) throws {
|
||||
let transport = context.makeTransport()
|
||||
try transport.createDirectory(plan.projectDir)
|
||||
for copy in plan.projectFiles {
|
||||
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
|
||||
// Empty `sourceRelativePath` is the "synthesized content"
|
||||
// sentinel used by `buildPlan` for `.scarf/config.json`.
|
||||
// The installer materialises config.json from
|
||||
// `plan.configValues` here rather than copying a bundle
|
||||
// file that doesn't exist.
|
||||
if copy.sourceRelativePath.isEmpty {
|
||||
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
|
||||
let data = try encodeConfigFile(plan: plan)
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
continue
|
||||
}
|
||||
throw ProjectTemplateError.requiredFileMissing(
|
||||
"synthesized file with unknown destination: \(copy.destinationPath)"
|
||||
)
|
||||
}
|
||||
|
||||
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
|
||||
/// shape. Secrets appear as `keychainRef` URIs — the raw bytes were
|
||||
/// routed into the Keychain by the VM before `install()` was called.
|
||||
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
|
||||
let file = ProjectConfigFile(
|
||||
schemaVersion: 2,
|
||||
templateId: plan.manifest.id,
|
||||
values: plan.configValues,
|
||||
updatedAt: ISO8601DateFormatter().string(from: Date())
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
return try encoder.encode(file)
|
||||
}
|
||||
|
||||
// MARK: - Skills
|
||||
|
||||
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
|
||||
guard let namespaceDir = plan.skillsNamespaceDir else { return }
|
||||
let transport = context.makeTransport()
|
||||
try transport.createDirectory(namespaceDir)
|
||||
for copy in plan.skillsFiles {
|
||||
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
|
||||
let data = try Data(contentsOf: URL(fileURLWithPath: source))
|
||||
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(copy.destinationPath, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory
|
||||
|
||||
nonisolated private func appendMemoryIfNeeded(plan: TemplateInstallPlan) throws {
|
||||
guard let appendix = plan.memoryAppendix else { return }
|
||||
let transport = context.makeTransport()
|
||||
let existing = (try? transport.readFile(plan.memoryPath)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
let combined = existing + appendix
|
||||
guard let data = combined.data(using: .utf8) else {
|
||||
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
|
||||
}
|
||||
let parent = (plan.memoryPath as NSString).deletingLastPathComponent
|
||||
try transport.createDirectory(parent)
|
||||
try transport.writeFile(plan.memoryPath, data: data)
|
||||
}
|
||||
|
||||
// MARK: - Cron
|
||||
|
||||
/// Create each cron job via `hermes cron create`, then immediately pause
|
||||
/// it (Hermes creates jobs enabled). Returns the list of resolved job
|
||||
/// names, which is what the lock file records — we don't know the job
|
||||
/// ids without parsing the create output, but the name is enough to
|
||||
/// find + remove them later.
|
||||
nonisolated private func createCronJobs(plan: TemplateInstallPlan) throws -> [String] {
|
||||
guard !plan.cronJobs.isEmpty else { return [] }
|
||||
|
||||
let existingBefore = Set(HermesFileService(context: context).loadCronJobs().map(\.id))
|
||||
var createdNames: [String] = []
|
||||
|
||||
for job in plan.cronJobs {
|
||||
var args = ["cron", "create", "--name", job.name]
|
||||
if let deliver = job.deliver, !deliver.isEmpty { args += ["--deliver", deliver] }
|
||||
if let repeatCount = job.repeatCount { args += ["--repeat", String(repeatCount)] }
|
||||
for skill in job.skills ?? [] where !skill.isEmpty {
|
||||
args += ["--skill", skill]
|
||||
}
|
||||
args.append(job.schedule)
|
||||
if let prompt = job.prompt, !prompt.isEmpty {
|
||||
// Substitute template-author tokens with install-time
|
||||
// values. Hermes doesn't set a CWD for cron runs — when
|
||||
// the agent fires the prompt, any relative path
|
||||
// (`.scarf/config.json`, `status-log.md`, etc.) resolves
|
||||
// against the agent's own dir, not the project. Templates
|
||||
// use `{{PROJECT_DIR}}` as a placeholder for the absolute
|
||||
// path; we swap in the real project dir here so the
|
||||
// registered cron job carries a fully-qualified prompt
|
||||
// that works regardless of CWD.
|
||||
let resolvedPrompt = Self.substituteCronTokens(prompt, plan: plan)
|
||||
args.append(resolvedPrompt)
|
||||
}
|
||||
|
||||
let (output, exit) = context.runHermes(args)
|
||||
guard exit == 0 else {
|
||||
throw ProjectTemplateError.cronCreateFailed(job: job.name, output: output)
|
||||
}
|
||||
createdNames.append(job.name)
|
||||
}
|
||||
|
||||
// Diff the current job set against the snapshot we took before
|
||||
// creating — anything new belongs to this install and gets paused.
|
||||
// We pause by id (not name) because `cron pause` takes an id.
|
||||
let currentJobs = HermesFileService(context: context).loadCronJobs()
|
||||
let newJobs = currentJobs.filter { !existingBefore.contains($0.id) && createdNames.contains($0.name) }
|
||||
for job in newJobs {
|
||||
let (_, exit) = context.runHermes(["cron", "pause", job.id])
|
||||
if exit != 0 {
|
||||
Self.logger.warning("couldn't pause newly-created cron job \(job.id, privacy: .public) — leaving enabled")
|
||||
}
|
||||
}
|
||||
|
||||
return createdNames
|
||||
}
|
||||
|
||||
// MARK: - Registry
|
||||
|
||||
nonisolated private func registerProject(plan: TemplateInstallPlan) throws -> ProjectEntry {
|
||||
let service = ProjectDashboardService(context: context)
|
||||
var registry = service.loadRegistry()
|
||||
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
||||
registry.projects.append(entry)
|
||||
// Must throw on failure — silent failure here used to make the
|
||||
// installer return a valid entry while the registry on disk
|
||||
// never got updated, producing the "install completed but the
|
||||
// project doesn't show up in the sidebar" bug. If the registry
|
||||
// write fails, the whole install is surfaced as failed so the
|
||||
// user can see + address the underlying problem.
|
||||
try service.saveRegistry(registry)
|
||||
return entry
|
||||
}
|
||||
|
||||
// MARK: - Token substitution (install-time placeholder resolution)
|
||||
|
||||
/// Supported placeholders for template-author prompts. Keep the set
|
||||
/// intentionally small — every token here becomes a load-bearing
|
||||
/// part of the template format that we can't rename without
|
||||
/// breaking existing bundles.
|
||||
///
|
||||
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
|
||||
/// directory. Required for cron prompts because Hermes doesn't
|
||||
/// establish a CWD when firing cron jobs; relative paths would
|
||||
/// resolve against whatever dir Hermes happens to be in.
|
||||
///
|
||||
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
|
||||
/// Less load-bearing; occasionally useful for tagging or
|
||||
/// delivery targets that reference the template.
|
||||
///
|
||||
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
|
||||
/// for the skills namespace and project dir name.
|
||||
nonisolated static func substituteCronTokens(
|
||||
_ prompt: String,
|
||||
plan: TemplateInstallPlan
|
||||
) -> String {
|
||||
var out = prompt
|
||||
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
|
||||
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
|
||||
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Lock file
|
||||
|
||||
nonisolated private func writeLockFile(
|
||||
plan: TemplateInstallPlan,
|
||||
cronJobNames: [String]
|
||||
) throws {
|
||||
// Every value that ended up as a keychainRef in config.json gets
|
||||
// tracked in the lock so the uninstaller can SecItemDelete each
|
||||
// entry. Field keys are recorded separately for informational
|
||||
// display in the uninstall preview sheet.
|
||||
let keychainItems: [String]? = {
|
||||
let refs = plan.configValues.compactMap { (_, value) -> String? in
|
||||
if case .keychainRef(let uri) = value { return uri } else { return nil }
|
||||
}
|
||||
return refs.isEmpty ? nil : refs.sorted()
|
||||
}()
|
||||
let configFields: [String]? = {
|
||||
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
|
||||
return schema.fields.map(\.key)
|
||||
}()
|
||||
|
||||
let lock = TemplateLock(
|
||||
templateId: plan.manifest.id,
|
||||
templateVersion: plan.manifest.version,
|
||||
templateName: plan.manifest.name,
|
||||
installedAt: ISO8601DateFormatter().string(from: Date()),
|
||||
projectFiles: plan.projectFiles.map(\.destinationPath),
|
||||
skillsNamespaceDir: plan.skillsNamespaceDir,
|
||||
skillsFiles: plan.skillsFiles.map(\.destinationPath),
|
||||
cronJobNames: cronJobNames,
|
||||
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
|
||||
configKeychainItems: keychainItems,
|
||||
configFields: configFields
|
||||
)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(lock)
|
||||
let path = plan.projectDir + "/.scarf/template.lock.json"
|
||||
try context.makeTransport().writeFile(path, data: data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Reads, validates, and plans the install of a `.scarftemplate` bundle. Pure
|
||||
/// — owns no state across calls. The installer (see
|
||||
/// `ProjectTemplateInstaller`) consumes the `TemplateInstallPlan` this
|
||||
/// produces.
|
||||
///
|
||||
/// Responsibilities:
|
||||
/// 1. Unpack a `.scarftemplate` zip into a caller-owned temp directory.
|
||||
/// 2. Parse `template.json` and validate it against the schema we know about.
|
||||
/// 3. Walk the unpacked contents and verify they match the manifest's
|
||||
/// `contents` claim (so a malicious bundle can't hide files from the
|
||||
/// preview sheet).
|
||||
/// 4. Produce a `TemplateInstallPlan` describing every concrete filesystem
|
||||
/// op the installer will perform, given a parent directory the user
|
||||
/// picked.
|
||||
struct ProjectTemplateService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateService")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Inspection
|
||||
|
||||
/// Unpack the zip at `zipPath` into a fresh temp directory, parse and
|
||||
/// validate the manifest, and walk the contents. Throws on any
|
||||
/// inconsistency. On success, the caller owns `inspection.unpackedDir`
|
||||
/// and must remove it once they're done.
|
||||
nonisolated func inspect(zipPath: String) throws -> TemplateInspection {
|
||||
let unpackedDir = try makeTempDir()
|
||||
try unzip(zipPath: zipPath, intoDir: unpackedDir)
|
||||
|
||||
let manifestPath = unpackedDir + "/template.json"
|
||||
guard FileManager.default.fileExists(atPath: manifestPath) else {
|
||||
throw ProjectTemplateError.manifestMissing
|
||||
}
|
||||
|
||||
let manifestData: Data
|
||||
do {
|
||||
manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
|
||||
} catch {
|
||||
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||
}
|
||||
let manifest: ProjectTemplateManifest
|
||||
do {
|
||||
manifest = try JSONDecoder().decode(ProjectTemplateManifest.self, from: manifestData)
|
||||
} catch {
|
||||
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
|
||||
// optional `config` block. Both are valid. Newer versions get
|
||||
// refused so the installer never silently misinterprets a
|
||||
// future-shape bundle.
|
||||
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
|
||||
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
|
||||
}
|
||||
|
||||
// Validate the optional config schema at inspect time — a
|
||||
// malformed schema (duplicate keys, secret-with-default, etc.)
|
||||
// gets rejected before the user ever sees the preview sheet.
|
||||
if let schema = manifest.config {
|
||||
do {
|
||||
try ProjectConfigService.validateSchema(schema)
|
||||
} catch {
|
||||
throw ProjectTemplateError.manifestParseFailed(
|
||||
"invalid config schema: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let files = try Self.walk(unpackedDir)
|
||||
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
|
||||
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
|
||||
|
||||
return TemplateInspection(
|
||||
manifest: manifest,
|
||||
unpackedDir: unpackedDir,
|
||||
files: files,
|
||||
cronJobs: cronJobs
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Planning
|
||||
|
||||
/// Turn an inspection into a concrete install plan given the parent
|
||||
/// directory the user picked. The plan is deterministic — two calls with
|
||||
/// the same inputs produce the same ops.
|
||||
nonisolated func buildPlan(
|
||||
inspection: TemplateInspection,
|
||||
parentDir: String
|
||||
) throws -> TemplateInstallPlan {
|
||||
let manifest = inspection.manifest
|
||||
let slug = manifest.slug
|
||||
let projectDir = parentDir + "/" + slug
|
||||
|
||||
if FileManager.default.fileExists(atPath: projectDir) {
|
||||
throw ProjectTemplateError.projectDirExists(projectDir)
|
||||
}
|
||||
|
||||
var projectFiles: [TemplateFileCopy] = [
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "README.md",
|
||||
destinationPath: projectDir + "/README.md"
|
||||
),
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "AGENTS.md",
|
||||
destinationPath: projectDir + "/AGENTS.md"
|
||||
),
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "dashboard.json",
|
||||
destinationPath: projectDir + "/.scarf/dashboard.json"
|
||||
)
|
||||
]
|
||||
|
||||
// Optional per-agent instruction shims. Each is copied verbatim to
|
||||
// its conventional project-root path; we don't try to be clever.
|
||||
let instructionRoot = "instructions"
|
||||
for relative in (manifest.contents.instructions ?? []) {
|
||||
let source = instructionRoot + "/" + relative
|
||||
guard inspection.files.contains(source) else {
|
||||
throw ProjectTemplateError.requiredFileMissing(source)
|
||||
}
|
||||
projectFiles.append(
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: source,
|
||||
destinationPath: projectDir + "/" + relative
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Namespaced skills: copied wholesale from skills/<name>/** into
|
||||
// ~/.hermes/skills/templates/<slug>/<name>/**.
|
||||
var skillsFiles: [TemplateFileCopy] = []
|
||||
var skillsNamespaceDir: String? = nil
|
||||
if let skillNames = manifest.contents.skills, !skillNames.isEmpty {
|
||||
let namespaceDir = context.paths.skillsDir + "/templates/" + slug
|
||||
skillsNamespaceDir = namespaceDir
|
||||
for skillName in skillNames {
|
||||
let prefix = "skills/" + skillName + "/"
|
||||
let skillFiles = inspection.files.filter { $0.hasPrefix(prefix) }
|
||||
guard !skillFiles.isEmpty else {
|
||||
throw ProjectTemplateError.requiredFileMissing(prefix)
|
||||
}
|
||||
for relative in skillFiles {
|
||||
let suffix = String(relative.dropFirst("skills/".count))
|
||||
skillsFiles.append(
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: relative,
|
||||
destinationPath: namespaceDir + "/" + suffix
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cron jobs: always prefix name with the template tag so users can
|
||||
// find and remove them later. Jobs ship disabled — the installer
|
||||
// pauses each one immediately after `cron create`.
|
||||
let cronJobs: [TemplateCronJobSpec] = inspection.cronJobs.map { job in
|
||||
TemplateCronJobSpec(
|
||||
name: "[tmpl:\(manifest.id)] \(job.name)",
|
||||
schedule: job.schedule,
|
||||
prompt: job.prompt,
|
||||
deliver: job.deliver,
|
||||
skills: job.skills,
|
||||
repeatCount: job.repeatCount
|
||||
)
|
||||
}
|
||||
|
||||
// Memory appendix: wrap whatever the template ships in
|
||||
// begin/end markers so an uninstall can find and remove exactly the
|
||||
// bytes this template added. `verifyClaims` already guaranteed the
|
||||
// file is present — so a read error here means something unusual
|
||||
// (permissions, encoding, etc.); surface it with the real
|
||||
// `error.localizedDescription` rather than hiding behind a
|
||||
// generic "file missing."
|
||||
var memoryAppendix: String? = nil
|
||||
if manifest.contents.memory?.append == true {
|
||||
let appendSource = inspection.unpackedDir + "/memory/append.md"
|
||||
let raw: String
|
||||
do {
|
||||
raw = try String(contentsOf: URL(fileURLWithPath: appendSource), encoding: .utf8)
|
||||
} catch {
|
||||
Self.logger.error("failed to read memory/append.md in unpacked bundle: \(error.localizedDescription, privacy: .public)")
|
||||
throw ProjectTemplateError.manifestParseFailed("memory/append.md: \(error.localizedDescription)")
|
||||
}
|
||||
memoryAppendix = Self.wrapMemoryBlock(
|
||||
templateId: manifest.id,
|
||||
templateVersion: manifest.version,
|
||||
body: raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
}
|
||||
|
||||
// Configuration schema + manifest cache. The installer writes
|
||||
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
|
||||
// (schema cache used by the post-install editor) when the
|
||||
// template declares a non-empty schema. Both paths go into
|
||||
// projectFiles so the uninstaller picks them up via the lock.
|
||||
var configSchema: TemplateConfigSchema? = nil
|
||||
var manifestCachePath: String? = nil
|
||||
if let schema = manifest.config, !schema.isEmpty {
|
||||
configSchema = schema
|
||||
let configPath = projectDir + "/.scarf/config.json"
|
||||
projectFiles.append(
|
||||
// Source is synthesized by the installer from configValues;
|
||||
// no file in the unpacked bundle maps to this entry. We use
|
||||
// an empty `sourceRelativePath` as the "no physical source"
|
||||
// sentinel — the installer special-cases it below (see
|
||||
// ProjectTemplateInstaller.createProjectFiles).
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "",
|
||||
destinationPath: configPath
|
||||
)
|
||||
)
|
||||
let cachePath = projectDir + "/.scarf/manifest.json"
|
||||
manifestCachePath = cachePath
|
||||
projectFiles.append(
|
||||
TemplateFileCopy(
|
||||
sourceRelativePath: "template.json",
|
||||
destinationPath: cachePath
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return TemplateInstallPlan(
|
||||
manifest: manifest,
|
||||
unpackedDir: inspection.unpackedDir,
|
||||
projectDir: projectDir,
|
||||
projectFiles: projectFiles,
|
||||
skillsNamespaceDir: skillsNamespaceDir,
|
||||
skillsFiles: skillsFiles,
|
||||
cronJobs: cronJobs,
|
||||
memoryAppendix: memoryAppendix,
|
||||
memoryPath: context.paths.memoryMD,
|
||||
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
|
||||
configSchema: configSchema,
|
||||
configValues: [:], // filled in by TemplateInstallerViewModel before install()
|
||||
manifestCachePath: manifestCachePath
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
/// Remove a temp dir created by `inspect`. Safe to call if it already
|
||||
/// doesn't exist (install or cancel flows both end here).
|
||||
nonisolated func cleanupTempDir(_ path: String) {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
|
||||
// MARK: - Memory block helpers (installer + future uninstaller share these)
|
||||
|
||||
nonisolated static func memoryBlockBeginMarker(templateId: String) -> String {
|
||||
"<!-- scarf-template:\(templateId):begin -->"
|
||||
}
|
||||
|
||||
nonisolated static func memoryBlockEndMarker(templateId: String) -> String {
|
||||
"<!-- scarf-template:\(templateId):end -->"
|
||||
}
|
||||
|
||||
nonisolated static func wrapMemoryBlock(
|
||||
templateId: String,
|
||||
templateVersion: String,
|
||||
body: String
|
||||
) -> String {
|
||||
let begin = memoryBlockBeginMarker(templateId: templateId)
|
||||
let end = memoryBlockEndMarker(templateId: templateId)
|
||||
return "\n\n\(begin) v\(templateVersion)\n\(body)\n\(end)\n"
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private nonisolated func makeTempDir() throws -> String {
|
||||
let base = NSTemporaryDirectory() + "scarf-template-" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: base,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
return base
|
||||
}
|
||||
|
||||
/// Shell out to `/usr/bin/unzip` — matches the existing profile-export
|
||||
/// pattern (`hermes profile import` shells to `unzip`) and avoids
|
||||
/// pulling in a third-party zip library.
|
||||
private nonisolated func unzip(zipPath: String, intoDir: String) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
|
||||
process.arguments = ["-qq", "-o", zipPath, "-d", intoDir]
|
||||
|
||||
let outPipe = Pipe()
|
||||
let errPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
process.standardError = errPipe
|
||||
|
||||
// Foundation dup()s these handles into the child on `run()`, but the
|
||||
// parent copies stay open until explicitly released. Both ends must
|
||||
// be closed or each Process spawn leaks 4 fds.
|
||||
func closePipes() {
|
||||
try? outPipe.fileHandleForReading.close()
|
||||
try? outPipe.fileHandleForWriting.close()
|
||||
try? errPipe.fileHandleForReading.close()
|
||||
try? errPipe.fileHandleForWriting.close()
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
closePipes()
|
||||
throw ProjectTemplateError.unzipFailed(error.localizedDescription)
|
||||
}
|
||||
process.waitUntilExit()
|
||||
let errData = try? errPipe.fileHandleForReading.readToEnd()
|
||||
closePipes()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
|
||||
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively walk `dir` and return every file (not directory) as a
|
||||
/// path relative to `dir`. Skips symlinks entirely — templates should
|
||||
/// never contain them, and following them could escape the unpack dir.
|
||||
///
|
||||
/// Both the base dir and the enumerated URLs are resolved via
|
||||
/// `resolvingSymlinksInPath` before comparison. On macOS, temp dirs
|
||||
/// under `/var/folders/…` resolve to `/private/var/folders/…`, so a
|
||||
/// naive string-prefix check would produce malformed relative paths
|
||||
/// when the base is unresolved but enumerated URLs are resolved.
|
||||
nonisolated private static func walk(_ dir: String) throws -> [String] {
|
||||
var results: [String] = []
|
||||
let baseURL = URL(fileURLWithPath: dir).resolvingSymlinksInPath()
|
||||
let basePath = baseURL.path.hasSuffix("/") ? baseURL.path : baseURL.path + "/"
|
||||
let enumerator = FileManager.default.enumerator(
|
||||
at: baseURL,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
while let url = enumerator?.nextObject() as? URL {
|
||||
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
|
||||
if values.isSymbolicLink == true {
|
||||
throw ProjectTemplateError.unsafeZipEntry(url.path)
|
||||
}
|
||||
guard values.isRegularFile == true else { continue }
|
||||
var full = url.resolvingSymlinksInPath().path
|
||||
if full.hasPrefix(basePath) {
|
||||
full.removeFirst(basePath.count)
|
||||
}
|
||||
if full.contains("..") {
|
||||
throw ProjectTemplateError.unsafeZipEntry(full)
|
||||
}
|
||||
results.append(full)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
nonisolated private static func readCronJobs(unpackedDir: String) throws -> [TemplateCronJobSpec] {
|
||||
let path = unpackedDir + "/cron/jobs.json"
|
||||
guard FileManager.default.fileExists(atPath: path) else { return [] }
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: URL(fileURLWithPath: path))
|
||||
} catch {
|
||||
throw ProjectTemplateError.requiredFileMissing("cron/jobs.json")
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode([TemplateCronJobSpec].self, from: data)
|
||||
} catch {
|
||||
throw ProjectTemplateError.manifestParseFailed("cron/jobs.json: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify the manifest's `contents` claim exactly matches the unpacked
|
||||
/// files. Any mismatch — claimed-but-missing or present-but-unclaimed —
|
||||
/// throws, so the preview sheet the user sees is always accurate.
|
||||
nonisolated private static func verifyClaims(
|
||||
manifest: ProjectTemplateManifest,
|
||||
files: [String],
|
||||
cronJobCount: Int
|
||||
) throws {
|
||||
let fileSet = Set(files)
|
||||
|
||||
if manifest.contents.dashboard {
|
||||
if !fileSet.contains("dashboard.json") {
|
||||
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
|
||||
}
|
||||
}
|
||||
if manifest.contents.agentsMd {
|
||||
if !fileSet.contains("AGENTS.md") {
|
||||
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
|
||||
}
|
||||
}
|
||||
// README and AGENTS are always required; dashboard is always required
|
||||
// per spec. `contents.dashboard`/`contents.agentsMd` exist so a future
|
||||
// schema can relax those rules; for v1 we hard-require them regardless.
|
||||
if !fileSet.contains("README.md") {
|
||||
throw ProjectTemplateError.requiredFileMissing("README.md")
|
||||
}
|
||||
if !fileSet.contains("AGENTS.md") {
|
||||
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
|
||||
}
|
||||
if !fileSet.contains("dashboard.json") {
|
||||
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
|
||||
}
|
||||
|
||||
if let claimed = manifest.contents.instructions {
|
||||
for rel in claimed {
|
||||
let full = "instructions/" + rel
|
||||
if !fileSet.contains(full) {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest lists \(full) but the file is missing from the bundle"
|
||||
)
|
||||
}
|
||||
}
|
||||
let present = fileSet.filter { $0.hasPrefix("instructions/") }
|
||||
let claimedFull = Set(claimed.map { "instructions/" + $0 })
|
||||
if let extra = present.first(where: { !claimedFull.contains($0) }) {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"bundle contains \(extra) but it's not listed in manifest.contents.instructions"
|
||||
)
|
||||
}
|
||||
} else if fileSet.contains(where: { $0.hasPrefix("instructions/") }) {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"bundle has instructions/ files but manifest.contents.instructions is missing"
|
||||
)
|
||||
}
|
||||
|
||||
if let claimed = manifest.contents.skills {
|
||||
for name in claimed {
|
||||
let prefix = "skills/" + name + "/"
|
||||
if !fileSet.contains(where: { $0.hasPrefix(prefix) }) {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest lists skill \(name) but skills/\(name)/ has no files"
|
||||
)
|
||||
}
|
||||
}
|
||||
let presentSkills = Set(fileSet.compactMap { path -> String? in
|
||||
guard path.hasPrefix("skills/") else { return nil }
|
||||
let rest = path.dropFirst("skills/".count)
|
||||
return rest.split(separator: "/", maxSplits: 1).first.map(String.init)
|
||||
})
|
||||
let claimedSet = Set(claimed)
|
||||
if let extra = presentSkills.subtracting(claimedSet).first {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"bundle contains skills/\(extra)/ but it's not listed in manifest.contents.skills"
|
||||
)
|
||||
}
|
||||
} else if fileSet.contains(where: { $0.hasPrefix("skills/") }) {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"bundle contains skills/ but manifest.contents.skills is missing"
|
||||
)
|
||||
}
|
||||
|
||||
let claimedCron = manifest.contents.cron ?? 0
|
||||
if claimedCron != cronJobCount {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest.contents.cron=\(claimedCron) but bundle contains \(cronJobCount) cron jobs"
|
||||
)
|
||||
}
|
||||
|
||||
let hasMemoryFile = fileSet.contains("memory/append.md")
|
||||
let claimsMemory = manifest.contents.memory?.append == true
|
||||
if claimsMemory != hasMemoryFile {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
|
||||
)
|
||||
}
|
||||
|
||||
// Config claim must match the schema's actual field count so
|
||||
// the preview sheet is honest about the size of the configure
|
||||
// step. `nil` in contents means "no schema" just like `0`;
|
||||
// we normalise both to 0 before comparing.
|
||||
let claimedConfig = manifest.contents.config ?? 0
|
||||
let actualConfig = manifest.config?.fields.count ?? 0
|
||||
if claimedConfig != actualConfig {
|
||||
throw ProjectTemplateError.contentClaimMismatch(
|
||||
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a project-registry name that doesn't collide. Deterministic
|
||||
/// — given the same existing registry, always returns the same answer.
|
||||
nonisolated private static func uniqueProjectName(
|
||||
preferred: String,
|
||||
context: ServerContext
|
||||
) -> String {
|
||||
let existing = Set(ProjectDashboardService(context: context).loadRegistry().projects.map(\.name))
|
||||
if !existing.contains(preferred) { return preferred }
|
||||
var i = 2
|
||||
while existing.contains("\(preferred) \(i)") {
|
||||
i += 1
|
||||
}
|
||||
return "\(preferred) \(i)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Reverses the work of `ProjectTemplateInstaller`, driven by the
|
||||
/// `<project>/.scarf/template.lock.json` the installer dropped. Symmetric
|
||||
/// with the installer: `loadUninstallPlan(for:)` builds a plan the preview
|
||||
/// sheet can display honestly; `uninstall(plan:)` executes it. No hidden
|
||||
/// side effects — every path the uninstaller touches is in the plan.
|
||||
///
|
||||
/// **User-added files are preserved.** The lock records exactly what the
|
||||
/// installer wrote; any file the user created in the project dir after
|
||||
/// install (e.g. a `sites.txt` or `status-log.md` authored by the agent
|
||||
/// on first run) is listed as an "extra entry" in the plan and left on
|
||||
/// disk. If the project dir ends up empty after removing lock-tracked
|
||||
/// files, the dir itself is removed; otherwise the dir (with user content)
|
||||
/// stays.
|
||||
struct ProjectTemplateUninstaller: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateUninstaller")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Detection
|
||||
|
||||
/// Is the given project installed from a template that we can
|
||||
/// uninstall cleanly? Cheap — just a file-existence check on the lock
|
||||
/// path.
|
||||
nonisolated func isTemplateInstalled(project: ProjectEntry) -> Bool {
|
||||
context.makeTransport().fileExists(lockPath(for: project))
|
||||
}
|
||||
|
||||
// MARK: - Planning
|
||||
|
||||
/// Read the lock file, walk the filesystem + cron list, and produce a
|
||||
/// plan listing every op the uninstaller will perform. Does not
|
||||
/// modify anything.
|
||||
nonisolated func loadUninstallPlan(for project: ProjectEntry) throws -> TemplateUninstallPlan {
|
||||
let transport = context.makeTransport()
|
||||
let path = lockPath(for: project)
|
||||
guard transport.fileExists(path) else {
|
||||
throw ProjectTemplateError.lockFileMissing(path)
|
||||
}
|
||||
let lockData: Data
|
||||
do {
|
||||
lockData = try transport.readFile(path)
|
||||
} catch {
|
||||
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
|
||||
}
|
||||
let lock: TemplateLock
|
||||
do {
|
||||
lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
|
||||
} catch {
|
||||
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
// Partition tracked project files into present vs. already-gone.
|
||||
// The lock file itself is always in `projectFiles` — the installer
|
||||
// doesn't explicitly record it, but the preview sheet and the
|
||||
// execute step must remove it.
|
||||
var lockTrackedFiles = lock.projectFiles
|
||||
lockTrackedFiles.append(path)
|
||||
var toRemove: [String] = []
|
||||
var alreadyGone: [String] = []
|
||||
for file in lockTrackedFiles {
|
||||
if transport.fileExists(file) {
|
||||
toRemove.append(file)
|
||||
} else {
|
||||
alreadyGone.append(file)
|
||||
}
|
||||
}
|
||||
|
||||
// Scan the project dir for entries that AREN'T in the lock — these
|
||||
// are user-added and we preserve them. An empty project dir (after
|
||||
// removing lock-tracked files) gets removed too.
|
||||
let trackedSet = Set(lockTrackedFiles)
|
||||
let extras = try enumerateProjectDirExtras(
|
||||
projectDir: project.path,
|
||||
trackedPaths: trackedSet,
|
||||
transport: transport
|
||||
)
|
||||
let projectDirBecomesEmpty = extras.isEmpty
|
||||
|
||||
// Resolve cron job ids by matching lock names against the live
|
||||
// list. Names that no longer exist go into the already-gone bucket
|
||||
// — the user likely removed them by hand.
|
||||
let currentJobs = HermesFileService(context: context).loadCronJobs()
|
||||
var cronToRemove: [(id: String, name: String)] = []
|
||||
var cronGone: [String] = []
|
||||
for name in lock.cronJobNames {
|
||||
if let match = currentJobs.first(where: { $0.name == name }) {
|
||||
cronToRemove.append((id: match.id, name: match.name))
|
||||
} else {
|
||||
cronGone.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Memory block detection. The installer wraps its appendix between
|
||||
// `<!-- scarf-template:<id>:begin -->` / `:end -->` markers; look
|
||||
// for the begin marker in the current MEMORY.md. If it's missing
|
||||
// (never installed, or removed by hand) we simply skip the memory
|
||||
// strip step.
|
||||
let memoryPath = context.paths.memoryMD
|
||||
var memoryBlockPresent = false
|
||||
if lock.memoryBlockId != nil {
|
||||
if transport.fileExists(memoryPath),
|
||||
let data = try? transport.readFile(memoryPath),
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(
|
||||
templateId: lock.memoryBlockId!
|
||||
)
|
||||
memoryBlockPresent = text.contains(beginMarker)
|
||||
}
|
||||
}
|
||||
|
||||
return TemplateUninstallPlan(
|
||||
lock: lock,
|
||||
project: project,
|
||||
projectFilesToRemove: toRemove,
|
||||
projectFilesAlreadyGone: alreadyGone,
|
||||
extraProjectEntries: extras,
|
||||
projectDirBecomesEmpty: projectDirBecomesEmpty,
|
||||
skillsNamespaceDir: lock.skillsNamespaceDir,
|
||||
cronJobsToRemove: cronToRemove,
|
||||
cronJobsAlreadyGone: cronGone,
|
||||
memoryBlockPresent: memoryBlockPresent,
|
||||
memoryPath: memoryPath
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Execution
|
||||
|
||||
/// Execute the plan. Non-atomic: steps run in order, and if any step
|
||||
/// throws, later steps don't run. v1 doesn't ship rollback — the lock
|
||||
/// file itself is only removed at the very end, so a mid-flight
|
||||
/// failure leaves enough breadcrumbs for the user to retry or finish
|
||||
/// by hand.
|
||||
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
|
||||
let transport = context.makeTransport()
|
||||
|
||||
// 1. Project files (tracked only — user additions untouched).
|
||||
for file in plan.projectFilesToRemove {
|
||||
do {
|
||||
try transport.removeFile(file)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't remove project file \(file, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
// keep going — partial cleanup is better than bailing and
|
||||
// leaving orphan skills/cron state
|
||||
}
|
||||
}
|
||||
if plan.projectDirBecomesEmpty, transport.fileExists(plan.project.path) {
|
||||
do {
|
||||
try transport.removeFile(plan.project.path)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't remove empty project dir \(plan.project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Skills namespace dir (always removed wholesale — it's
|
||||
// isolated, never mixed with user skills).
|
||||
if let skillsDir = plan.skillsNamespaceDir, transport.fileExists(skillsDir) {
|
||||
try removeRecursively(skillsDir, transport: transport)
|
||||
}
|
||||
|
||||
// 3. Cron jobs via CLI — `hermes cron remove <id>`. A non-zero
|
||||
// exit gets logged but doesn't abort the uninstall; leaving a
|
||||
// stray cron job is better than leaving it AND the skills/memory
|
||||
// state that was supposed to pair with it.
|
||||
for job in plan.cronJobsToRemove {
|
||||
let (output, exit) = context.runHermes(["cron", "remove", job.id])
|
||||
if exit != 0 {
|
||||
Self.logger.warning("failed to remove cron job \(job.id, privacy: .public) \(job.name, privacy: .public): \(output, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Memory block — strip the bracketed block in place. Safe
|
||||
// when the block is absent; we already decided presence in the
|
||||
// plan and only come here when `memoryBlockPresent` was true
|
||||
// AND the plan recorded a memoryBlockId.
|
||||
if plan.memoryBlockPresent, let blockId = plan.lock.memoryBlockId {
|
||||
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
|
||||
}
|
||||
|
||||
// 4a. Config Keychain items — remove every secret the template's
|
||||
// install step stashed in the login Keychain. Items that were
|
||||
// already deleted (e.g. user cleaned them with Keychain Access)
|
||||
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
|
||||
// a stale lock doesn't abort the rest of the uninstall.
|
||||
let keychain = ProjectConfigKeychain()
|
||||
for uri in plan.lock.configKeychainItems ?? [] {
|
||||
guard let ref = TemplateKeychainRef.parse(uri) else {
|
||||
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try keychain.delete(ref: ref)
|
||||
} catch {
|
||||
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Projects registry — remove the entry by path (more stable
|
||||
// than name: user may have renamed the project in the UI).
|
||||
let dashboardService = ProjectDashboardService(context: context)
|
||||
var registry = dashboardService.loadRegistry()
|
||||
registry.projects.removeAll { $0.path == plan.project.path }
|
||||
// saveRegistry throws now — log a write failure but don't abort
|
||||
// the uninstall. Every earlier step already completed (files
|
||||
// removed, skills removed, cron jobs removed, memory stripped,
|
||||
// Keychain cleared); failing here leaves a stale registry row
|
||||
// pointing at a deleted project — cosmetic and easy to fix
|
||||
// from the sidebar.
|
||||
do {
|
||||
try dashboardService.saveRegistry(registry)
|
||||
} catch {
|
||||
Self.logger.warning("uninstall couldn't rewrite projects registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated private func lockPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/.scarf/template.lock.json"
|
||||
}
|
||||
|
||||
/// Walk the project dir and return the absolute paths of every entry
|
||||
/// not in `trackedPaths`. `.scarf/` (and its remaining contents after
|
||||
/// the lock is recorded) is filtered out because the installer owns
|
||||
/// that directory entirely — if the user dropped a file into it,
|
||||
/// that's on them, but the common case is that `.scarf/` only holds
|
||||
/// our dashboard.json + template.lock.json.
|
||||
nonisolated private func enumerateProjectDirExtras(
|
||||
projectDir: String,
|
||||
trackedPaths: Set<String>,
|
||||
transport: any ServerTransport
|
||||
) throws -> [String] {
|
||||
guard transport.fileExists(projectDir) else { return [] }
|
||||
var extras: [String] = []
|
||||
let entries: [String]
|
||||
do {
|
||||
entries = try transport.listDirectory(projectDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
for entry in entries {
|
||||
let full = projectDir + "/" + entry
|
||||
// Skip the .scarf/ dir entirely when deciding "does the
|
||||
// project dir have user content?" — the only files we put
|
||||
// there (dashboard.json + lock) are tracked already, and
|
||||
// if they're still there the overall project is not yet
|
||||
// "empty."
|
||||
if entry == ".scarf" { continue }
|
||||
if trackedPaths.contains(full) { continue }
|
||||
extras.append(full)
|
||||
}
|
||||
return extras
|
||||
}
|
||||
|
||||
/// Recursively delete a directory via the transport. The transport's
|
||||
/// `removeFile` works on files and on empty directories; we walk
|
||||
/// children first, then remove the now-empty parent.
|
||||
nonisolated private func removeRecursively(
|
||||
_ path: String,
|
||||
transport: any ServerTransport
|
||||
) throws {
|
||||
guard transport.fileExists(path) else { return }
|
||||
if transport.stat(path)?.isDirectory != true {
|
||||
try transport.removeFile(path)
|
||||
return
|
||||
}
|
||||
let entries = (try? transport.listDirectory(path)) ?? []
|
||||
for entry in entries {
|
||||
try removeRecursively(path + "/" + entry, transport: transport)
|
||||
}
|
||||
try transport.removeFile(path)
|
||||
}
|
||||
|
||||
/// Remove the `<!-- scarf-template:<id>:begin --> … :end -->` block
|
||||
/// from MEMORY.md, preserving everything else. A missing end marker
|
||||
/// is logged but doesn't fail — we strip from the begin marker to
|
||||
/// EOF in that case, on the theory that a broken template block is
|
||||
/// worse than a slightly aggressive strip.
|
||||
nonisolated private func stripMemoryBlock(
|
||||
blockId: String,
|
||||
memoryPath: String,
|
||||
transport: any ServerTransport
|
||||
) throws {
|
||||
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(templateId: blockId)
|
||||
let endMarker = ProjectTemplateService.memoryBlockEndMarker(templateId: blockId)
|
||||
|
||||
let data = try transport.readFile(memoryPath)
|
||||
guard let text = String(data: data, encoding: .utf8) else { return }
|
||||
guard let beginRange = text.range(of: beginMarker) else { return }
|
||||
|
||||
let stripRange: Range<String.Index>
|
||||
if let endRange = text.range(of: endMarker, range: beginRange.upperBound..<text.endIndex) {
|
||||
// Include the end marker and one trailing newline if present.
|
||||
var upper = endRange.upperBound
|
||||
if upper < text.endIndex, text[upper] == "\n" {
|
||||
upper = text.index(after: upper)
|
||||
}
|
||||
stripRange = beginRange.lowerBound..<upper
|
||||
} else {
|
||||
Self.logger.warning("memory block for \(blockId, privacy: .public) has begin marker but no end marker; stripping to EOF")
|
||||
stripRange = beginRange.lowerBound..<text.endIndex
|
||||
}
|
||||
|
||||
// Also consume one leading blank line that the installer inserts
|
||||
// before the begin marker, so repeated install/uninstall cycles
|
||||
// don't accumulate blank lines at the insertion site.
|
||||
var lower = stripRange.lowerBound
|
||||
if lower > text.startIndex {
|
||||
let prev = text.index(before: lower)
|
||||
if text[prev] == "\n", prev > text.startIndex {
|
||||
let prevPrev = text.index(before: prev)
|
||||
if text[prevPrev] == "\n" {
|
||||
lower = prev
|
||||
}
|
||||
}
|
||||
}
|
||||
let updated = text.replacingCharacters(in: lower..<stripRange.upperBound, with: "")
|
||||
guard let outData = updated.data(using: .utf8) else { return }
|
||||
try transport.writeFile(memoryPath, data: outData)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// Process-wide router for `scarf://install?url=…` URLs. The app delegate's
|
||||
/// `onOpenURL` hands the URL in here; the Projects feature observes
|
||||
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
|
||||
///
|
||||
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
|
||||
/// launch from a browser link) and still be picked up by the first
|
||||
/// `ProjectsView` that appears.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateURLRouter {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
|
||||
|
||||
static let shared = TemplateURLRouter()
|
||||
|
||||
/// Non-nil when an install request is waiting to be handled. Can be
|
||||
/// either a remote `https://…` URL (from a `scarf://install?url=…` deep
|
||||
/// link) or a local `file://…` URL (from a Finder double-click on a
|
||||
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
|
||||
/// this, dispatch by scheme, present the install sheet, then call
|
||||
/// `consume` to clear it. Only one pending install at a time — if a
|
||||
/// second arrives before the first is consumed, it replaces the first
|
||||
/// (matches browser-link intuition where the latest click wins).
|
||||
var pendingInstallURL: URL?
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Parse and validate an inbound URL. Returns `true` if the URL was
|
||||
/// recognized and staged for handling. Unknown schemes or malformed
|
||||
/// payloads return `false` so the caller can log/ignore. Supports:
|
||||
///
|
||||
/// - `scarf://install?url=https://…` — remote template URL from a web link.
|
||||
/// - `file:///…/foo.scarftemplate` — local file from a Finder
|
||||
/// double-click or a drag onto the app icon.
|
||||
@discardableResult
|
||||
func handle(_ url: URL) -> Bool {
|
||||
if url.isFileURL {
|
||||
return handleFileURL(url)
|
||||
}
|
||||
if url.scheme?.lowercased() == "scarf" {
|
||||
return handleScarfURL(url)
|
||||
}
|
||||
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
|
||||
private func handleFileURL(_ url: URL) -> Bool {
|
||||
guard url.pathExtension.lowercased() == "scarftemplate" else {
|
||||
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
pendingInstallURL = url
|
||||
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleScarfURL(_ url: URL) -> Bool {
|
||||
guard url.host?.lowercased() == "install" else {
|
||||
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
|
||||
let remote = URL(string: raw) else {
|
||||
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
// Refuse anything but https — defense-in-depth against a browser or
|
||||
// mail client that would happily hand us a javascript: or http://
|
||||
// URL pointing at something unexpected.
|
||||
guard remote.scheme?.lowercased() == "https" else {
|
||||
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
pendingInstallURL = remote
|
||||
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
|
||||
return true
|
||||
}
|
||||
|
||||
/// Called by the install sheet once it has picked up the URL.
|
||||
func consume() {
|
||||
pendingInstallURL = nil
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,61 @@ final class CronViewModel {
|
||||
}
|
||||
|
||||
func runNow(_ job: HermesCronJob) {
|
||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
||||
// `hermes cron run <id>` only marks the job as due on the next
|
||||
// scheduler tick — it doesn't actually execute. If the Hermes
|
||||
// gateway's scheduler isn't running (common during dev + right
|
||||
// after install), the user's "Run now" click results in zero
|
||||
// visible effect because the tick never comes. We follow up
|
||||
// with `hermes cron tick` which runs all due jobs once and
|
||||
// exits. Redundant-but-harmless when the gateway is running;
|
||||
// the actual trigger when it isn't.
|
||||
//
|
||||
// Feedback model: show a "Agent started" toast as soon as
|
||||
// `cron run` succeeds, WITHOUT waiting for `cron tick` to
|
||||
// return. Agent jobs routinely run past a minute (network IO +
|
||||
// an LLM call + a file rewrite), and earlier versions with a
|
||||
// 60s tick timeout surfaced a misleading "Run failed" toast
|
||||
// every time while the job kept running in the background.
|
||||
// The app's HermesFileWatcher picks up the dashboard.json
|
||||
// rewrite that the agent lands at the end — that's what the
|
||||
// user actually watches for, not this toast.
|
||||
let svc = fileService
|
||||
let jobID = job.id
|
||||
Task.detached { [weak self] in
|
||||
let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if runResult.exitCode != 0 {
|
||||
self.message = "Run failed to queue: \(runResult.output.prefix(200))"
|
||||
self.logger.warning("cron run failed: \(runResult.output)")
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
self.message = "Agent started — dashboard will update when it finishes"
|
||||
self.load()
|
||||
}
|
||||
// `cron run` is queued; now force the tick. The 300s
|
||||
// timeout catches truly stuck processes without killing
|
||||
// the long-but-valid agent case that blew up the 60s
|
||||
// version. A timeout here is survivable — the Hermes
|
||||
// scheduler re-runs due jobs on its own cadence — so we
|
||||
// log but don't surface it as a failure toast.
|
||||
try? await Task.sleep(for: .milliseconds(250))
|
||||
let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 300)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
if tickResult.exitCode != 0 {
|
||||
self.logger.warning("cron tick exited non-zero (job may still complete via scheduler): \(tickResult.output)")
|
||||
}
|
||||
self.load()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.message = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func deleteJob(_ job: HermesCronJob) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
final class ProjectsViewModel {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||
let context: ServerContext
|
||||
private let service: ProjectDashboardService
|
||||
|
||||
@@ -39,7 +41,19 @@ final class ProjectsViewModel {
|
||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||
let entry = ProjectEntry(name: name, path: path)
|
||||
registry.projects.append(entry)
|
||||
service.saveRegistry(registry)
|
||||
// saveRegistry throws now. The VM doesn't currently have a
|
||||
// surface for user-visible errors (there's no alert/toast in
|
||||
// the Projects view), so log at error level to the unified
|
||||
// log and keep the in-memory state consistent with whatever
|
||||
// landed on disk. If the write fails, the added entry won't
|
||||
// persist across launches — the user sees it appear + work
|
||||
// this session, then it's gone at relaunch. Not ideal, but
|
||||
// matches today's UX and flagged for a proper alert later.
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("addProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
projects = registry.projects
|
||||
selectProject(entry)
|
||||
}
|
||||
@@ -47,7 +61,11 @@ final class ProjectsViewModel {
|
||||
func removeProject(_ project: ProjectEntry) {
|
||||
var registry = service.loadRegistry()
|
||||
registry.projects.removeAll { $0.name == project.name }
|
||||
service.saveRegistry(registry)
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("removeProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
projects = registry.projects
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = nil
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
private enum DashboardTab: String, CaseIterable {
|
||||
case dashboard = "Dashboard"
|
||||
@@ -14,12 +15,40 @@ private enum DashboardTab: String, CaseIterable {
|
||||
|
||||
struct ProjectsView: View {
|
||||
@State private var viewModel: ProjectsViewModel
|
||||
@State private var installerViewModel: TemplateInstallerViewModel
|
||||
@State private var uninstallerViewModel: TemplateUninstallerViewModel
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@State private var showingAddSheet = false
|
||||
@State private var showingInstallSheet = false
|
||||
@State private var exportSheetProject: ProjectEntry?
|
||||
@State private var showingInstallURLPrompt = false
|
||||
@State private var installURLInput = ""
|
||||
@State private var showingUninstallSheet = false
|
||||
@State private var configEditorProject: ProjectEntry?
|
||||
/// Project queued for the "remove from list" confirmation dialog.
|
||||
/// Non-nil while the dialog is up; the `confirmationDialog` binding
|
||||
/// flips based on presence. We store the full entry (not just a
|
||||
/// flag) so the dialog's action closure knows which project to
|
||||
/// drop from the registry.
|
||||
@State private var pendingRemoveFromList: ProjectEntry?
|
||||
|
||||
private let uninstaller: ProjectTemplateUninstaller
|
||||
|
||||
init(context: ServerContext) {
|
||||
_viewModel = State(initialValue: ProjectsViewModel(context: context))
|
||||
_installerViewModel = State(initialValue: TemplateInstallerViewModel(context: context))
|
||||
_uninstallerViewModel = State(initialValue: TemplateUninstallerViewModel(context: context))
|
||||
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||
}
|
||||
|
||||
/// True when the given project has a cached manifest (i.e. was
|
||||
/// installed from a schemaful template). Cheap — just a file
|
||||
/// existence check via the transport.
|
||||
private func isConfigurable(_ project: ProjectEntry) -> Bool {
|
||||
let path = ProjectConfigService.manifestCachePath(for: project)
|
||||
return serverContext.makeTransport().fileExists(path)
|
||||
}
|
||||
|
||||
@State private var selectedTab: DashboardTab = .dashboard
|
||||
@@ -32,6 +61,7 @@ struct ProjectsView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.navigationTitle("Projects")
|
||||
.toolbar { templatesToolbar }
|
||||
.task {
|
||||
viewModel.load()
|
||||
if let name = coordinator.selectedProjectName,
|
||||
@@ -39,11 +69,195 @@ struct ProjectsView: View {
|
||||
viewModel.selectProject(project)
|
||||
}
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
// Cold-launch deep link or Finder double-click: the router may
|
||||
// have a URL staged before this view installed the onChange
|
||||
// observer below. Without this first-appearance check,
|
||||
// SwiftUI's .onChange would never fire (it only reacts to
|
||||
// *changes* after installation) and the URL would sit on the
|
||||
// singleton forever.
|
||||
if let pending = TemplateURLRouter.shared.pendingInstallURL {
|
||||
dispatchPendingInstall(pending)
|
||||
}
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
viewModel.load()
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
.onChange(of: TemplateURLRouter.shared.pendingInstallURL) { _, new in
|
||||
// A URL landed *while the app was already running*.
|
||||
if let new {
|
||||
dispatchPendingInstall(new)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingInstallSheet) {
|
||||
TemplateInstallSheet(viewModel: installerViewModel) { entry in
|
||||
viewModel.load()
|
||||
coordinator.selectedProjectName = entry.name
|
||||
if let project = viewModel.projects.first(where: { $0.name == entry.name }) {
|
||||
viewModel.selectProject(project)
|
||||
}
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
}
|
||||
.sheet(item: $exportSheetProject) { project in
|
||||
TemplateExportSheet(
|
||||
viewModel: TemplateExporterViewModel(context: serverContext, project: project)
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showingInstallURLPrompt) {
|
||||
installURLSheet
|
||||
}
|
||||
.sheet(isPresented: $showingUninstallSheet) {
|
||||
TemplateUninstallSheet(viewModel: uninstallerViewModel) { removed in
|
||||
// Refresh the registry and clear selection if we just
|
||||
// removed the project the user was viewing.
|
||||
if viewModel.selectedProject?.path == removed.path {
|
||||
viewModel.selectedProject = nil
|
||||
}
|
||||
if coordinator.selectedProjectName == removed.name {
|
||||
coordinator.selectedProjectName = nil
|
||||
}
|
||||
viewModel.load()
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
}
|
||||
.sheet(item: $configEditorProject) { project in
|
||||
ConfigEditorSheet(
|
||||
context: serverContext,
|
||||
project: project
|
||||
)
|
||||
}
|
||||
// Confirmation dialog for the sidebar's "Remove from List" action.
|
||||
// The action is registry-only (doesn't touch disk), but the name
|
||||
// historically confused users into thinking it was a full delete.
|
||||
// A confirmation with explicit wording clarifies scope before the
|
||||
// click is destructive-looking but actually harmless.
|
||||
.confirmationDialog(
|
||||
removeFromListDialogTitle,
|
||||
isPresented: Binding(
|
||||
get: { pendingRemoveFromList != nil },
|
||||
set: { if !$0 { pendingRemoveFromList = nil } }
|
||||
),
|
||||
titleVisibility: .visible,
|
||||
presenting: pendingRemoveFromList
|
||||
) { project in
|
||||
Button("Remove from List") {
|
||||
viewModel.removeProject(project)
|
||||
if coordinator.selectedProjectName == project.name {
|
||||
coordinator.selectedProjectName = nil
|
||||
}
|
||||
pendingRemoveFromList = nil
|
||||
}
|
||||
Button("Cancel", role: .cancel) {
|
||||
pendingRemoveFromList = nil
|
||||
}
|
||||
} message: { project in
|
||||
Text(
|
||||
"\(project.name) will be removed from Scarf's project list. " +
|
||||
"Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. " +
|
||||
"To actually remove installed files, use \"Uninstall Template…\" instead."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Title string for the remove-from-list confirmation dialog. Kept
|
||||
/// as a computed property so the dialog and any future reuse share
|
||||
/// the exact same copy.
|
||||
private var removeFromListDialogTitle: LocalizedStringKey {
|
||||
"Remove from Scarf's project list?"
|
||||
}
|
||||
|
||||
// MARK: - Toolbar
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var templatesToolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button("Install from File…", systemImage: "tray.and.arrow.down") {
|
||||
openInstallFilePicker()
|
||||
}
|
||||
Button("Install from URL…", systemImage: "link") {
|
||||
installURLInput = ""
|
||||
showingInstallURLPrompt = true
|
||||
}
|
||||
Divider()
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button("Export \"\(selected.name)\" as Template…", systemImage: "tray.and.arrow.up") {
|
||||
exportSheetProject = selected
|
||||
}
|
||||
} else {
|
||||
Button("Export as Template…", systemImage: "tray.and.arrow.up") {}
|
||||
.disabled(true)
|
||||
}
|
||||
} label: {
|
||||
Label("Templates", systemImage: "shippingbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var installURLSheet: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Install Template from URL")
|
||||
.font(.headline)
|
||||
Text("Paste an https URL pointing at a .scarftemplate file.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("https://example.com/my.scarftemplate", text: $installURLInput)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
HStack {
|
||||
Button("Cancel") { showingInstallURLPrompt = false }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Install") {
|
||||
if let url = URL(string: installURLInput), url.scheme?.lowercased() == "https" {
|
||||
installerViewModel.openRemoteURL(url)
|
||||
showingInstallURLPrompt = false
|
||||
showingInstallSheet = true
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(URL(string: installURLInput)?.scheme?.lowercased() != "https")
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 480)
|
||||
}
|
||||
|
||||
/// Route a pending install URL to the right VM entry point. `file://`
|
||||
/// URLs come from Finder double-clicks + the "Install from File…" flow
|
||||
/// when routed via the router; `https://` URLs come from `scarf://`
|
||||
/// deep links and the "Install from URL…" prompt.
|
||||
private func dispatchPendingInstall(_ url: URL) {
|
||||
if url.isFileURL {
|
||||
installerViewModel.openLocalFile(url.path)
|
||||
} else {
|
||||
installerViewModel.openRemoteURL(url)
|
||||
}
|
||||
TemplateURLRouter.shared.consume()
|
||||
showingInstallSheet = true
|
||||
}
|
||||
|
||||
private func openInstallFilePicker() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
panel.allowsMultipleSelection = false
|
||||
// Accept both the declared Scarf template UTI and plain zip — the
|
||||
// custom UTI wins for files with the .scarftemplate extension, and
|
||||
// the zip fallback means an author distributing under .zip (e.g.
|
||||
// before the UTI is registered on the receiving Mac) still works.
|
||||
var types: [UTType] = [.zip]
|
||||
if let templateType = UTType("com.scarf.template") {
|
||||
types.insert(templateType, at: 0)
|
||||
}
|
||||
panel.allowedContentTypes = types
|
||||
panel.allowsOtherFileTypes = true
|
||||
panel.prompt = String(localized: "Install Template")
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
installerViewModel.openLocalFile(url.path)
|
||||
showingInstallSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Project List
|
||||
@@ -65,6 +279,32 @@ struct ProjectsView: View {
|
||||
Text(project.name)
|
||||
}
|
||||
.tag(project)
|
||||
.contextMenu {
|
||||
if isConfigurable(project) {
|
||||
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||
configEditorProject = project
|
||||
}
|
||||
}
|
||||
if uninstaller.isTemplateInstalled(project: project) {
|
||||
// "Uninstall Template…" only appears for projects
|
||||
// installed from a `.scarftemplate`. Trailing
|
||||
// ellipsis signals a confirmation sheet follows
|
||||
// (macOS HIG convention); the sheet itself lists
|
||||
// every file/cron/skill that will be removed.
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
// "Remove from List" used to be "Remove from Scarf",
|
||||
// which users read as a full delete. Clarified label +
|
||||
// ellipsis + confirmation dialog all spell out that
|
||||
// this is registry-only; nothing on disk is touched.
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
pendingRemoveFromList = project
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
@@ -76,10 +316,16 @@ struct ProjectsView: View {
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button(action: { viewModel.removeProject(selected) }) {
|
||||
// Route through the same confirmation dialog as the
|
||||
// context-menu "Remove from List" entry. The minus
|
||||
// icon is a drive-by click target right next to "+" —
|
||||
// confirming before mutating the registry stops the
|
||||
// "I clicked by accident and my project's gone" case.
|
||||
Button(action: { pendingRemoveFromList = selected }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
@@ -216,6 +462,25 @@ 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)
|
||||
showingUninstallSheet = true
|
||||
} label: {
|
||||
Image(systemName: "shippingbox.and.arrow.backward")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Uninstall template")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// Drives the post-install "Configuration" button on the project
|
||||
/// dashboard. Loads `<project>/.scarf/manifest.json` + `config.json`,
|
||||
/// hands a `TemplateConfigViewModel` seeded with current values to the
|
||||
/// sheet, then writes the edited values back on commit.
|
||||
///
|
||||
/// Smaller surface than `TemplateInstallerViewModel` — no unzipping,
|
||||
/// no parent-dir picking, no cron CLI. Just: read → edit → save.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateConfigEditorViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigEditorViewModel")
|
||||
|
||||
enum Stage: Sendable {
|
||||
case idle
|
||||
case loading
|
||||
/// Manifest + config loaded; the sheet is displaying the form.
|
||||
case editing
|
||||
case saving
|
||||
case succeeded
|
||||
case failed(String)
|
||||
/// Project wasn't installed from a schemaful template — no
|
||||
/// manifest cache on disk. The dashboard button is hidden in
|
||||
/// this case so we shouldn't hit this stage normally.
|
||||
case notConfigurable
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
let project: ProjectEntry
|
||||
private let configService: ProjectConfigService
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
self.context = context
|
||||
self.project = project
|
||||
self.configService = ProjectConfigService(context: context)
|
||||
}
|
||||
|
||||
var stage: Stage = .idle
|
||||
var manifest: ProjectTemplateManifest?
|
||||
var currentValues: [String: TemplateConfigValue] = [:]
|
||||
|
||||
/// Non-nil while `.editing`; used to construct the sheet's VM.
|
||||
var formViewModel: TemplateConfigViewModel?
|
||||
|
||||
/// Load the cached manifest + current config values, then move to
|
||||
/// `.editing` so the sheet can render the form.
|
||||
func begin() {
|
||||
stage = .loading
|
||||
let service = configService
|
||||
let project = project
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
guard let cachedManifest = try service.loadCachedManifest(project: project),
|
||||
let schema = cachedManifest.config,
|
||||
!schema.isEmpty else {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .notConfigurable
|
||||
}
|
||||
return
|
||||
}
|
||||
let configFile = try service.load(project: project)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.manifest = cachedManifest
|
||||
self.currentValues = configFile?.values ?? [:]
|
||||
self.formViewModel = TemplateConfigViewModel(
|
||||
schema: schema,
|
||||
templateId: cachedManifest.id,
|
||||
templateSlug: cachedManifest.slug,
|
||||
initialValues: self.currentValues,
|
||||
mode: .edit(project: project)
|
||||
)
|
||||
self.stage = .editing
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("couldn't load config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the sheet's commit succeeded. Persists the edited
|
||||
/// values to `<project>/.scarf/config.json`. Secrets are already
|
||||
/// in the Keychain — the VM's commit step wrote them.
|
||||
func save(values: [String: TemplateConfigValue]) {
|
||||
guard let manifest else { return }
|
||||
stage = .saving
|
||||
let service = configService
|
||||
let project = project
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
try service.save(
|
||||
project: project,
|
||||
templateId: manifest.id,
|
||||
values: values
|
||||
)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .succeeded
|
||||
}
|
||||
} catch {
|
||||
Self.logger.error("couldn't save config for \(project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
stage = .idle
|
||||
formViewModel = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import os
|
||||
|
||||
/// Drives the configure form for template install + post-install editing.
|
||||
///
|
||||
/// **Timing of secret storage.** The VM keeps freshly-entered secret bytes
|
||||
/// in-memory (`pendingSecrets`) until the user clicks the commit button.
|
||||
/// Only then does `commit()` push each secret through
|
||||
/// `ProjectConfigService.storeSecret` and get back a `keychainRef` URI.
|
||||
/// This means cancelling the sheet never leaves an orphan Keychain
|
||||
/// entry behind — the form is transactional from the user's POV.
|
||||
///
|
||||
/// **Validation.** Runs via `ProjectConfigService.validateValues` every
|
||||
/// time the user attempts to commit. Per-field errors are tracked in
|
||||
/// `errors` so the sheet can surface them inline with the offending field.
|
||||
/// No live validation on every keystroke — that creates a messy
|
||||
/// "error appears the moment you start typing" UX.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateConfigViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateConfigViewModel")
|
||||
|
||||
enum Mode: Sendable {
|
||||
/// User is filling in values for the first time as part of the
|
||||
/// install flow. Secrets will be written to the Keychain when
|
||||
/// `commit` succeeds.
|
||||
case install
|
||||
/// User is editing values for an already-installed project.
|
||||
/// Existing keychain refs are preserved for fields the user
|
||||
/// doesn't touch; only secrets the user actually changes get
|
||||
/// re-written to the Keychain.
|
||||
case edit(project: ProjectEntry)
|
||||
}
|
||||
|
||||
let schema: TemplateConfigSchema
|
||||
let templateId: String
|
||||
let templateSlug: String
|
||||
let mode: Mode
|
||||
private let configService: ProjectConfigService
|
||||
|
||||
/// Current form values, keyed by field key. Non-secret values live
|
||||
/// here directly; secret fields either hold a `.keychainRef(...)`
|
||||
/// (existing, untouched in edit mode) or nothing at all (user
|
||||
/// hasn't entered a secret yet, or they just cleared it).
|
||||
var values: [String: TemplateConfigValue] = [:]
|
||||
|
||||
/// Raw secret bytes waiting to be written to the Keychain on
|
||||
/// `commit()`. Indexed by field key. `values[key]` stays as its
|
||||
/// current `.keychainRef(...)` (for edit mode) or missing (for
|
||||
/// install mode) until commit swaps it for the freshly-written
|
||||
/// ref URI.
|
||||
var pendingSecrets: [String: Data] = [:]
|
||||
|
||||
/// One error per field with a problem. Populated by `commit()` on
|
||||
/// validation failure; the sheet surfaces the message inline below
|
||||
/// the offending control.
|
||||
var errors: [String: String] = [:]
|
||||
|
||||
init(
|
||||
schema: TemplateConfigSchema,
|
||||
templateId: String,
|
||||
templateSlug: String,
|
||||
initialValues: [String: TemplateConfigValue] = [:],
|
||||
mode: Mode,
|
||||
configService: ProjectConfigService = ProjectConfigService()
|
||||
) {
|
||||
self.schema = schema
|
||||
self.templateId = templateId
|
||||
self.templateSlug = templateSlug
|
||||
self.mode = mode
|
||||
self.configService = configService
|
||||
self.values = Self.applyDefaults(schema: schema, initial: initialValues)
|
||||
}
|
||||
|
||||
// MARK: - Field setters (the sheet calls these as controls change)
|
||||
|
||||
func setString(_ key: String, _ value: String) {
|
||||
values[key] = .string(value)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func setNumber(_ key: String, _ value: Double) {
|
||||
values[key] = .number(value)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func setBool(_ key: String, _ value: Bool) {
|
||||
values[key] = .bool(value)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
func setList(_ key: String, _ items: [String]) {
|
||||
values[key] = .list(items)
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
/// Stage a new secret value. Doesn't hit the Keychain until
|
||||
/// `commit()`. An empty `value` clears both the pending secret and
|
||||
/// the field's stored keychainRef — only valid in edit mode, where
|
||||
/// "empty" means "I want to remove this secret."
|
||||
func setSecret(_ key: String, _ value: String) {
|
||||
if value.isEmpty {
|
||||
pendingSecrets.removeValue(forKey: key)
|
||||
values.removeValue(forKey: key)
|
||||
} else {
|
||||
pendingSecrets[key] = Data(value.utf8)
|
||||
// Keep any existing ref around; the sheet can display
|
||||
// "(changed)" while the ref is still the old one. commit()
|
||||
// overwrites on disk.
|
||||
}
|
||||
errors.removeValue(forKey: key)
|
||||
}
|
||||
|
||||
// MARK: - Commit
|
||||
|
||||
/// Validate, persist secrets to the Keychain, and hand back the
|
||||
/// final values dictionary. On validation failure, `errors` is
|
||||
/// populated and the method returns `nil` without touching the
|
||||
/// Keychain — the form is transactional.
|
||||
///
|
||||
/// In install mode, `project` is required (secrets need a path
|
||||
/// hash for their Keychain account). In edit mode it falls out of
|
||||
/// the `.edit(project:)` associated value.
|
||||
func commit(project: ProjectEntry? = nil) -> [String: TemplateConfigValue]? {
|
||||
// Build the value set we're about to validate. For secrets
|
||||
// that have a pending update, we treat them as present (we'll
|
||||
// write them in a moment); for secrets already stored as
|
||||
// keychainRef, we treat them as present too. Only a completely
|
||||
// empty secret field is "missing."
|
||||
var candidate = values
|
||||
for key in pendingSecrets.keys {
|
||||
// The field is about to have a fresh keychainRef — for
|
||||
// validation purposes, use a placeholder ref so the type
|
||||
// check passes. The real ref replaces it below.
|
||||
candidate[key] = .keychainRef("pending://\(key)")
|
||||
}
|
||||
let validationErrors = ProjectConfigService.validateValues(candidate, against: schema)
|
||||
guard validationErrors.isEmpty else {
|
||||
var byField: [String: String] = [:]
|
||||
for err in validationErrors {
|
||||
guard let key = err.fieldKey else { continue }
|
||||
byField[key] = err.message
|
||||
}
|
||||
self.errors = byField
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validation passed — write the pending secrets to the Keychain.
|
||||
let targetProject: ProjectEntry
|
||||
switch mode {
|
||||
case .install:
|
||||
guard let project else {
|
||||
Self.logger.error("commit(project:) called in install mode without a project")
|
||||
return nil
|
||||
}
|
||||
targetProject = project
|
||||
case .edit(let proj):
|
||||
targetProject = proj
|
||||
}
|
||||
|
||||
for (key, secret) in pendingSecrets {
|
||||
do {
|
||||
let ref = try configService.storeSecret(
|
||||
templateSlug: templateSlug,
|
||||
fieldKey: key,
|
||||
project: targetProject,
|
||||
secret: secret
|
||||
)
|
||||
values[key] = ref
|
||||
} catch {
|
||||
Self.logger.error("failed to store secret for \(key, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
errors[key] = "Couldn't save secret to the Keychain: \(error.localizedDescription)"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
pendingSecrets.removeAll()
|
||||
errors.removeAll()
|
||||
return values
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Seed the form with any author-supplied defaults for fields that
|
||||
/// don't already have an initial value (from a saved config.json).
|
||||
nonisolated private static func applyDefaults(
|
||||
schema: TemplateConfigSchema,
|
||||
initial: [String: TemplateConfigValue]
|
||||
) -> [String: TemplateConfigValue] {
|
||||
var out = initial
|
||||
for field in schema.fields where out[field.key] == nil {
|
||||
if let def = field.defaultValue {
|
||||
out[field.key] = def
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Drives the template export sheet. Holds form state for the author-facing
|
||||
/// fields (id, name, version, description, …) and the selection of skills
|
||||
/// and cron jobs to include, then builds and writes the `.scarftemplate` on
|
||||
/// confirm.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateExporterViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateExporterViewModel")
|
||||
|
||||
enum Stage: Sendable {
|
||||
case idle
|
||||
case exporting
|
||||
case succeeded(path: String)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
let project: ProjectEntry
|
||||
private let exporter: ProjectTemplateExporter
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
self.context = context
|
||||
self.project = project
|
||||
self.exporter = ProjectTemplateExporter(context: context)
|
||||
|
||||
self.templateName = project.name
|
||||
self.templateId = "you/\(ProjectTemplateExporter.slugify(project.name))"
|
||||
}
|
||||
|
||||
// Form fields
|
||||
var templateId: String
|
||||
var templateName: String
|
||||
var templateVersion: String = "1.0.0"
|
||||
var templateDescription: String = ""
|
||||
var authorName: String = ""
|
||||
var authorURL: String = ""
|
||||
var category: String = ""
|
||||
var tags: String = ""
|
||||
var includeSkillIds: Set<String> = []
|
||||
var includeCronJobIds: Set<String> = []
|
||||
var memoryAppendix: String = ""
|
||||
|
||||
// Derived: what the author can pick from
|
||||
var availableSkills: [HermesSkill] = []
|
||||
var availableCronJobs: [HermesCronJob] = []
|
||||
|
||||
var stage: Stage = .idle
|
||||
|
||||
func load() {
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
let service = HermesFileService(context: ctx)
|
||||
let skills = service.loadSkills().flatMap(\.skills)
|
||||
let jobs = service.loadCronJobs()
|
||||
await MainActor.run { [weak self] in
|
||||
self?.availableSkills = skills
|
||||
self?.availableCronJobs = jobs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func previewPlan() -> ProjectTemplateExporter.ExportPlan {
|
||||
exporter.previewPlan(for: currentInputs)
|
||||
}
|
||||
|
||||
/// Kick off the export, writing to `outputPath`. The caller is
|
||||
/// responsible for bouncing the user through an `NSSavePanel` to get
|
||||
/// that path.
|
||||
func export(to outputPath: String) {
|
||||
stage = .exporting
|
||||
let exporter = exporter
|
||||
let inputs = currentInputs
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
try exporter.export(inputs: inputs, outputZipPath: outputPath)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .succeeded(path: outputPath)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private var currentInputs: ProjectTemplateExporter.ExportInputs {
|
||||
let parsedTags = tags
|
||||
.split(separator: ",")
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
let trimmedAppendix = memoryAppendix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return ProjectTemplateExporter.ExportInputs(
|
||||
project: project,
|
||||
templateId: templateId.trimmingCharacters(in: .whitespaces),
|
||||
templateName: templateName.trimmingCharacters(in: .whitespaces),
|
||||
templateVersion: templateVersion.trimmingCharacters(in: .whitespaces),
|
||||
description: templateDescription.trimmingCharacters(in: .whitespaces),
|
||||
authorName: authorName.isEmpty ? nil : authorName,
|
||||
authorUrl: authorURL.isEmpty ? nil : authorURL,
|
||||
category: category.isEmpty ? nil : category,
|
||||
tags: parsedTags,
|
||||
includeSkillIds: Array(includeSkillIds),
|
||||
includeCronJobIds: Array(includeCronJobIds),
|
||||
memoryAppendix: trimmedAppendix.isEmpty ? nil : trimmedAppendix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectTemplateExporter {
|
||||
/// Lowercase-and-hyphenate a human name into something safe for a
|
||||
/// template id suffix. Only used to seed the default id in the export
|
||||
/// form — the author can overwrite it.
|
||||
nonisolated static func slugify(_ raw: String) -> String {
|
||||
let lower = raw.lowercased()
|
||||
let mapped = lower.unicodeScalars.map { scalar -> Character in
|
||||
let c = Character(scalar)
|
||||
if c.isLetter || c.isNumber { return c }
|
||||
return "-"
|
||||
}
|
||||
let collapsed = String(mapped)
|
||||
.split(separator: "-", omittingEmptySubsequences: true)
|
||||
.joined(separator: "-")
|
||||
return collapsed.isEmpty ? "template" : collapsed
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Drives the template install sheet. Handles three entry points:
|
||||
/// 1. `openLocalFile(_:)` — user picked a `.scarftemplate` from disk.
|
||||
/// 2. `openRemoteURL(_:)` — user pasted/deeplinked a https URL.
|
||||
/// 3. `confirmInstall()` — user clicked "Install" in the preview sheet.
|
||||
///
|
||||
/// The view model owns one ephemeral temp dir at a time (the unpacked
|
||||
/// bundle). `cancel()` or `confirmInstall()` removes it.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateInstallerViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
|
||||
|
||||
enum Stage: Sendable {
|
||||
case idle
|
||||
case fetching(sourceDescription: String)
|
||||
case inspecting
|
||||
case awaitingParentDirectory
|
||||
/// Template declared a non-empty config schema; the sheet
|
||||
/// presents `TemplateConfigSheet` before continuing to the
|
||||
/// preview. Schema-less templates skip this stage entirely.
|
||||
case awaitingConfig
|
||||
case planned
|
||||
case installing
|
||||
case succeeded(installed: ProjectEntry)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
private let templateService: ProjectTemplateService
|
||||
private let installer: ProjectTemplateInstaller
|
||||
|
||||
init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.templateService = ProjectTemplateService(context: context)
|
||||
self.installer = ProjectTemplateInstaller(context: context)
|
||||
}
|
||||
|
||||
var stage: Stage = .idle
|
||||
var inspection: TemplateInspection?
|
||||
var plan: TemplateInstallPlan?
|
||||
var chosenParentDirectory: String?
|
||||
/// README body preloaded off MainActor when inspection completes, so the
|
||||
/// preview sheet can render it without hitting `String(contentsOf:)` from
|
||||
/// inside a View body.
|
||||
var readmeBody: String?
|
||||
|
||||
// MARK: - Entry points
|
||||
|
||||
/// Inspect a local `.scarftemplate` file. Moves stage to `.inspecting`
|
||||
/// then either `.awaitingParentDirectory` or `.failed`. The unpacked
|
||||
/// README body is read off MainActor here and stored on the VM so the
|
||||
/// preview sheet doesn't do sync I/O during View body evaluation.
|
||||
func openLocalFile(_ zipPath: String) {
|
||||
resetTempState()
|
||||
stage = .inspecting
|
||||
let service = templateService
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let inspection = try service.inspect(zipPath: zipPath)
|
||||
let readme = Self.readReadme(unpackedDir: inspection.unpackedDir)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.inspection = inspection
|
||||
self.readmeBody = readme
|
||||
self.stage = .awaitingParentDirectory
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read README.md from an unpacked template dir. Nonisolated so the
|
||||
/// inspect task can call it off MainActor. Returns `nil` on any I/O
|
||||
/// failure — the preview sheet treats a nil README as "no section."
|
||||
nonisolated private static func readReadme(unpackedDir: String) -> String? {
|
||||
let path = unpackedDir + "/README.md"
|
||||
do {
|
||||
return try String(contentsOf: URL(fileURLWithPath: path), encoding: .utf8)
|
||||
} catch {
|
||||
Logger(subsystem: "com.scarf", category: "TemplateInstallerViewModel")
|
||||
.warning("couldn't read README at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Download a https `.scarftemplate` to a temp file, then hand off to
|
||||
/// `openLocalFile`. The 50 MB cap matches the plan — templates shouldn't
|
||||
/// be anywhere near that, and rejecting huge downloads is cheap defense.
|
||||
///
|
||||
/// Content-Length is checked first as an early-out, but chunked
|
||||
/// transfer responses omit that header. The authoritative check is the
|
||||
/// actual on-disk file size after the download completes — it runs
|
||||
/// unconditionally and covers the chunked-transfer case.
|
||||
func openRemoteURL(_ url: URL) {
|
||||
resetTempState()
|
||||
stage = .fetching(sourceDescription: url.host ?? url.absoluteString)
|
||||
Task.detached { [weak self] in
|
||||
let maxBytes: Int64 = 50 * 1024 * 1024
|
||||
do {
|
||||
let tempZip = NSTemporaryDirectory() + "scarf-template-download-" + UUID().uuidString + ".scarftemplate"
|
||||
let (tempURL, response) = try await URLSession.shared.download(from: url)
|
||||
defer { try? FileManager.default.removeItem(at: tempURL) }
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
throw ProjectTemplateError.unzipFailed("HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
if let length = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let bytes = Int64(length), bytes > maxBytes {
|
||||
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(bytes) bytes)")
|
||||
}
|
||||
}
|
||||
// Unconditional post-download size check — catches chunked
|
||||
// responses that ship no Content-Length. The download already
|
||||
// hit disk, but refusing to *process* it bounds the blast
|
||||
// radius to one temp file that gets removed in the defer.
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: tempURL.path)
|
||||
let actualSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
|
||||
guard actualSize <= maxBytes else {
|
||||
throw ProjectTemplateError.unzipFailed("template exceeds 50 MB size cap (\(actualSize) bytes)")
|
||||
}
|
||||
try FileManager.default.moveItem(atPath: tempURL.path, toPath: tempZip)
|
||||
await MainActor.run { [weak self] in
|
||||
self?.openLocalFile(tempZip)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed("Couldn't fetch template: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Planning + confirmation
|
||||
|
||||
/// Finalize the plan now that the user has picked a parent directory.
|
||||
func pickParentDirectory(_ parentDir: String) {
|
||||
guard let inspection else { return }
|
||||
chosenParentDirectory = parentDir
|
||||
let service = templateService
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.plan = plan
|
||||
// If the template declares a non-empty config
|
||||
// schema, insert the configure step before the
|
||||
// preview sheet. Otherwise go straight to .planned.
|
||||
if let schema = plan.configSchema, !schema.isEmpty {
|
||||
self.stage = .awaitingConfig
|
||||
} else {
|
||||
self.stage = .planned
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called by `TemplateInstallSheet` once the user has filled in
|
||||
/// the configure form and `TemplateConfigViewModel.commit()`
|
||||
/// succeeded. Stashes the values in the plan and advances to the
|
||||
/// preview stage (`.planned`). Secrets in `values` are already
|
||||
/// `.keychainRef(...)` — the VM's commit step wrote them to the
|
||||
/// Keychain.
|
||||
func submitConfig(values: [String: TemplateConfigValue]) {
|
||||
guard var plan else { return }
|
||||
plan.configValues = values
|
||||
self.plan = plan
|
||||
stage = .planned
|
||||
}
|
||||
|
||||
/// Called when the user cancels out of the configure step without
|
||||
/// committing. Returns to `.awaitingParentDirectory` so they can
|
||||
/// try again (or dismiss the whole sheet).
|
||||
func cancelConfig() {
|
||||
stage = .awaitingParentDirectory
|
||||
}
|
||||
|
||||
func confirmInstall() {
|
||||
guard let plan else { return }
|
||||
stage = .installing
|
||||
let installer = installer
|
||||
let service = templateService
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let entry = try installer.install(plan: plan)
|
||||
service.cleanupTempDir(plan.unpackedDir)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.stage = .succeeded(installed: entry)
|
||||
self.inspection = nil
|
||||
self.plan = nil
|
||||
self.chosenParentDirectory = nil
|
||||
self.readmeBody = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
func cancel() {
|
||||
resetTempState()
|
||||
stage = .idle
|
||||
}
|
||||
|
||||
private func resetTempState() {
|
||||
if let inspection {
|
||||
templateService.cleanupTempDir(inspection.unpackedDir)
|
||||
}
|
||||
inspection = nil
|
||||
plan = nil
|
||||
chosenParentDirectory = nil
|
||||
readmeBody = nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Foundation
|
||||
import os
|
||||
|
||||
/// Drives the template-uninstall sheet. Mirrors the installer VM in
|
||||
/// stage shape: open a plan (`begin`), preview it, confirm or cancel.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TemplateUninstallerViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateUninstallerViewModel")
|
||||
|
||||
enum Stage: Sendable {
|
||||
case idle
|
||||
case loading
|
||||
case planned
|
||||
case uninstalling
|
||||
case succeeded(removed: ProjectEntry)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
/// Snapshot of "what survived the uninstall" — surfaced in the
|
||||
/// success screen so the user understands why the project directory
|
||||
/// is or isn't gone from disk. Computed from the plan right before
|
||||
/// executing it (`plan` itself is nil'd on success, so we can't
|
||||
/// reach back for this info after the fact).
|
||||
struct PreservedOutcome: Sendable {
|
||||
/// True when the uninstaller removed the project dir (nothing
|
||||
/// user-owned was left inside). In this case `preservedPaths`
|
||||
/// is empty and the success view skips the banner entirely.
|
||||
let projectDirRemoved: Bool
|
||||
/// Absolute paths of files the uninstaller refused to touch
|
||||
/// because they weren't installed by the template (typically
|
||||
/// `status-log.md` after the cron ran, or anything the user
|
||||
/// dropped into the project dir manually).
|
||||
let preservedPaths: [String]
|
||||
/// Project dir — echoed back so the success view can show the
|
||||
/// user where the orphan files now live.
|
||||
let projectDir: String
|
||||
}
|
||||
|
||||
let context: ServerContext
|
||||
private let uninstaller: ProjectTemplateUninstaller
|
||||
|
||||
init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.uninstaller = ProjectTemplateUninstaller(context: context)
|
||||
}
|
||||
|
||||
var stage: Stage = .idle
|
||||
var plan: TemplateUninstallPlan?
|
||||
/// Populated on transition to `.succeeded`. Nil whenever the user
|
||||
/// re-enters the flow (cancel/begin both clear it).
|
||||
var preservedOutcome: PreservedOutcome?
|
||||
|
||||
/// Load the `template.lock.json` for the given project and build a
|
||||
/// removal plan. Moves stage to `.planned` on success.
|
||||
func begin(project: ProjectEntry) {
|
||||
stage = .loading
|
||||
preservedOutcome = nil
|
||||
let uninstaller = uninstaller
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
let plan = try uninstaller.loadUninstallPlan(for: project)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.plan = plan
|
||||
self.stage = .planned
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func confirmUninstall() {
|
||||
guard let plan else { return }
|
||||
stage = .uninstalling
|
||||
let uninstaller = uninstaller
|
||||
// Capture the preservation shape before executing — the plan
|
||||
// itself gets nil'd on success and we want the banner to show
|
||||
// whatever was true at the moment of removal.
|
||||
let outcome = PreservedOutcome(
|
||||
projectDirRemoved: plan.projectDirBecomesEmpty,
|
||||
preservedPaths: plan.extraProjectEntries,
|
||||
projectDir: plan.project.path
|
||||
)
|
||||
Task.detached { [weak self] in
|
||||
do {
|
||||
try uninstaller.uninstall(plan: plan)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.preservedOutcome = outcome
|
||||
self.stage = .succeeded(removed: plan.project)
|
||||
self.plan = nil
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { [weak self] in
|
||||
self?.stage = .failed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
plan = nil
|
||||
preservedOutcome = nil
|
||||
stage = .idle
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Post-install configuration editor. Thin wrapper around the same
|
||||
/// `TemplateConfigSheet` the install flow uses — owns a
|
||||
/// `TemplateConfigEditorViewModel` that loads the cached manifest +
|
||||
/// current values from `<project>/.scarf/`, feeds them to the form,
|
||||
/// and writes the edited values back to `config.json` on commit.
|
||||
///
|
||||
/// Entry points: right-click on the project list (when the project has
|
||||
/// a cached manifest) and a button on the dashboard header (shown
|
||||
/// only when `isConfigurable` is true).
|
||||
struct ConfigEditorSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var viewModel: TemplateConfigEditorViewModel
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
_viewModel = State(
|
||||
initialValue: TemplateConfigEditorViewModel(
|
||||
context: context,
|
||||
project: project
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch viewModel.stage {
|
||||
case .idle, .loading:
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Loading configuration…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 320)
|
||||
.padding()
|
||||
case .editing:
|
||||
if let form = viewModel.formViewModel,
|
||||
let manifest = viewModel.manifest {
|
||||
TemplateConfigSheet(
|
||||
viewModel: form,
|
||||
title: "Configure \(manifest.name)",
|
||||
commitLabel: "Save",
|
||||
project: nil, // edit mode; VM carries the project
|
||||
onCommit: { values in
|
||||
viewModel.save(values: values)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
unexpectedState
|
||||
}
|
||||
case .saving:
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Saving…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 320)
|
||||
.padding()
|
||||
case .succeeded:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
Text("Configuration saved").font(.title2.bold())
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
case .failed(let message):
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Couldn't save").font(.title2.bold())
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
case .notConfigurable:
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No configuration")
|
||||
.font(.title3.bold())
|
||||
Text("This project wasn't installed from a schemaful template.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.task { viewModel.begin() }
|
||||
}
|
||||
|
||||
private var unexpectedState: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Internal state inconsistency — please close and re-open.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.frame(minWidth: 560, minHeight: 280)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The configure form rendered for template install + post-install
|
||||
/// editing. One row per schema field; controls dispatch by field type.
|
||||
/// Commit button returns the finalized values via `onCommit` — in
|
||||
/// install mode the caller stashes them in the install plan; in edit
|
||||
/// mode the caller writes them straight to `<project>/.scarf/config.json`.
|
||||
struct TemplateConfigSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State var viewModel: TemplateConfigViewModel
|
||||
let title: LocalizedStringKey
|
||||
let commitLabel: LocalizedStringKey
|
||||
/// In install mode the caller passes the planned `ProjectEntry`
|
||||
/// (project dir path is the unique key for the Keychain secret).
|
||||
/// In edit mode the VM already holds the project; pass `nil` here.
|
||||
let project: ProjectEntry?
|
||||
let onCommit: ([String: TemplateConfigValue]) -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if viewModel.schema.fields.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No fields",
|
||||
systemImage: "slider.horizontal.3",
|
||||
description: Text("This template has no configuration fields.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, minHeight: 120)
|
||||
} else {
|
||||
ForEach(viewModel.schema.fields) { field in
|
||||
fieldRow(field)
|
||||
}
|
||||
}
|
||||
if let rec = viewModel.schema.modelRecommendation {
|
||||
modelRecommendation(rec)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 480)
|
||||
}
|
||||
|
||||
// MARK: - Header / footer
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title).font(.title2.bold())
|
||||
Text(viewModel.templateId)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
// Caller owns dismissal — this view is used both as a
|
||||
// standalone sheet (ConfigEditorSheet, where the caller
|
||||
// wants dismissal) AND inlined inside the install sheet
|
||||
// (TemplateInstallSheet.configureView, where calling
|
||||
// .dismiss here would tear down the OUTER install sheet
|
||||
// and abort the flow before .planned is reached).
|
||||
onCancel()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(commitLabel) {
|
||||
if let finalized = viewModel.commit(project: project) {
|
||||
onCommit(finalized)
|
||||
}
|
||||
// Same dismissal-is-caller's-responsibility rule as
|
||||
// Cancel — inside the install sheet, onCommit transitions
|
||||
// stage to .planned and the outer view re-renders to
|
||||
// show the preview. In the edit sheet, onCommit
|
||||
// transitions the editor VM and its state machine
|
||||
// handles dismissal via the success view's Done button.
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
// MARK: - Field rows
|
||||
|
||||
@ViewBuilder
|
||||
private func fieldRow(_ field: TemplateConfigField) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
Text(field.label).font(.headline)
|
||||
if field.required {
|
||||
Text("*")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
Spacer()
|
||||
Text(field.type.rawValue)
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if let description = field.description, !description.isEmpty {
|
||||
// Inline markdown so descriptions can include
|
||||
// `[Create one](https://…)`-style links to token
|
||||
// generation pages, **bold** emphasis on important
|
||||
// prerequisites, etc.
|
||||
TemplateMarkdown.inlineText(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
control(for: field)
|
||||
if let err = viewModel.errors[field.key] {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func control(for field: TemplateConfigField) -> some View {
|
||||
switch field.type {
|
||||
case .string:
|
||||
StringControl(
|
||||
value: stringBinding(for: field),
|
||||
placeholder: field.placeholder
|
||||
)
|
||||
case .text:
|
||||
TextControl(value: stringBinding(for: field))
|
||||
case .number:
|
||||
NumberControl(value: numberBinding(for: field))
|
||||
case .bool:
|
||||
BoolControl(label: field.label, value: boolBinding(for: field))
|
||||
case .enum:
|
||||
EnumControl(
|
||||
options: field.options ?? [],
|
||||
value: stringBinding(for: field)
|
||||
)
|
||||
case .list:
|
||||
ListControl(items: listBinding(for: field))
|
||||
case .secret:
|
||||
SecretControl(
|
||||
fieldKey: field.key,
|
||||
placeholder: field.placeholder,
|
||||
viewModel: viewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Model recommendation panel
|
||||
|
||||
private func modelRecommendation(_ rec: TemplateModelRecommendation) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Label("Recommended model", systemImage: "lightbulb")
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(.secondary)
|
||||
Text(rec.preferred).font(.body.monospaced())
|
||||
if let rationale = rec.rationale, !rationale.isEmpty {
|
||||
Text(rationale)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if let alts = rec.alternatives, !alts.isEmpty {
|
||||
Text("Also works: \(alts.joined(separator: ", "))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Scarf doesn't auto-switch your active model. Change it in Settings if you'd like.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.accentColor.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Binding helpers (threading the VM through typed lenses)
|
||||
|
||||
private func stringBinding(for field: TemplateConfigField) -> Binding<String> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .string(let s) = viewModel.values[field.key] { return s }
|
||||
return ""
|
||||
},
|
||||
set: { viewModel.setString(field.key, $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func numberBinding(for field: TemplateConfigField) -> Binding<Double> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .number(let n) = viewModel.values[field.key] { return n }
|
||||
return 0
|
||||
},
|
||||
set: { viewModel.setNumber(field.key, $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func boolBinding(for field: TemplateConfigField) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .bool(let b) = viewModel.values[field.key] { return b }
|
||||
return false
|
||||
},
|
||||
set: { viewModel.setBool(field.key, $0) }
|
||||
)
|
||||
}
|
||||
|
||||
private func listBinding(for field: TemplateConfigField) -> Binding<[String]> {
|
||||
Binding(
|
||||
get: {
|
||||
if case .list(let items) = viewModel.values[field.key] { return items }
|
||||
return []
|
||||
},
|
||||
set: { viewModel.setList(field.key, $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Field controls
|
||||
|
||||
private struct StringControl: View {
|
||||
@Binding var value: String
|
||||
let placeholder: String?
|
||||
var body: some View {
|
||||
TextField(placeholder ?? "", text: $value)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private struct TextControl: View {
|
||||
@Binding var value: String
|
||||
var body: some View {
|
||||
TextEditor(text: $value)
|
||||
.font(.body.monospaced())
|
||||
.frame(minHeight: 80, maxHeight: 160)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(.secondary.opacity(0.3))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NumberControl: View {
|
||||
@Binding var value: Double
|
||||
var body: some View {
|
||||
TextField("", value: $value, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BoolControl: View {
|
||||
let label: String
|
||||
@Binding var value: Bool
|
||||
var body: some View {
|
||||
Toggle(isOn: $value) {
|
||||
Text(value ? "Enabled" : "Disabled")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct EnumControl: View {
|
||||
let options: [TemplateConfigField.EnumOption]
|
||||
@Binding var value: String
|
||||
var body: some View {
|
||||
// Segmented for ≤ 4 options, dropdown otherwise — fits Scarf's
|
||||
// existing settings UI.
|
||||
if options.count <= 4 {
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
} else {
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Variable-length list of string values. Each row is a text field
|
||||
/// with an inline remove button; a + button adds a trailing row.
|
||||
private struct ListControl: View {
|
||||
@Binding var items: [String]
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(items.indices, id: \.self) { i in
|
||||
HStack(spacing: 6) {
|
||||
TextField("", text: Binding(
|
||||
get: { i < items.count ? items[i] : "" },
|
||||
set: { newValue in
|
||||
guard i < items.count else { return }
|
||||
items[i] = newValue
|
||||
}
|
||||
))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Button {
|
||||
guard i < items.count else { return }
|
||||
items.remove(at: i)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(items.count <= 1)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
items.append("")
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus.circle")
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Secret fields never echo the previously-stored value back. Instead
|
||||
/// we render "(unchanged)" when a Keychain ref already exists and let
|
||||
/// the user type over it if they want to replace. Empty input in edit
|
||||
/// mode signals "remove this secret entirely."
|
||||
private struct SecretControl: View {
|
||||
let fieldKey: String
|
||||
let placeholder: String?
|
||||
@Bindable var viewModel: TemplateConfigViewModel
|
||||
|
||||
@State private var typedValue: String = ""
|
||||
@State private var isRevealed: Bool = false
|
||||
|
||||
private var hasStoredRef: Bool {
|
||||
if case .keychainRef = viewModel.values[fieldKey] { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Group {
|
||||
if isRevealed {
|
||||
TextField(placeholder ?? "", text: $typedValue)
|
||||
} else {
|
||||
SecureField(placeholder ?? "", text: $typedValue)
|
||||
}
|
||||
}
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onChange(of: typedValue) { _, new in
|
||||
viewModel.setSecret(fieldKey, new)
|
||||
}
|
||||
Button {
|
||||
isRevealed.toggle()
|
||||
} label: {
|
||||
Image(systemName: isRevealed ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help(isRevealed ? "Hide" : "Show while typing")
|
||||
}
|
||||
if hasStoredRef && typedValue.isEmpty {
|
||||
Text("Saved in Keychain — leave empty to keep the stored value.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !typedValue.isEmpty {
|
||||
Text("Will be saved to the Keychain on commit.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
/// Author-facing sheet for exporting an existing project as a
|
||||
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
|
||||
/// pick which skills/cron jobs to include, save via NSSavePanel.
|
||||
struct TemplateExportSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State var viewModel: TemplateExporterViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
switch viewModel.stage {
|
||||
case .idle:
|
||||
form
|
||||
case .exporting:
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
Text("Building template…")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .succeeded(let path):
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
Text("Exported").font(.title2.bold())
|
||||
Text(path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
HStack {
|
||||
Button("Show in Finder") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: path)])
|
||||
}
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
case .failed(let message):
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Export Failed").font(.title2.bold())
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 620, minHeight: 560)
|
||||
.padding()
|
||||
.task { viewModel.load() }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var form: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Export \"\(viewModel.project.name)\" as Template")
|
||||
.font(.title2.bold())
|
||||
metadataGroup
|
||||
Divider()
|
||||
requiredFilesGroup
|
||||
Divider()
|
||||
instructionsGroup
|
||||
Divider()
|
||||
skillsGroup
|
||||
Divider()
|
||||
cronGroup
|
||||
Divider()
|
||||
memoryGroup
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
HStack {
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Export…") { runExport() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canExport)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var metadataGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Metadata").font(.headline)
|
||||
LabeledContent("Template ID") {
|
||||
TextField("owner/name", text: $viewModel.templateId)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Display Name") {
|
||||
TextField("", text: $viewModel.templateName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Version") {
|
||||
TextField("1.0.0", text: $viewModel.templateVersion)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Description") {
|
||||
TextField("One-line pitch", text: $viewModel.templateDescription, axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Author") {
|
||||
TextField("Your name", text: $viewModel.authorName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Author URL") {
|
||||
TextField("https://…", text: $viewModel.authorURL)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Category") {
|
||||
TextField("e.g. productivity", text: $viewModel.category)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
LabeledContent("Tags (comma-separated)") {
|
||||
TextField("focus, timer", text: $viewModel.tags)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var requiredFilesGroup: some View {
|
||||
let plan = viewModel.previewPlan()
|
||||
return VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Required Files").font(.headline)
|
||||
check(label: "dashboard.json (\(plan.projectDir)/.scarf/dashboard.json)", ok: plan.dashboardPresent)
|
||||
check(label: "README.md (\(plan.projectDir)/README.md)", ok: plan.readmePresent)
|
||||
check(label: "AGENTS.md (\(plan.projectDir)/AGENTS.md)", ok: plan.agentsMdPresent)
|
||||
}
|
||||
}
|
||||
|
||||
private var instructionsGroup: some View {
|
||||
let plan = viewModel.previewPlan()
|
||||
return VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Agent-specific instructions (optional)").font(.headline)
|
||||
if plan.instructionFiles.isEmpty {
|
||||
Text("No per-agent instruction files found in the project root.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(plan.instructionFiles, id: \.self) { file in
|
||||
Label(file, systemImage: "doc.plaintext")
|
||||
.font(.callout)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var skillsGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Include Skills").font(.headline)
|
||||
if viewModel.availableSkills.isEmpty {
|
||||
Text("No skills found.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.availableSkills) { skill in
|
||||
Toggle(isOn: Binding(
|
||||
get: { viewModel.includeSkillIds.contains(skill.id) },
|
||||
set: { on in
|
||||
if on { viewModel.includeSkillIds.insert(skill.id) }
|
||||
else { viewModel.includeSkillIds.remove(skill.id) }
|
||||
}
|
||||
)) {
|
||||
Text(skill.id).font(.callout.monospaced())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var cronGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Include Cron Jobs").font(.headline)
|
||||
if viewModel.availableCronJobs.isEmpty {
|
||||
Text("No cron jobs found.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(viewModel.availableCronJobs) { job in
|
||||
Toggle(isOn: Binding(
|
||||
get: { viewModel.includeCronJobIds.contains(job.id) },
|
||||
set: { on in
|
||||
if on { viewModel.includeCronJobIds.insert(job.id) }
|
||||
else { viewModel.includeCronJobIds.remove(job.id) }
|
||||
}
|
||||
)) {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(job.name).font(.callout)
|
||||
Text(job.schedule.display ?? job.schedule.expression ?? job.schedule.kind)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var memoryGroup: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Memory Appendix (optional)").font(.headline)
|
||||
Text("Markdown that will be appended to the installer's MEMORY.md, wrapped in template-specific markers so it can be removed cleanly later.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
TextEditor(text: $viewModel.memoryAppendix)
|
||||
.font(.callout.monospaced())
|
||||
.frame(minHeight: 80, maxHeight: 160)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.stroke(.secondary.opacity(0.4))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func check(label: String, ok: Bool) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: ok ? "checkmark.circle.fill" : "xmark.circle.fill")
|
||||
.foregroundStyle(ok ? .green : .red)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(ok ? .primary : .secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var canExport: Bool {
|
||||
let plan = viewModel.previewPlan()
|
||||
return plan.dashboardPresent
|
||||
&& plan.readmePresent
|
||||
&& plan.agentsMdPresent
|
||||
&& !viewModel.templateId.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !viewModel.templateName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !viewModel.templateVersion.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
&& !viewModel.templateDescription.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
|
||||
private func runExport() {
|
||||
let panel = NSSavePanel()
|
||||
panel.allowedContentTypes = [.zip]
|
||||
panel.nameFieldStringValue = ProjectTemplateExporter.slugify(viewModel.templateName) + ".scarftemplate"
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.export(to: url.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Preview-and-confirm sheet for installing a `.scarftemplate`. Honest
|
||||
/// accounting: shows every file that will be written, every cron job that
|
||||
/// will be registered, and the memory diff — nothing gets written until the
|
||||
/// user clicks Install.
|
||||
struct TemplateInstallSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State var viewModel: TemplateInstallerViewModel
|
||||
let onCompleted: (ProjectEntry) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch viewModel.stage {
|
||||
case .idle:
|
||||
idleView
|
||||
case .fetching(let src):
|
||||
progress("Downloading from \(src)…")
|
||||
case .inspecting:
|
||||
progress("Inspecting template…")
|
||||
case .awaitingParentDirectory:
|
||||
pickParentView
|
||||
case .awaitingConfig:
|
||||
configureView
|
||||
case .planned:
|
||||
if let plan = viewModel.plan {
|
||||
plannedView(plan: plan)
|
||||
} else {
|
||||
progress("Preparing…")
|
||||
}
|
||||
case .installing:
|
||||
progress("Installing…")
|
||||
case .succeeded(let entry):
|
||||
successView(entry: entry)
|
||||
case .failed(let message):
|
||||
failureView(message: message)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 640, minHeight: 520)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Stages
|
||||
|
||||
private var idleView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("No template loaded.")
|
||||
.font(.headline)
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func progress(_ label: LocalizedStringKey) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var pickParentView: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if let manifest = viewModel.inspection?.manifest {
|
||||
manifestHeader(manifest)
|
||||
Divider()
|
||||
}
|
||||
Text("Where should this project live?")
|
||||
.font(.headline)
|
||||
Text("Scarf will create a new folder inside the directory you pick, named after the template id.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Choose Folder…") { chooseParentDirectory() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure step for schemaful templates. Inlines
|
||||
/// `TemplateConfigSheet` into the install flow rather than pushing
|
||||
/// a second sheet on top — keeps the user in one window. The
|
||||
/// nested VM is created freshly each time `.awaitingConfig` is
|
||||
/// entered so a Cancel + retry doesn't carry stale form state.
|
||||
@ViewBuilder
|
||||
private var configureView: some View {
|
||||
if let plan = viewModel.plan,
|
||||
let schema = plan.configSchema,
|
||||
let manifest = viewModel.inspection?.manifest {
|
||||
TemplateConfigSheet(
|
||||
viewModel: TemplateConfigViewModel(
|
||||
schema: schema,
|
||||
templateId: manifest.id,
|
||||
templateSlug: manifest.slug,
|
||||
initialValues: plan.configValues,
|
||||
mode: .install
|
||||
),
|
||||
title: "Configure \(manifest.name)",
|
||||
commitLabel: "Continue",
|
||||
project: ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir),
|
||||
onCommit: { values in
|
||||
viewModel.submitConfig(values: values)
|
||||
},
|
||||
onCancel: {
|
||||
viewModel.cancelConfig()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
progress("Preparing…")
|
||||
}
|
||||
}
|
||||
|
||||
private func plannedView(plan: TemplateInstallPlan) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
manifestHeader(plan.manifest)
|
||||
.padding(.bottom, 8)
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
projectFilesSection(plan: plan)
|
||||
if plan.skillsNamespaceDir != nil {
|
||||
skillsSection(plan: plan)
|
||||
}
|
||||
if !plan.cronJobs.isEmpty {
|
||||
cronSection(plan: plan)
|
||||
}
|
||||
if plan.memoryAppendix != nil {
|
||||
memorySection(plan: plan)
|
||||
}
|
||||
if let schema = plan.configSchema, !schema.isEmpty {
|
||||
configurationSection(plan: plan, schema: schema)
|
||||
}
|
||||
readmeSection
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Text("\(plan.totalWriteCount) changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Install") { viewModel.confirmInstall() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func manifestHeader(_ manifest: ProjectTemplateManifest) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(manifest.name).font(.title2.bold())
|
||||
Text("v\(manifest.version)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(manifest.id)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
// Inline-only markdown — descriptions are a sentence or two;
|
||||
// bold/italic/code/links are all that reasonable template
|
||||
// authors use there.
|
||||
TemplateMarkdown.inlineText(manifest.description)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
if let author = manifest.author {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.crop.circle")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(author.name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let url = author.url, let parsed = URL(string: url) {
|
||||
Link(parsed.host ?? url, destination: parsed)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func projectFilesSection(plan: TemplateInstallPlan) -> some View {
|
||||
section(title: "New project directory", subtitle: plan.projectDir) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(plan.projectFiles, id: \.destinationPath) { copy in
|
||||
fileRow(label: copy.destinationPath, systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skillsSection(plan: TemplateInstallPlan) -> some View {
|
||||
section(
|
||||
title: "Skills (namespaced, safe to remove later)",
|
||||
subtitle: plan.skillsNamespaceDir
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(plan.skillsFiles, id: \.destinationPath) { copy in
|
||||
fileRow(label: copy.destinationPath, systemImage: "puzzlepiece")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
||||
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(plan.cronJobs, id: \.name) { job in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(job.name).font(.callout.monospaced())
|
||||
Text("schedule: \(job.schedule)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
// Prompt preview — disclosed in an expandable
|
||||
// group so the preview stays compact when the
|
||||
// user doesn't care to read it. Markdown-rendered
|
||||
// so prompts that include `code`, **bold**, or
|
||||
// enumerated steps look right. Tokens like
|
||||
// {{PROJECT_DIR}} are still visible here — they
|
||||
// get substituted when the installer calls
|
||||
// `hermes cron create`.
|
||||
if let prompt = job.prompt, !prompt.isEmpty {
|
||||
DisclosureGroup("Prompt") {
|
||||
ScrollView {
|
||||
TemplateMarkdown.render(prompt)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 140)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.4))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.leading, 26)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func memorySection(plan: TemplateInstallPlan) -> some View {
|
||||
section(title: "Memory appendix", subtitle: plan.memoryPath) {
|
||||
ScrollView {
|
||||
Text(plan.memoryAppendix ?? "")
|
||||
.font(.caption.monospaced())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.frame(maxHeight: 160)
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration values the user entered in the configure step.
|
||||
/// Secrets display masked so the preview never echoes a freshly
|
||||
/// typed API key back on screen.
|
||||
private func configurationSection(plan: TemplateInstallPlan, schema: TemplateConfigSchema) -> some View {
|
||||
section(title: "Configuration", subtitle: "written to \(plan.projectDir)/.scarf/config.json") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(schema.fields) { field in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(field.key)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(minWidth: 120, alignment: .leading)
|
||||
Text(displayValue(for: field, in: plan.configValues))
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line display form for a value in the preview. Secrets are
|
||||
/// always masked; lists show a count + first entry; strings are
|
||||
/// truncated by `.lineLimit(1)` at the view level.
|
||||
private func displayValue(
|
||||
for field: TemplateConfigField,
|
||||
in values: [String: TemplateConfigValue]
|
||||
) -> String {
|
||||
switch field.type {
|
||||
case .secret:
|
||||
return values[field.key] == nil ? "(not set)" : "••••••• (Keychain)"
|
||||
case .list:
|
||||
if case .list(let items) = values[field.key] {
|
||||
if items.isEmpty { return "(none)" }
|
||||
if items.count == 1 { return items[0] }
|
||||
return "\(items[0]) + \(items.count - 1) more"
|
||||
}
|
||||
return "(none)"
|
||||
default:
|
||||
return values[field.key]?.displayString ?? "(not set)"
|
||||
}
|
||||
}
|
||||
|
||||
private var readmeSection: some View {
|
||||
Group {
|
||||
// The body is preloaded in the VM off MainActor when inspection
|
||||
// completes — no sync file I/O during View body evaluation.
|
||||
if let readme = viewModel.readmeBody {
|
||||
section(title: "README", subtitle: nil) {
|
||||
ScrollView {
|
||||
TemplateMarkdown.render(readme)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxHeight: 260)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section<Content: View>(title: String, subtitle: String?, @ViewBuilder content: () -> Content) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
content()
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func fileRow(label: String, systemImage: String) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
}
|
||||
}
|
||||
|
||||
private func successView(entry: ProjectEntry) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
Text("Installed \(entry.name)")
|
||||
.font(.title2.bold())
|
||||
Text(entry.path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Open Project") {
|
||||
onCompleted(entry)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func failureView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Install Failed").font(.title2.bold())
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func chooseParentDirectory() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseDirectories = true
|
||||
panel.canChooseFiles = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = String(localized: "Choose Parent Folder")
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
viewModel.pickParentDirectory(url.path)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
/// Minimal markdown renderer used by the template install/config UIs.
|
||||
///
|
||||
/// SwiftUI `Text` has built-in inline-markdown support via
|
||||
/// `AttributedString(markdown:)` — bold, italic, inline code, links.
|
||||
/// That's enough for field descriptions + template taglines. For
|
||||
/// longer content (README preview, full doc blocks), this helper adds
|
||||
/// block-level handling: lines starting with `#`/`##`/`###` render
|
||||
/// as bigger bold text; lines starting with `-`/`*`/`1.` render as
|
||||
/// list items with a hanging indent; fenced ``` ``` blocks render as
|
||||
/// monospaced; blank lines become paragraph breaks.
|
||||
///
|
||||
/// Scope is intentionally small. This isn't a full CommonMark
|
||||
/// renderer — it's "enough markdown to make template READMEs look
|
||||
/// right in the install sheet without pulling in a dependency." If
|
||||
/// the set of templates needs more over time, evolve this file or
|
||||
/// graduate to a proper library.
|
||||
enum TemplateMarkdown {
|
||||
|
||||
/// Render a markdown source string as a SwiftUI view. Preserves
|
||||
/// reading order and approximate visual hierarchy. Safe with
|
||||
/// untrusted input — we never execute HTML or scripts.
|
||||
@ViewBuilder
|
||||
static func render(_ source: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
let blocks = parse(source)
|
||||
ForEach(blocks.indices, id: \.self) { i in
|
||||
block(blocks[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline-only markdown (bold/italic/code/links) as a single
|
||||
/// `Text`. Use for short strings where block structure doesn't
|
||||
/// apply — field labels, one-line descriptions.
|
||||
static func inlineText(_ source: String) -> Text {
|
||||
if let attr = try? AttributedString(
|
||||
markdown: source,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return Text(attr)
|
||||
}
|
||||
return Text(source)
|
||||
}
|
||||
|
||||
// MARK: - Block model
|
||||
|
||||
fileprivate enum Block {
|
||||
case paragraph(AttributedString)
|
||||
case heading(level: Int, text: AttributedString)
|
||||
case bullet(AttributedString)
|
||||
case numbered(index: Int, text: AttributedString)
|
||||
case code(String)
|
||||
}
|
||||
|
||||
// MARK: - Parser
|
||||
|
||||
fileprivate static func parse(_ source: String) -> [Block] {
|
||||
var blocks: [Block] = []
|
||||
var lines = source.components(separatedBy: "\n")
|
||||
var i = 0
|
||||
while i < lines.count {
|
||||
let line = lines[i]
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
// Fenced code block.
|
||||
if trimmed.hasPrefix("```") {
|
||||
var body: [String] = []
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
let inner = lines[i]
|
||||
if inner.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||
i += 1
|
||||
break
|
||||
}
|
||||
body.append(inner)
|
||||
i += 1
|
||||
}
|
||||
blocks.append(.code(body.joined(separator: "\n")))
|
||||
continue
|
||||
}
|
||||
|
||||
// Heading.
|
||||
if let headingMatch = trimmed.firstMatch(of: /^(#{1,6})\s+(.*)$/) {
|
||||
let level = (headingMatch.1).count
|
||||
let text = String(headingMatch.2)
|
||||
blocks.append(.heading(level: level, text: renderInline(text)))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Bullet list.
|
||||
if let bulletMatch = line.firstMatch(of: /^\s*[-*]\s+(.*)$/) {
|
||||
let text = String(bulletMatch.1)
|
||||
blocks.append(.bullet(renderInline(text)))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Numbered list.
|
||||
if let numMatch = line.firstMatch(of: /^\s*(\d+)\.\s+(.*)$/) {
|
||||
let index = Int(String(numMatch.1)) ?? 1
|
||||
let text = String(numMatch.2)
|
||||
blocks.append(.numbered(index: index, text: renderInline(text)))
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Blank line — skip.
|
||||
if trimmed.isEmpty {
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Paragraph — collect contiguous non-blank lines that
|
||||
// aren't headings/lists/fences into one paragraph block.
|
||||
var paragraphLines: [String] = [line]
|
||||
i += 1
|
||||
while i < lines.count {
|
||||
let next = lines[i]
|
||||
let nextTrim = next.trimmingCharacters(in: .whitespaces)
|
||||
if nextTrim.isEmpty { break }
|
||||
if nextTrim.hasPrefix("```") { break }
|
||||
if nextTrim.firstMatch(of: /^#{1,6}\s/) != nil { break }
|
||||
if next.firstMatch(of: /^\s*[-*]\s+/) != nil { break }
|
||||
if next.firstMatch(of: /^\s*\d+\.\s+/) != nil { break }
|
||||
paragraphLines.append(next)
|
||||
i += 1
|
||||
}
|
||||
let joined = paragraphLines.joined(separator: " ")
|
||||
blocks.append(.paragraph(renderInline(joined)))
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
/// Parse inline markdown (bold, italic, inline code, links) into
|
||||
/// an AttributedString. Falls back to plain text on parse failure.
|
||||
fileprivate static func renderInline(_ source: String) -> AttributedString {
|
||||
if let attr = try? AttributedString(
|
||||
markdown: source,
|
||||
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||
) {
|
||||
return attr
|
||||
}
|
||||
return AttributedString(source)
|
||||
}
|
||||
|
||||
// MARK: - Rendering
|
||||
|
||||
@ViewBuilder
|
||||
fileprivate static func block(_ b: Block) -> some View {
|
||||
switch b {
|
||||
case .paragraph(let text):
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
case .heading(let level, let text):
|
||||
headingText(text: text, level: level)
|
||||
case .bullet(let text):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("•").font(.callout)
|
||||
Text(text).font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
case .numbered(let index, let text):
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Text("\(index).").font(.callout.monospacedDigit())
|
||||
Text(text).font(.callout)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
case .code(let src):
|
||||
Text(src)
|
||||
.font(.caption.monospaced())
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
fileprivate static func headingText(text: AttributedString, level: Int) -> some View {
|
||||
switch level {
|
||||
case 1: Text(text).font(.title2.bold()).padding(.top, 8)
|
||||
case 2: Text(text).font(.title3.bold()).padding(.top, 6)
|
||||
case 3: Text(text).font(.headline).padding(.top, 4)
|
||||
default: Text(text).font(.subheadline.bold()).padding(.top, 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Preview-and-confirm sheet for uninstalling a template-installed
|
||||
/// project. Symmetric with the install sheet: lists every file, cron
|
||||
/// job, and memory block that will be removed BEFORE anything happens.
|
||||
struct TemplateUninstallSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State var viewModel: TemplateUninstallerViewModel
|
||||
/// Called on success with the project that was removed. Parent uses
|
||||
/// this to refresh its projects list and clear any selection.
|
||||
let onCompleted: (ProjectEntry) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch viewModel.stage {
|
||||
case .idle:
|
||||
idleView
|
||||
case .loading:
|
||||
progress("Reading template.lock.json…")
|
||||
case .planned:
|
||||
if let plan = viewModel.plan {
|
||||
plannedView(plan: plan)
|
||||
} else {
|
||||
progress("Preparing…")
|
||||
}
|
||||
case .uninstalling:
|
||||
progress("Removing…")
|
||||
case .succeeded(let removed):
|
||||
successView(removed: removed)
|
||||
case .failed(let message):
|
||||
failureView(message: message)
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 620, minHeight: 480)
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Stages
|
||||
|
||||
private var idleView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text("No template loaded.")
|
||||
.font(.headline)
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func progress(_ label: LocalizedStringKey) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
Text(label)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func plannedView(plan: TemplateUninstallPlan) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
header(plan: plan)
|
||||
.padding(.bottom, 8)
|
||||
Divider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
projectFilesSection(plan: plan)
|
||||
if plan.skillsNamespaceDir != nil {
|
||||
skillsSection(plan: plan)
|
||||
}
|
||||
cronSection(plan: plan)
|
||||
memorySection(plan: plan)
|
||||
registrySection(plan: plan)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
Divider()
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Text("\(plan.totalRemoveCount) changes")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Remove") { viewModel.confirmUninstall() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func header(plan: TemplateUninstallPlan) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Remove “\(plan.lock.templateName)”").font(.title2.bold())
|
||||
Text("v\(plan.lock.templateVersion)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Text(plan.lock.templateId)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Installed \(plan.lock.installedAt)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func projectFilesSection(plan: TemplateUninstallPlan) -> some View {
|
||||
section(title: "Project directory", subtitle: plan.project.path) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(plan.projectFilesToRemove, id: \.self) { path in
|
||||
fileRow(
|
||||
label: path,
|
||||
systemImage: "minus.circle",
|
||||
color: .red,
|
||||
tag: "remove"
|
||||
)
|
||||
}
|
||||
ForEach(plan.projectFilesAlreadyGone, id: \.self) { path in
|
||||
fileRow(
|
||||
label: path,
|
||||
systemImage: "questionmark.circle",
|
||||
color: .secondary,
|
||||
tag: "already gone"
|
||||
)
|
||||
}
|
||||
ForEach(plan.extraProjectEntries, id: \.self) { path in
|
||||
fileRow(
|
||||
label: path,
|
||||
systemImage: "lock.shield",
|
||||
color: .green,
|
||||
tag: "keep (not installed by template)"
|
||||
)
|
||||
}
|
||||
if plan.projectDirBecomesEmpty {
|
||||
Text("Project directory will also be removed (nothing user-owned left inside).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
} else if !plan.extraProjectEntries.isEmpty {
|
||||
Text("Project directory stays — it still holds files you created after install.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func skillsSection(plan: TemplateUninstallPlan) -> some View {
|
||||
section(
|
||||
title: "Skills",
|
||||
subtitle: plan.skillsNamespaceDir
|
||||
) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
Text("Remove the entire namespace dir recursively")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cronSection(plan: TemplateUninstallPlan) -> some View {
|
||||
section(
|
||||
title: "Cron jobs",
|
||||
subtitle: plan.cronJobsToRemove.isEmpty && plan.cronJobsAlreadyGone.isEmpty
|
||||
? "none"
|
||||
: nil
|
||||
) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(plan.cronJobsToRemove, id: \.id) { job in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(job.name).font(.callout.monospaced())
|
||||
Text(job.id)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ForEach(plan.cronJobsAlreadyGone, id: \.self) { name in
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "questionmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
Text("\(name) — already gone")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func memorySection(plan: TemplateUninstallPlan) -> some View {
|
||||
if plan.memoryBlockPresent {
|
||||
section(title: "Memory block", subtitle: plan.memoryPath) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
Text("Strip the template's begin/end block, preserve everything else in MEMORY.md")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
} else if plan.lock.memoryBlockId != nil {
|
||||
section(title: "Memory block", subtitle: nil) {
|
||||
Text("A memory block was recorded in the lock but is no longer present in MEMORY.md — skipping.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registrySection(plan: TemplateUninstallPlan) -> some View {
|
||||
section(title: "Projects registry", subtitle: nil) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "minus.circle")
|
||||
.foregroundStyle(.red)
|
||||
.font(.caption)
|
||||
Text("Remove \"\(plan.project.name)\" from Scarf's project list")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func section<Content: View>(
|
||||
title: LocalizedStringKey,
|
||||
subtitle: String?,
|
||||
@ViewBuilder content: () -> Content
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title).font(.headline)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
content()
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func fileRow(label: String, systemImage: String, color: Color, tag: LocalizedStringKey) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: systemImage)
|
||||
.foregroundStyle(color)
|
||||
.font(.caption)
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
Spacer()
|
||||
Text(tag)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private func successView(removed: ProjectEntry) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.green)
|
||||
Text("Removed \(removed.name)")
|
||||
.font(.title2.bold())
|
||||
|
||||
// Preserved-files banner. Only renders when the project dir
|
||||
// stayed and at least one file was left behind — that's the
|
||||
// case the user keeps getting surprised by ("I uninstalled
|
||||
// but my project folder is still there?"). Explicit
|
||||
// explanation + file list makes it obvious the files the
|
||||
// user (or the cron job) created are intentionally kept.
|
||||
if let outcome = viewModel.preservedOutcome,
|
||||
outcome.projectDirRemoved == false,
|
||||
outcome.preservedPaths.isEmpty == false {
|
||||
preservedFilesBanner(outcome: outcome)
|
||||
}
|
||||
|
||||
Button("Done") {
|
||||
onCompleted(removed)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
/// Orange informational banner listing the files the uninstaller
|
||||
/// left in the project directory. Caps the visible list at 8 rows
|
||||
/// with a "+N more…" tail so a long log (many runs = many status
|
||||
/// file entries) doesn't blow out the sheet height.
|
||||
private func preservedFilesBanner(
|
||||
outcome: TemplateUninstallerViewModel.PreservedOutcome
|
||||
) -> some View {
|
||||
let visible = Array(outcome.preservedPaths.prefix(8))
|
||||
let overflow = outcome.preservedPaths.count - visible.count
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "folder.badge.questionmark")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Project folder kept")
|
||||
.font(.headline)
|
||||
}
|
||||
Text("These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(visible, id: \.self) { path in
|
||||
Text(path)
|
||||
.font(.caption.monospaced())
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
}
|
||||
if overflow > 0 {
|
||||
Text("+ \(overflow) more…")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("Delete \(outcome.projectDir) from Finder if you don't need these files anymore.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: 520, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.orange.opacity(0.10))
|
||||
)
|
||||
}
|
||||
|
||||
private func failureView(message: String) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Uninstall Failed").font(.title2.bold())
|
||||
Text(message)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Button("Close") {
|
||||
viewModel.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,56 @@
|
||||
<integer>86400</integer>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<false/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.scarf.url</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>scarf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.scarf.template</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Scarf Project Template</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.zip-archive</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>scarftemplate</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<array>
|
||||
<string>application/vnd.scarf.template+zip</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Scarf Project Template</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.scarf.template</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleDisplayName" : {
|
||||
"comment" : "Bundle display name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Scarf"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "scarf"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSHumanReadableCopyright" : {
|
||||
"comment" : "Copyright (human-readable)",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSMicrophoneUsageDescription" : {
|
||||
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
|
||||
"extractionState" : "manual",
|
||||
@@ -48,7 +84,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scarf Project Template" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
}
|
||||
+15790
-15274
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,23 @@ struct ScarfApp: App {
|
||||
// covers the case where the user added a server in
|
||||
// another window since this one last opened.
|
||||
.onAppear { liveRegistry.rebuild() }
|
||||
// scarf://install?url=… deep-link handler. Stages the
|
||||
// URL on the process-wide router; ProjectsView picks it
|
||||
// up and presents the install sheet. Activating the
|
||||
// app here ensures a cold launch from a browser click
|
||||
// surfaces the sheet without the user having to click
|
||||
// into Scarf first.
|
||||
.onOpenURL { url in
|
||||
TemplateURLRouter.shared.handle(url)
|
||||
NSApplication.shared.activate()
|
||||
}
|
||||
} else {
|
||||
// MissingServerView is a dead-end "server was removed" pane
|
||||
// with no ProjectsView — so no observer of the router's
|
||||
// pendingInstallURL exists in this window. Routing a
|
||||
// scarf://install URL here would silently drop it. Leave
|
||||
// onOpenURL off this branch; ContextBoundRoot windows in
|
||||
// the same app instance will still handle it.
|
||||
MissingServerView(removedServerID: serverID)
|
||||
.environment(registry)
|
||||
.environment(updater)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user