feat: project templates v1 (install + uninstall + export + URL handler)

Shareable `.scarftemplate` bundle format lets users package a project's
dashboard, cross-agent AGENTS.md, optional per-agent instruction shims,
optional namespaced skills, optional tagged cron jobs, and an optional
memory appendix into a single zip that anyone can install with one click.

Core:
- Bundle format + manifest schema v1 (template.json with contents claim
  cross-checked against zip entries to prevent hidden files).
- ProjectTemplateService inspects + validates + builds an install plan.
- ProjectTemplateInstaller executes plans with transport-routed I/O so
  the v1 local-only flow extends cleanly to remote ServerContexts later.
- ProjectTemplateExporter builds bundles from existing projects with
  user-selected skills + cron jobs.
- ProjectTemplateUninstaller reverses installs using template.lock.json.
  Only lock-tracked files are removed; user-added files are preserved.

UI:
- Templates menu in Projects toolbar: Install from File, Install from
  URL, Export as Template.
- Preview-and-confirm sheets for install, uninstall, and export with
  full diff of what will be written/removed before anything runs.
- Right-click context menu on project list + dashboard header button
  for uninstall (only shown when template.lock.json exists).

Deep link + file associations:
- scarf:// URL scheme registered; onOpenURL in scarfApp.swift routes
  scarf://install?url=https://... and file:// URLs for .scarftemplate
  files to the install sheet.
- Custom UTType com.scarf.template registered so Finder shows the file
  with a Scarf icon and double-click opens the install preview.
- Cold-launch race fix: .task picks up any URL staged on the router
  before the onChange observer was installed.

Safety:
- Never writes to config.yaml, auth.json, sessions, or credentials.
- Cron jobs ship paused with a [tmpl:<id>] name prefix.
- Skills install to a namespaced ~/.hermes/skills/templates/<slug>/ dir
  so they never collide with user-authored skills.
- Memory appendix is wrapped in scarf-template:<id>:begin/end markers
  for clean removal during uninstall.
- Download cap: 50 MB for URL-fetched templates, enforced on the actual
  on-disk file size after download so chunked transfers can't bypass it.

