mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -85,3 +85,22 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
|
||||
## Hermes Version
|
||||
|
||||
Targets Hermes v0.9.0 (v2026.4.13). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||
|
||||
## Project Templates
|
||||
|
||||
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
|
||||
|
||||
- `template.json` — manifest (id, name, version, `contents` claim)
|
||||
- `README.md` — shown in the install preview sheet
|
||||
- `AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
|
||||
- `dashboard.json` — copied to `<project>/.scarf/dashboard.json`
|
||||
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||
- `skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
|
||||
- `cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
|
||||
- `memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
|
||||
|
||||
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
|
||||
|
||||
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
|
||||
|
||||
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
|
||||
|
||||
@@ -19,7 +19,15 @@
|
||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||
</p>
|
||||
|
||||
## What's New in 2.1
|
||||
## What's New in 2.2
|
||||
|
||||
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, and cron jobs into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the new **Templates** menu in the Projects toolbar.
|
||||
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), and a live diff of any memory appendix. Nothing writes until you click Install.
|
||||
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records everything written for easy uninstall. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
|
||||
|
||||
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0).
|
||||
|
||||
### Previously, in 2.1
|
||||
|
||||
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
|
||||
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -38,5 +38,56 @@
|
||||
<integer>86400</integer>
|
||||
<key>SUEnableInstallerLauncherService</key>
|
||||
<false/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.scarf.url</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>scarf</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UTExportedTypeDeclarations</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UTTypeIdentifier</key>
|
||||
<string>com.scarf.template</string>
|
||||
<key>UTTypeDescription</key>
|
||||
<string>Scarf Project Template</string>
|
||||
<key>UTTypeConformsTo</key>
|
||||
<array>
|
||||
<string>public.zip-archive</string>
|
||||
<string>public.data</string>
|
||||
</array>
|
||||
<key>UTTypeTagSpecification</key>
|
||||
<dict>
|
||||
<key>public.filename-extension</key>
|
||||
<array>
|
||||
<string>scarftemplate</string>
|
||||
</array>
|
||||
<key>public.mime-type</key>
|
||||
<array>
|
||||
<string>application/vnd.scarf.template+zip</string>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>Scarf Project Template</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>com.scarf.template</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,6 +1,42 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"CFBundleDisplayName" : {
|
||||
"comment" : "Bundle display name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "Scarf"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CFBundleName" : {
|
||||
"comment" : "Bundle name",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "scarf"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSHumanReadableCopyright" : {
|
||||
"comment" : "Copyright (human-readable)",
|
||||
"extractionState" : "extracted_with_value",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NSMicrophoneUsageDescription" : {
|
||||
"comment" : "Shown by macOS when Scarf first requests microphone access for Hermes voice chat.",
|
||||
"extractionState" : "manual",
|
||||
@@ -48,6 +84,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Scarf Project Template" : {
|
||||
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
|
||||
+15667
-15266
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,23 @@ struct ScarfApp: App {
|
||||
// covers the case where the user added a server in
|
||||
// another window since this one last opened.
|
||||
.onAppear { liveRegistry.rebuild() }
|
||||
// scarf://install?url=… deep-link handler. Stages the
|
||||
// URL on the process-wide router; ProjectsView picks it
|
||||
// up and presents the install sheet. Activating the
|
||||
// app here ensures a cold launch from a browser click
|
||||
// surfaces the sheet without the user having to click
|
||||
// into Scarf first.
|
||||
.onOpenURL { url in
|
||||
TemplateURLRouter.shared.handle(url)
|
||||
NSApplication.shared.activate()
|
||||
}
|
||||
} else {
|
||||
// MissingServerView is a dead-end "server was removed" pane
|
||||
// with no ProjectsView — so no observer of the router's
|
||||
// pendingInstallURL exists in this window. Routing a
|
||||
// scarf://install URL here would silently drop it. Leave
|
||||
// onOpenURL off this branch; ContextBoundRoot windows in
|
||||
// the same app instance will still handle it.
|
||||
MissingServerView(removedServerID: serverID)
|
||||
.environment(registry)
|
||||
.environment(updater)
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user