Tests: 22 tests in 7 suites cover manifest parsing, claim verification,
URL routing (scarf:// + file://), end-to-end install and uninstall
against a minimal bundle (projects registry is snapshotted + restored),
user-added file preservation, and exporter round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-22 01:03:37 +01:00
parent 7311320bfd
commit c800b93804
20 changed files with 19505 additions and 15276 deletions
@@ -0,0 +1,282 @@
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
/// 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?
}
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
/// Convenience: total number of writes (files + cron jobs + optional
/// memory append + registry append). Displayed in the preview sheet.
nonisolated var totalWriteCount: Int {
projectFiles.count + skillsFiles.count + cronJobs.count + (memoryAppendix == nil ? 0 : 1) + 1
}
}
/// 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?
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"
}
}
// 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,301 @@
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"))
}
// Manifest claims exactly what we just wrote
let manifest = ProjectTemplateManifest(
schemaVersion: 1,
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
)
)
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)
}
}
/// 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,209 @@
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 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: - Skills
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
guard let namespaceDir = plan.skillsNamespaceDir else { return }
let transport = context.makeTransport()
try transport.createDirectory(namespaceDir)
for copy in plan.skillsFiles {
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
let data = try Data(contentsOf: URL(fileURLWithPath: source))
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(copy.destinationPath, data: data)
}
}
// MARK: - Memory
nonisolated private func appendMemoryIfNeeded(plan: TemplateInstallPlan) throws {
guard let appendix = plan.memoryAppendix else { return }
let transport = context.makeTransport()
let existing = (try? transport.readFile(plan.memoryPath)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
let combined = existing + appendix
guard let data = combined.data(using: .utf8) else {
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
}
let parent = (plan.memoryPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(plan.memoryPath, data: data)
}
// MARK: - Cron
/// Create each cron job via `hermes cron create`, then immediately pause
/// it (Hermes creates jobs enabled). Returns the list of resolved job
/// names, which is what the lock file records we don't know the job
/// ids without parsing the create output, but the name is enough to
/// find + remove them later.
nonisolated private func createCronJobs(plan: TemplateInstallPlan) throws -> [String] {
guard !plan.cronJobs.isEmpty else { return [] }
let existingBefore = Set(HermesFileService(context: context).loadCronJobs().map(\.id))
var createdNames: [String] = []
for job in plan.cronJobs {
var args = ["cron", "create", "--name", job.name]
if let deliver = job.deliver, !deliver.isEmpty { args += ["--deliver", deliver] }
if let repeatCount = job.repeatCount { args += ["--repeat", String(repeatCount)] }
for skill in job.skills ?? [] where !skill.isEmpty {
args += ["--skill", skill]
}
args.append(job.schedule)
if let prompt = job.prompt, !prompt.isEmpty {
args.append(prompt)
}
let (output, exit) = context.runHermes(args)
guard exit == 0 else {
throw ProjectTemplateError.cronCreateFailed(job: job.name, output: output)
}
createdNames.append(job.name)
}
// Diff the current job set against the snapshot we took before
// creating anything new belongs to this install and gets paused.
// We pause by id (not name) because `cron pause` takes an id.
let currentJobs = HermesFileService(context: context).loadCronJobs()
let newJobs = currentJobs.filter { !existingBefore.contains($0.id) && createdNames.contains($0.name) }
for job in newJobs {
let (_, exit) = context.runHermes(["cron", "pause", job.id])
if exit != 0 {
Self.logger.warning("couldn't pause newly-created cron job \(job.id, privacy: .public) — leaving enabled")
}
}
return createdNames
}
// MARK: - Registry
nonisolated private func registerProject(plan: TemplateInstallPlan) throws -> ProjectEntry {
let service = ProjectDashboardService(context: context)
var registry = service.loadRegistry()
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
registry.projects.append(entry)
service.saveRegistry(registry)
return entry
}
// MARK: - Lock file
nonisolated private func writeLockFile(
plan: TemplateInstallPlan,
cronJobNames: [String]
) throws {
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
)
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,437 @@
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)
}
guard manifest.schemaVersion == 1 else {
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
}
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)
)
}
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)
)
}
// 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)"
)
}
}
/// 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,301 @@
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)
}
// 5. Projects registry remove the entry by path (more stable
// than name: user may have renamed the project in the UI).
let dashboardService = ProjectDashboardService(context: context)
var registry = dashboardService.loadRegistry()
registry.projects.removeAll { $0.path == plan.project.path }
dashboardService.saveRegistry(registry)
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
}
// MARK: - Helpers
nonisolated private func lockPath(for project: ProjectEntry) -> String {
project.path + "/.scarf/template.lock.json"
}
/// Walk the project dir and return the absolute paths of every entry
/// not in `trackedPaths`. `.scarf/` (and its remaining contents after
/// the lock is recorded) is filtered out because the installer owns
/// that directory entirely if the user dropped a file into it,
/// that's on them, but the common case is that `.scarf/` only holds
/// our dashboard.json + template.lock.json.
nonisolated private func enumerateProjectDirExtras(
projectDir: String,
trackedPaths: Set<String>,
transport: any ServerTransport
) throws -> [String] {
guard transport.fileExists(projectDir) else { return [] }
var extras: [String] = []
let entries: [String]
do {
entries = try transport.listDirectory(projectDir)
} catch {
return []
}
for entry in entries {
let full = projectDir + "/" + entry
// Skip the .scarf/ dir entirely when deciding "does the
// project dir have user content?" the only files we put
// there (dashboard.json + lock) are tracked already, and
// if they're still there the overall project is not yet
// "empty."
if entry == ".scarf" { continue }
if trackedPaths.contains(full) { continue }
extras.append(full)
}
return extras
}
/// Recursively delete a directory via the transport. The transport's
/// `removeFile` works on files and on empty directories; we walk
/// children first, then remove the now-empty parent.
nonisolated private func removeRecursively(
_ path: String,
transport: any ServerTransport
) throws {
guard transport.fileExists(path) else { return }
if transport.stat(path)?.isDirectory != true {
try transport.removeFile(path)
return
}
let entries = (try? transport.listDirectory(path)) ?? []
for entry in entries {
try removeRecursively(path + "/" + entry, transport: transport)
}
try transport.removeFile(path)
}
/// Remove the `<!-- scarf-template:<id>:begin --> :end -->` block
/// from MEMORY.md, preserving everything else. A missing end marker
/// is logged but doesn't fail we strip from the begin marker to
/// EOF in that case, on the theory that a broken template block is
/// worse than a slightly aggressive strip.
nonisolated private func stripMemoryBlock(
blockId: String,
memoryPath: String,
transport: any ServerTransport
) throws {
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(templateId: blockId)
let endMarker = ProjectTemplateService.memoryBlockEndMarker(templateId: blockId)
let data = try transport.readFile(memoryPath)
guard let text = String(data: data, encoding: .utf8) else { return }
guard let beginRange = text.range(of: beginMarker) else { return }
let stripRange: Range<String.Index>
if let endRange = text.range(of: endMarker, range: beginRange.upperBound..<text.endIndex) {
// Include the end marker and one trailing newline if present.
var upper = endRange.upperBound
if upper < text.endIndex, text[upper] == "\n" {
upper = text.index(after: upper)
}
stripRange = beginRange.lowerBound..<upper
} else {
Self.logger.warning("memory block for \(blockId, privacy: .public) has begin marker but no end marker; stripping to EOF")
stripRange = beginRange.lowerBound..<text.endIndex
}
// Also consume one leading blank line that the installer inserts
// before the begin marker, so repeated install/uninstall cycles
// don't accumulate blank lines at the insertion site.
var lower = stripRange.lowerBound
if lower > text.startIndex {
let prev = text.index(before: lower)
if text[prev] == "\n", prev > text.startIndex {
let prevPrev = text.index(before: prev)
if text[prevPrev] == "\n" {
lower = prev
}
}
}
let updated = text.replacingCharacters(in: lower..<stripRange.upperBound, with: "")
guard let outData = updated.data(using: .utf8) else { return }
try transport.writeFile(memoryPath, data: outData)
}
}
@@ -0,0 +1,87 @@
import Foundation
import Observation
import os
/// Process-wide router for `scarf://install?url=` URLs. The app delegate's
/// `onOpenURL` hands the URL in here; the Projects feature observes
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
///
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
/// launch from a browser link) and still be picked up by the first
/// `ProjectsView` that appears.
@Observable
@MainActor
final class TemplateURLRouter {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
static let shared = TemplateURLRouter()
/// Non-nil when an install request is waiting to be handled. Can be
/// either a remote `https://` URL (from a `scarf://install?url=` deep
/// link) or a local `file://` URL (from a Finder double-click on a
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
/// this, dispatch by scheme, present the install sheet, then call
/// `consume` to clear it. Only one pending install at a time if a
/// second arrives before the first is consumed, it replaces the first
/// (matches browser-link intuition where the latest click wins).
var pendingInstallURL: URL?
private init() {}
/// Parse and validate an inbound URL. Returns `true` if the URL was
/// recognized and staged for handling. Unknown schemes or malformed
/// payloads return `false` so the caller can log/ignore. Supports:
///
/// - `scarf://install?url=https://` remote template URL from a web link.
/// - `file:////foo.scarftemplate` local file from a Finder
/// double-click or a drag onto the app icon.
@discardableResult
func handle(_ url: URL) -> Bool {
if url.isFileURL {
return handleFileURL(url)
}
if url.scheme?.lowercased() == "scarf" {
return handleScarfURL(url)
}
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
return false
}
private func handleFileURL(_ url: URL) -> Bool {
guard url.pathExtension.lowercased() == "scarftemplate" else {
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
return false
}
pendingInstallURL = url
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
return true
}
private func handleScarfURL(_ url: URL) -> Bool {
guard url.host?.lowercased() == "install" else {
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
return false
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
let remote = URL(string: raw) else {
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
return false
}
// Refuse anything but https defense-in-depth against a browser or
// mail client that would happily hand us a javascript: or http://
// URL pointing at something unexpected.
guard remote.scheme?.lowercased() == "https" else {
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
return false
}
pendingInstallURL = remote
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
return true
}
/// Called by the install sheet once it has picked up the URL.
func consume() {
pendingInstallURL = nil
}
}
@@ -1,4 +1,5 @@
import SwiftUI
import UniformTypeIdentifiers
private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard"
@@ -14,12 +15,25 @@ 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
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)
}
@State private var selectedTab: DashboardTab = .dashboard
@@ -32,6 +46,7 @@ struct ProjectsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationTitle("Projects")
.toolbar { templatesToolbar }
.task {
viewModel.load()
if let name = coordinator.selectedProjectName,
@@ -39,11 +54,151 @@ 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)
}
}
}
// 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 +220,18 @@ struct ProjectsView: View {
Text(project.name)
}
.tag(project)
.contextMenu {
if uninstaller.isTemplateInstalled(project: project) {
Button("Uninstall Template…", systemImage: "trash") {
uninstallerViewModel.begin(project: project)
showingUninstallSheet = true
}
Divider()
}
Button("Remove from Scarf", systemImage: "minus.circle") {
viewModel.removeProject(project)
}
}
}
.listStyle(.sidebar)
@@ -216,6 +383,16 @@ struct ProjectsView: View {
Image(systemName: "folder")
}
.buttonStyle(.borderless)
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,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,200 @@
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
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
let context = context
Task.detached { [weak self] in
do {
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
_ = context
await MainActor.run { [weak self] in
self?.plan = plan
self?.stage = .planned
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func confirmInstall() {
guard let plan else { return }
stage = .installing
let installer = installer
let service = templateService
Task.detached { [weak self] in
do {
let entry = try installer.install(plan: plan)
service.cleanupTempDir(plan.unpackedDir)
await MainActor.run { [weak self] in
guard let self else { return }
self.stage = .succeeded(installed: entry)
self.inspection = nil
self.plan = nil
self.chosenParentDirectory = nil
self.readmeBody = nil
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
// MARK: - Cleanup
func cancel() {
resetTempState()
stage = .idle
}
private func resetTempState() {
if let inspection {
templateService.cleanupTempDir(inspection.unpackedDir)
}
inspection = nil
plan = nil
chosenParentDirectory = nil
readmeBody = nil
}
}
@@ -0,0 +1,76 @@
import Foundation
import os
/// Drives the template-uninstall sheet. Mirrors the installer VM in
/// stage shape: open a plan (`begin`), preview it, confirm or cancel.
@Observable
@MainActor
final class TemplateUninstallerViewModel {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateUninstallerViewModel")
enum Stage: Sendable {
case idle
case loading
case planned
case uninstalling
case succeeded(removed: ProjectEntry)
case failed(String)
}
let context: ServerContext
private let uninstaller: ProjectTemplateUninstaller
init(context: ServerContext) {
self.context = context
self.uninstaller = ProjectTemplateUninstaller(context: context)
}
var stage: Stage = .idle
var plan: TemplateUninstallPlan?
/// Load the `template.lock.json` for the given project and build a
/// removal plan. Moves stage to `.planned` on success.
func begin(project: ProjectEntry) {
stage = .loading
let uninstaller = uninstaller
Task.detached { [weak self] in
do {
let plan = try uninstaller.loadUninstallPlan(for: project)
await MainActor.run { [weak self] in
guard let self else { return }
self.plan = plan
self.stage = .planned
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func confirmUninstall() {
guard let plan else { return }
stage = .uninstalling
let uninstaller = uninstaller
Task.detached { [weak self] in
do {
try uninstaller.uninstall(plan: plan)
await MainActor.run { [weak self] in
guard let self else { return }
self.stage = .succeeded(removed: plan.project)
self.plan = nil
}
} catch {
await MainActor.run { [weak self] in
self?.stage = .failed(error.localizedDescription)
}
}
}
}
func cancel() {
plan = nil
stage = .idle
}
}
@@ -0,0 +1,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,312 @@
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 .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)
}
}
}
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)
}
readmeSection
}
.padding(.vertical)
}
Divider()
HStack {
Button("Cancel") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Text("\(plan.totalWriteCount) changes")
.font(.caption)
.foregroundStyle(.secondary)
Button("Install") { viewModel.confirmInstall() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
.padding(.top, 8)
}
}
private func manifestHeader(_ manifest: ProjectTemplateManifest) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text(manifest.name).font(.title2.bold())
Text("v\(manifest.version)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(manifest.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Text(manifest.description)
.font(.subheadline)
.foregroundStyle(.secondary)
if let author = manifest.author {
HStack(spacing: 4) {
Image(systemName: "person.crop.circle")
.font(.caption)
.foregroundStyle(.secondary)
Text(author.name)
.font(.caption)
.foregroundStyle(.secondary)
if let url = author.url, let parsed = URL(string: url) {
Link(parsed.host ?? url, destination: parsed)
.font(.caption)
}
}
}
}
}
private func projectFilesSection(plan: TemplateInstallPlan) -> some View {
section(title: "New project directory", subtitle: plan.projectDir) {
VStack(alignment: .leading, spacing: 2) {
ForEach(plan.projectFiles, id: \.destinationPath) { copy in
fileRow(label: copy.destinationPath, systemImage: "doc.text")
}
}
}
}
private func skillsSection(plan: TemplateInstallPlan) -> some View {
section(
title: "Skills (namespaced, safe to remove later)",
subtitle: plan.skillsNamespaceDir
) {
VStack(alignment: .leading, spacing: 2) {
ForEach(plan.skillsFiles, id: \.destinationPath) { copy in
fileRow(label: copy.destinationPath, systemImage: "puzzlepiece")
}
}
}
}
private func cronSection(plan: TemplateInstallPlan) -> some View {
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
VStack(alignment: .leading, spacing: 4) {
ForEach(plan.cronJobs, id: \.name) { job in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 1) {
Text(job.name).font(.callout.monospaced())
Text("schedule: \(job.schedule)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
private func memorySection(plan: TemplateInstallPlan) -> some View {
section(title: "Memory appendix", subtitle: plan.memoryPath) {
ScrollView {
Text(plan.memoryAppendix ?? "")
.font(.caption.monospaced())
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.quaternary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.frame(maxHeight: 160)
}
}
private var readmeSection: some View {
Group {
// The body is preloaded in the VM off MainActor when inspection
// completes no sync file I/O during View body evaluation.
if let readme = viewModel.readmeBody {
section(title: "README", subtitle: nil) {
ScrollView {
Text(readme)
.font(.callout)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 200)
}
}
}
}
@ViewBuilder
private func section<Content: View>(title: String, subtitle: String?, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
if let subtitle {
Text(subtitle)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
content()
.padding(.top, 2)
}
}
private func fileRow(label: String, systemImage: String) -> some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.foregroundStyle(.secondary)
.font(.caption)
Text(label)
.font(.caption.monospaced())
.lineLimit(1)
.truncationMode(.head)
}
}
private func successView(entry: ProjectEntry) -> some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
Text("Installed \(entry.name)")
.font(.title2.bold())
Text(entry.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
Button("Open Project") {
onCompleted(entry)
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func failureView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("Install Failed").font(.title2.bold())
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
// MARK: - Actions
private func chooseParentDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
panel.prompt = String(localized: "Choose Parent Folder")
if panel.runModal() == .OK, let url = panel.url {
viewModel.pickParentDirectory(url.path)
}
}
}
@@ -0,0 +1,309 @@
import SwiftUI
/// Preview-and-confirm sheet for uninstalling a template-installed
/// project. Symmetric with the install sheet: lists every file, cron
/// job, and memory block that will be removed BEFORE anything happens.
struct TemplateUninstallSheet: View {
@Environment(\.dismiss) private var dismiss
@State var viewModel: TemplateUninstallerViewModel
/// Called on success with the project that was removed. Parent uses
/// this to refresh its projects list and clear any selection.
let onCompleted: (ProjectEntry) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
switch viewModel.stage {
case .idle:
idleView
case .loading:
progress("Reading template.lock.json…")
case .planned:
if let plan = viewModel.plan {
plannedView(plan: plan)
} else {
progress("Preparing…")
}
case .uninstalling:
progress("Removing…")
case .succeeded(let removed):
successView(removed: removed)
case .failed(let message):
failureView(message: message)
}
}
.frame(minWidth: 620, minHeight: 480)
.padding()
}
// MARK: - Stages
private var idleView: some View {
VStack(spacing: 16) {
Text("No template loaded.")
.font(.headline)
Button("Close") { dismiss() }
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func progress(_ label: LocalizedStringKey) -> some View {
VStack(spacing: 16) {
ProgressView()
Text(label)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func plannedView(plan: TemplateUninstallPlan) -> some View {
VStack(alignment: .leading, spacing: 0) {
header(plan: plan)
.padding(.bottom, 8)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 16) {
projectFilesSection(plan: plan)
if plan.skillsNamespaceDir != nil {
skillsSection(plan: plan)
}
cronSection(plan: plan)
memorySection(plan: plan)
registrySection(plan: plan)
}
.padding(.vertical)
}
Divider()
HStack {
Button("Cancel") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.cancelAction)
Spacer()
Text("\(plan.totalRemoveCount) changes")
.font(.caption)
.foregroundStyle(.secondary)
Button("Remove") { viewModel.confirmUninstall() }
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.tint(.red)
}
.padding(.top, 8)
}
}
private func header(plan: TemplateUninstallPlan) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .firstTextBaseline) {
Text("Remove “\(plan.lock.templateName)").font(.title2.bold())
Text("v\(plan.lock.templateVersion)")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Text(plan.lock.templateId)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Text("Installed \(plan.lock.installedAt)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func projectFilesSection(plan: TemplateUninstallPlan) -> some View {
section(title: "Project directory", subtitle: plan.project.path) {
VStack(alignment: .leading, spacing: 4) {
ForEach(plan.projectFilesToRemove, id: \.self) { path in
fileRow(
label: path,
systemImage: "minus.circle",
color: .red,
tag: "remove"
)
}
ForEach(plan.projectFilesAlreadyGone, id: \.self) { path in
fileRow(
label: path,
systemImage: "questionmark.circle",
color: .secondary,
tag: "already gone"
)
}
ForEach(plan.extraProjectEntries, id: \.self) { path in
fileRow(
label: path,
systemImage: "lock.shield",
color: .green,
tag: "keep (not installed by template)"
)
}
if plan.projectDirBecomesEmpty {
Text("Project directory will also be removed (nothing user-owned left inside).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 4)
} else if !plan.extraProjectEntries.isEmpty {
Text("Project directory stays — it still holds files you created after install.")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 4)
}
}
}
}
private func skillsSection(plan: TemplateUninstallPlan) -> some View {
section(
title: "Skills",
subtitle: plan.skillsNamespaceDir
) {
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
Text("Remove the entire namespace dir recursively")
.font(.caption)
}
}
}
private func cronSection(plan: TemplateUninstallPlan) -> some View {
section(
title: "Cron jobs",
subtitle: plan.cronJobsToRemove.isEmpty && plan.cronJobsAlreadyGone.isEmpty
? "none"
: nil
) {
VStack(alignment: .leading, spacing: 4) {
ForEach(plan.cronJobsToRemove, id: \.id) { job in
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
VStack(alignment: .leading, spacing: 1) {
Text(job.name).font(.callout.monospaced())
Text(job.id)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
ForEach(plan.cronJobsAlreadyGone, id: \.self) { name in
HStack(spacing: 6) {
Image(systemName: "questionmark.circle")
.foregroundStyle(.secondary)
.font(.caption)
Text("\(name) — already gone")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
@ViewBuilder
private func memorySection(plan: TemplateUninstallPlan) -> some View {
if plan.memoryBlockPresent {
section(title: "Memory block", subtitle: plan.memoryPath) {
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
Text("Strip the template's begin/end block, preserve everything else in MEMORY.md")
.font(.caption)
}
}
} else if plan.lock.memoryBlockId != nil {
section(title: "Memory block", subtitle: nil) {
Text("A memory block was recorded in the lock but is no longer present in MEMORY.md — skipping.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private func registrySection(plan: TemplateUninstallPlan) -> some View {
section(title: "Projects registry", subtitle: nil) {
HStack(spacing: 6) {
Image(systemName: "minus.circle")
.foregroundStyle(.red)
.font(.caption)
Text("Remove \"\(plan.project.name)\" from Scarf's project list")
.font(.caption)
}
}
}
@ViewBuilder
private func section<Content: View>(
title: LocalizedStringKey,
subtitle: String?,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.headline)
if let subtitle {
Text(subtitle)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
content()
.padding(.top, 2)
}
}
private func fileRow(label: String, systemImage: String, color: Color, tag: LocalizedStringKey) -> some View {
HStack(spacing: 6) {
Image(systemName: systemImage)
.foregroundStyle(color)
.font(.caption)
Text(label)
.font(.caption.monospaced())
.lineLimit(1)
.truncationMode(.head)
Spacer()
Text(tag)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
private func successView(removed: ProjectEntry) -> some View {
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.green)
Text("Removed \(removed.name)")
.font(.title2.bold())
Button("Done") {
onCompleted(removed)
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func failureView(message: String) -> some View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(.orange)
Text("Uninstall Failed").font(.title2.bold())
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Button("Close") {
viewModel.cancel()
dismiss()
}
.keyboardShortcut(.defaultAction)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
+51
View File
@@ -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>
+40 -1
View File
@@ -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"
}
}
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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)
+614
View File
@@ -0,0 +1,614 @@
import Testing
import Foundation
@testable import scarf
/// Exercises the service's ability to unpack, parse, and validate bundles.
/// Doesn't touch the installer see `ProjectTemplateInstallerTests` so
/// these don't need write access to ~/.hermes.
@Suite struct ProjectTemplateServiceTests {
@Test func manifestSlugSanitizesPunctuation() {
let manifest = Self.sampleManifest(id: "alan@w/focus dashboard!")
#expect(manifest.slug == "alan-w-focus-dashboard")
}
@Test func manifestSlugFallsBackToPlaceholder() {
let manifest = Self.sampleManifest(id: "////")
#expect(manifest.slug == "template")
}
@Test func inspectRejectsMissingManifest() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
// A zip with no template.json
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "hi",
"AGENTS.md": "hi",
"dashboard.json": "{}"
], includeManifest: false)
let service = ProjectTemplateService(context: .local)
#expect(throws: ProjectTemplateError.self) {
try service.inspect(zipPath: bundle)
}
}
@Test func inspectRejectsMissingAgentsMd() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "# Readme",
"dashboard.json": Self.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
#expect(throws: ProjectTemplateError.self) {
try service.inspect(zipPath: bundle)
}
}
@Test func inspectAcceptsMinimalValidBundle() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "# Readme",
"AGENTS.md": "# Agents",
"dashboard.json": Self.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "test/example")
#expect(inspection.manifest.slug == "test-example")
#expect(inspection.cronJobs.isEmpty)
#expect(inspection.files.contains("AGENTS.md"))
}
@Test func inspectRejectsContentClaimMismatch() throws {
let dir = try Self.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: dir) }
// Claim cron: 2 but ship no cron dir service must reject.
let manifest = Self.sampleManifest(cron: 2)
let manifestJSON = try JSONEncoder().encode(manifest)
let manifestString = String(data: manifestJSON, encoding: .utf8)!
let bundle = try Self.makeBundle(dir: dir, files: [
"README.md": "# Readme",
"AGENTS.md": "# Agents",
"dashboard.json": Self.sampleDashboardJSON,
"template.json": manifestString
], includeManifest: false)
let service = ProjectTemplateService(context: .local)
#expect(throws: ProjectTemplateError.self) {
try service.inspect(zipPath: bundle)
}
}
// MARK: - Helpers
static let sampleDashboardJSON = """
{
"version": 1,
"title": "Example",
"description": "test",
"sections": []
}
"""
static func sampleManifest(
id: String = "test/example",
cron: Int? = nil,
skills: [String]? = nil,
instructions: [String]? = nil
) -> ProjectTemplateManifest {
ProjectTemplateManifest(
schemaVersion: 1,
id: id,
name: "Example",
version: "1.0.0",
minScarfVersion: nil,
minHermesVersion: nil,
author: TemplateAuthor(name: "Tester", url: nil),
description: "Test template",
category: nil,
tags: nil,
icon: nil,
screenshots: nil,
contents: TemplateContents(
dashboard: true,
agentsMd: true,
instructions: instructions,
skills: skills,
cron: cron,
memory: nil
)
)
}
static func makeTempDir() throws -> String {
let dir = NSTemporaryDirectory() + "scarf-template-test-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
/// Write files to a staging dir, then zip them into `<dir>/bundle.scarftemplate`
/// and return its path. When `includeManifest` is true the caller doesn't
/// need to provide `template.json` we synthesize a valid one.
static func makeBundle(
dir: String,
files: [String: String],
includeManifest: Bool = true
) throws -> String {
let staging = dir + "/staging"
try FileManager.default.createDirectory(atPath: staging, withIntermediateDirectories: true)
for (relativePath, content) in files {
let full = staging + "/" + relativePath
let parent = (full as NSString).deletingLastPathComponent
if !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
try content.data(using: .utf8)!.write(to: URL(fileURLWithPath: full))
}
if includeManifest {
let manifest = sampleManifest()
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(manifest)
try data.write(to: URL(fileURLWithPath: staging + "/template.json"))
}
let bundlePath = dir + "/bundle.scarftemplate"
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.currentDirectoryURL = URL(fileURLWithPath: staging)
process.arguments = ["-qq", "-r", bundlePath, "."]
try process.run()
process.waitUntilExit()
#expect(process.terminationStatus == 0)
return bundlePath
}
}
/// URL-router has no filesystem side effects safe to unit-test directly.
@Suite struct TemplateURLRouterTests {
@Test @MainActor func refusesNonScarfScheme() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(string: "https://example.com/foo")!)
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func refusesUnknownHost() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(string: "scarf://bogus?url=https://example.com/x.scarftemplate")!)
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func refusesNonHttpsPayload() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(string: "scarf://install?url=file:///etc/passwd")!)
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func acceptsFileURLWithScarftemplateExtension() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let path = "/tmp/example.scarftemplate"
let ok = router.handle(URL(fileURLWithPath: path))
#expect(ok)
#expect(router.pendingInstallURL?.isFileURL == true)
#expect(router.pendingInstallURL?.path == path)
router.consume()
}
@Test @MainActor func refusesFileURLWithOtherExtension() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let ok = router.handle(URL(fileURLWithPath: "/tmp/somefile.zip"))
#expect(ok == false)
#expect(router.pendingInstallURL == nil)
}
@Test @MainActor func acceptsHttpsInstallUrl() {
let router = TemplateURLRouter.shared
router.pendingInstallURL = nil
let target = "https://example.com/foo.scarftemplate"
let ok = router.handle(URL(string: "scarf://install?url=\(target)")!)
#expect(ok)
#expect(router.pendingInstallURL?.absoluteString == target)
router.consume()
}
}
/// End-to-end install test against a minimal bundle (dashboard + README +
/// AGENTS.md, no skills/cron/memory). Exercises the full install path
/// through `preflight createProjectFiles registerProject
/// writeLockFile`. We avoid touching user state by:
/// 1. Picking a temp `projectDir` under `NSTemporaryDirectory()`.
/// 2. Snapshotting and restoring `~/.hermes/scarf/projects.json` around
/// each test so the registry write is reversible.
/// Skills/cron/memory paths aren't touched because the test bundles claim
/// none. That's the intentional v1 coverage: the project-dir side effects
/// are exhaustively tested; global-state side effects (skills namespace,
/// cron CLI, memory append) are covered by manual verification per the
/// plan's step 7.
@Suite struct ProjectTemplateInstallerTests {
@Test func installsMinimalBundleAndWritesLockFile() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
let registryBefore = Self.snapshotRegistry()
defer { Self.restoreRegistry(registryBefore) }
let installer = ProjectTemplateInstaller(context: .local)
let entry = try installer.install(plan: plan)
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md"))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md"))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/dashboard.json"))
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json"))
#expect(entry.path == plan.projectDir)
let lockData = try Data(contentsOf: URL(fileURLWithPath: plan.projectDir + "/.scarf/template.lock.json"))
let lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
#expect(lock.templateId == inspection.manifest.id)
#expect(lock.templateVersion == inspection.manifest.version)
#expect(lock.projectFiles.contains(plan.projectDir + "/AGENTS.md"))
#expect(lock.cronJobNames.isEmpty)
#expect(lock.memoryBlockId == nil)
}
@Test func preflightRejectsExistingProjectDir() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
// Simulate a concurrent creation between buildPlan and install.
try FileManager.default.createDirectory(atPath: plan.projectDir, withIntermediateDirectories: true)
let installer = ProjectTemplateInstaller(context: .local)
#expect(throws: ProjectTemplateError.self) {
try installer.install(plan: plan)
}
}
@Test func buildPlanRefusesDuplicateProjectDir() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
// Pre-create the slugged project dir so buildPlan's collision check
// fires before we get to install.
let slugDir = parentDir + "/" + inspection.manifest.slug
try FileManager.default.createDirectory(atPath: slugDir, withIntermediateDirectories: true)
#expect(throws: ProjectTemplateError.self) {
try service.buildPlan(inspection: inspection, parentDir: parentDir)
}
}
// MARK: - Registry snapshot helpers
/// Read the raw bytes of the current projects.json so we can restore
/// it byte-for-byte after the test. `nil` means the file didn't exist
/// restore by deleting whatever got created.
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// End-to-end install + uninstall test: install a minimal bundle, uninstall
/// it, verify every tracked file is gone, the registry is restored to its
/// pre-install state, and user-added files (if any) are preserved. Scoped
/// to bundles with no skills/cron/memory so no global state is touched.
@Suite struct ProjectTemplateUninstallerTests {
@Test func roundTripsInstallThenUninstall() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
let registryBefore = Self.snapshotRegistry()
defer { Self.restoreRegistry(registryBefore) }
let installer = ProjectTemplateInstaller(context: .local)
let entry = try installer.install(plan: plan)
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
let uninstaller = ProjectTemplateUninstaller(context: .local)
#expect(uninstaller.isTemplateInstalled(project: entry))
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
#expect(uninstallPlan.projectFilesToRemove.count == 4) // README, AGENTS, dashboard.json, lock
#expect(uninstallPlan.extraProjectEntries.isEmpty)
#expect(uninstallPlan.projectDirBecomesEmpty)
#expect(uninstallPlan.skillsNamespaceDir == nil)
#expect(uninstallPlan.cronJobsToRemove.isEmpty)
#expect(uninstallPlan.memoryBlockPresent == false)
try uninstaller.uninstall(plan: uninstallPlan)
#expect(FileManager.default.fileExists(atPath: plan.projectDir) == false)
// Registry entry gone length matches pre-install snapshot.
let service2 = ProjectDashboardService(context: .local)
let registryAfter = service2.loadRegistry()
#expect(registryAfter.projects.contains(where: { $0.path == entry.path }) == false)
}
@Test func preservesUserAddedFiles() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let parentDir = scratch + "/parent"
try FileManager.default.createDirectory(atPath: parentDir, withIntermediateDirectories: true)
let bundle = try ProjectTemplateServiceTests.makeBundle(dir: scratch, files: [
"README.md": "# Minimal",
"AGENTS.md": "# Agent notes",
"dashboard.json": ProjectTemplateServiceTests.sampleDashboardJSON
])
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
let plan = try service.buildPlan(inspection: inspection, parentDir: parentDir)
let registryBefore = Self.snapshotRegistry()
defer { Self.restoreRegistry(registryBefore) }
let installer = ProjectTemplateInstaller(context: .local)
let entry = try installer.install(plan: plan)
// Simulate the user / agent creating files post-install.
let userFile = plan.projectDir + "/sites.txt"
try "https://example.com\n".data(using: .utf8)!
.write(to: URL(fileURLWithPath: userFile))
let uninstaller = ProjectTemplateUninstaller(context: .local)
let uninstallPlan = try uninstaller.loadUninstallPlan(for: entry)
#expect(uninstallPlan.extraProjectEntries.contains(userFile))
#expect(uninstallPlan.projectDirBecomesEmpty == false)
try uninstaller.uninstall(plan: uninstallPlan)
// Project dir should still exist because sites.txt is there.
#expect(FileManager.default.fileExists(atPath: plan.projectDir))
#expect(FileManager.default.fileExists(atPath: userFile))
// Lock-tracked files are gone.
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/AGENTS.md") == false)
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/README.md") == false)
#expect(FileManager.default.fileExists(atPath: plan.projectDir + "/.scarf/template.lock.json") == false)
}
@Test func loadUninstallPlanRejectsProjectWithoutLock() throws {
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
try FileManager.default.createDirectory(atPath: scratch + "/bare", withIntermediateDirectories: true)
let entry = ProjectEntry(name: "Bare", path: scratch + "/bare")
let uninstaller = ProjectTemplateUninstaller(context: .local)
#expect(uninstaller.isTemplateInstalled(project: entry) == false)
#expect(throws: ProjectTemplateError.self) {
try uninstaller.loadUninstallPlan(for: entry)
}
}
// MARK: - Registry snapshot helpers (dup'd intentionally from
// ProjectTemplateInstallerTests small helper, not worth a shared
// fixture file for one more suite).
nonisolated private static func snapshotRegistry() -> Data? {
let path = ServerContext.local.paths.projectsRegistry
return try? Data(contentsOf: URL(fileURLWithPath: path))
}
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
let path = ServerContext.local.paths.projectsRegistry
if let snapshot {
try? snapshot.write(to: URL(fileURLWithPath: path))
} else {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// Validates every `.scarftemplate` shipped under `examples/templates/` in
/// the repo. A template whose manifest, `contents` claim, or file set is
/// out of sync will fail here so the examples can't silently rot.
@Suite struct ProjectTemplateExampleTemplateTests {
@Test func siteStatusCheckerParsesAndPlans() throws {
let bundle = try Self.locateExample(name: "site-status-checker")
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: bundle)
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "awizemann/site-status-checker")
#expect(inspection.manifest.contents.dashboard)
#expect(inspection.manifest.contents.agentsMd)
#expect(inspection.manifest.contents.cron == 1)
#expect(inspection.cronJobs.count == 1)
#expect(inspection.cronJobs.first?.name == "Check site status")
#expect(inspection.cronJobs.first?.schedule == "0 9 * * *")
let scratch = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: scratch) }
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
#expect(plan.projectDir.hasSuffix("awizemann-site-status-checker"))
#expect(plan.skillsFiles.isEmpty)
#expect(plan.memoryAppendix == nil)
#expect(plan.cronJobs.count == 1)
// Cron job name gets prefixed with the template tag so users can
// find + remove it later.
#expect(plan.cronJobs.first?.name == "[tmpl:awizemann/site-status-checker] Check site status")
// Verify the bundled dashboard.json decodes against the same
// `ProjectDashboard` struct the app uses at runtime catches drift
// between template-author conventions and the actual renderer
// (e.g. a widget type that ProjectsView doesn't know, a
// non-number value for a stat, etc.).
let dashboardPath = inspection.unpackedDir + "/dashboard.json"
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
#expect(dashboard.title == "Site Status")
#expect(dashboard.sections.count == 3)
// First section should have three stat widgets that the cron job
// updates by value. Assert titles + types so the AGENTS.md contract
// can't drift from the actual dashboard.
let statsSection = dashboard.sections[0]
#expect(statsSection.title == "Current Status")
let statTitles = statsSection.widgets.filter { $0.type == "stat" }.map(\.title)
#expect(statTitles.contains("Sites Up"))
#expect(statTitles.contains("Sites Down"))
#expect(statTitles.contains("Last Checked"))
// The cron prompt mentions sites.txt and dashboard.json if it
// ever stops doing that, the agent won't know what files to touch.
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
#expect(cronPrompt.contains("sites.txt"))
#expect(cronPrompt.contains("dashboard.json"))
#expect(cronPrompt.contains("status-log.md"))
}
/// Resolve the example bundle path robustly. Unit-test working dirs
/// differ between `xcodebuild test` (project root) and an Xcode IDE
/// run (build-output dir), so we walk up from this source file until
/// we find the repo root.
nonisolated private static func locateExample(name: String) throws -> String {
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
for _ in 0..<6 {
let candidate = dir.appendingPathComponent("examples/templates/\(name)/\(name).scarftemplate")
if FileManager.default.fileExists(atPath: candidate.path) {
return candidate.path
}
dir = dir.deletingLastPathComponent()
}
throw ProjectTemplateError.requiredFileMissing("examples/templates/\(name)/\(name).scarftemplate")
}
}
/// Round-trips a real project structure through the exporter and back into
/// the service. Does NOT run the installer (which would write to
/// ~/.hermes) it verifies the produced bundle is valid, and stops there.
@Suite struct ProjectTemplateExportTests {
@Test func roundTripsMinimalProject() throws {
let fakeProject = NSTemporaryDirectory() + "scarf-project-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: fakeProject + "/.scarf", withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: fakeProject) }
try ProjectTemplateServiceTests.sampleDashboardJSON
.data(using: .utf8)!
.write(to: URL(fileURLWithPath: fakeProject + "/.scarf/dashboard.json"))
try "# Test project".data(using: .utf8)!
.write(to: URL(fileURLWithPath: fakeProject + "/README.md"))
try "# Agent notes".data(using: .utf8)!
.write(to: URL(fileURLWithPath: fakeProject + "/AGENTS.md"))
let entry = ProjectEntry(name: "Round Trip", path: fakeProject)
let exporter = ProjectTemplateExporter(context: .local)
let outputDir = try ProjectTemplateServiceTests.makeTempDir()
defer { try? FileManager.default.removeItem(atPath: outputDir) }
let outputPath = outputDir + "/rt.scarftemplate"
let inputs = ProjectTemplateExporter.ExportInputs(
project: entry,
templateId: "tester/round-trip",
templateName: "Round Trip",
templateVersion: "0.1.0",
description: "round-trip test",
authorName: "Tester",
authorUrl: nil,
category: nil,
tags: [],
includeSkillIds: [],
includeCronJobIds: [],
memoryAppendix: nil
)
try exporter.export(inputs: inputs, outputZipPath: outputPath)
#expect(FileManager.default.fileExists(atPath: outputPath))
let service = ProjectTemplateService(context: .local)
let inspection = try service.inspect(zipPath: outputPath)
defer { service.cleanupTempDir(inspection.unpackedDir) }
#expect(inspection.manifest.id == "tester/round-trip")
#expect(inspection.files.contains("dashboard.json"))
#expect(inspection.files.contains("README.md"))
#expect(inspection.files.contains("AGENTS.md"))
}
}