mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96b5da3cd5 | |||
| 3fe8f4efa0 | |||
| 9cb2afc787 | |||
| 785fa7dc8c | |||
| 75b3e97cdb | |||
| 60820f9cfb | |||
| a7a174d2c6 |
@@ -1,6 +1,8 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
struct ProjectDashboardService: Sendable {
|
struct ProjectDashboardService: Sendable {
|
||||||
|
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
let transport: any ServerTransport
|
let transport: any ServerTransport
|
||||||
@@ -19,23 +21,28 @@ struct ProjectDashboardService: Sendable {
|
|||||||
do {
|
do {
|
||||||
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
|
||||||
} catch {
|
} catch {
|
||||||
print("[Scarf] Failed to decode project registry: \(error.localizedDescription)")
|
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
|
||||||
return ProjectRegistry(projects: [])
|
return ProjectRegistry(projects: [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveRegistry(_ registry: ProjectRegistry) {
|
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
|
||||||
|
///
|
||||||
|
/// **Throws** on every non-success path — the previous version of
|
||||||
|
/// this method silently swallowed `createDirectory` and `writeFile`
|
||||||
|
/// failures with `try?`, which meant the installer could return a
|
||||||
|
/// valid-looking `ProjectEntry` while the registry on disk never
|
||||||
|
/// received the new row (project would complete install, show a
|
||||||
|
/// success screen, then be invisible in the sidebar). Callers that
|
||||||
|
/// want fire-and-forget behaviour can still use `try?`, but the
|
||||||
|
/// choice is now theirs.
|
||||||
|
func saveRegistry(_ registry: ProjectRegistry) throws {
|
||||||
let dir = context.paths.scarfDir
|
let dir = context.paths.scarfDir
|
||||||
if !transport.fileExists(dir) {
|
if !transport.fileExists(dir) {
|
||||||
do {
|
try transport.createDirectory(dir)
|
||||||
try transport.createDirectory(dir)
|
|
||||||
} catch {
|
|
||||||
print("[Scarf] Failed to create scarf directory: \(error.localizedDescription)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
guard let data = try? JSONEncoder().encode(registry) else { return }
|
let data = try JSONEncoder().encode(registry)
|
||||||
// Pretty-print for readability (agents may read this file)
|
// Pretty-print for readability (agents may read this file).
|
||||||
let writeData: Data
|
let writeData: Data
|
||||||
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
if let pretty = try? JSONSerialization.jsonObject(with: data),
|
||||||
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
|
||||||
@@ -43,7 +50,7 @@ struct ProjectDashboardService: Sendable {
|
|||||||
} else {
|
} else {
|
||||||
writeData = data
|
writeData = data
|
||||||
}
|
}
|
||||||
try? transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Dashboard
|
// MARK: - Dashboard
|
||||||
|
|||||||
@@ -179,7 +179,17 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
}
|
}
|
||||||
args.append(job.schedule)
|
args.append(job.schedule)
|
||||||
if let prompt = job.prompt, !prompt.isEmpty {
|
if let prompt = job.prompt, !prompt.isEmpty {
|
||||||
args.append(prompt)
|
// Substitute template-author tokens with install-time
|
||||||
|
// values. Hermes doesn't set a CWD for cron runs — when
|
||||||
|
// the agent fires the prompt, any relative path
|
||||||
|
// (`.scarf/config.json`, `status-log.md`, etc.) resolves
|
||||||
|
// against the agent's own dir, not the project. Templates
|
||||||
|
// use `{{PROJECT_DIR}}` as a placeholder for the absolute
|
||||||
|
// path; we swap in the real project dir here so the
|
||||||
|
// registered cron job carries a fully-qualified prompt
|
||||||
|
// that works regardless of CWD.
|
||||||
|
let resolvedPrompt = Self.substituteCronTokens(prompt, plan: plan)
|
||||||
|
args.append(resolvedPrompt)
|
||||||
}
|
}
|
||||||
|
|
||||||
let (output, exit) = context.runHermes(args)
|
let (output, exit) = context.runHermes(args)
|
||||||
@@ -211,10 +221,45 @@ struct ProjectTemplateInstaller: Sendable {
|
|||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
|
||||||
registry.projects.append(entry)
|
registry.projects.append(entry)
|
||||||
service.saveRegistry(registry)
|
// Must throw on failure — silent failure here used to make the
|
||||||
|
// installer return a valid entry while the registry on disk
|
||||||
|
// never got updated, producing the "install completed but the
|
||||||
|
// project doesn't show up in the sidebar" bug. If the registry
|
||||||
|
// write fails, the whole install is surfaced as failed so the
|
||||||
|
// user can see + address the underlying problem.
|
||||||
|
try service.saveRegistry(registry)
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Token substitution (install-time placeholder resolution)
|
||||||
|
|
||||||
|
/// Supported placeholders for template-author prompts. Keep the set
|
||||||
|
/// intentionally small — every token here becomes a load-bearing
|
||||||
|
/// part of the template format that we can't rename without
|
||||||
|
/// breaking existing bundles.
|
||||||
|
///
|
||||||
|
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
|
||||||
|
/// directory. Required for cron prompts because Hermes doesn't
|
||||||
|
/// establish a CWD when firing cron jobs; relative paths would
|
||||||
|
/// resolve against whatever dir Hermes happens to be in.
|
||||||
|
///
|
||||||
|
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
|
||||||
|
/// Less load-bearing; occasionally useful for tagging or
|
||||||
|
/// delivery targets that reference the template.
|
||||||
|
///
|
||||||
|
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
|
||||||
|
/// for the skills namespace and project dir name.
|
||||||
|
nonisolated static func substituteCronTokens(
|
||||||
|
_ prompt: String,
|
||||||
|
plan: TemplateInstallPlan
|
||||||
|
) -> String {
|
||||||
|
var out = prompt
|
||||||
|
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
|
||||||
|
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
|
||||||
|
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Lock file
|
// MARK: - Lock file
|
||||||
|
|
||||||
nonisolated private func writeLockFile(
|
nonisolated private func writeLockFile(
|
||||||
|
|||||||
@@ -206,7 +206,17 @@ struct ProjectTemplateUninstaller: Sendable {
|
|||||||
let dashboardService = ProjectDashboardService(context: context)
|
let dashboardService = ProjectDashboardService(context: context)
|
||||||
var registry = dashboardService.loadRegistry()
|
var registry = dashboardService.loadRegistry()
|
||||||
registry.projects.removeAll { $0.path == plan.project.path }
|
registry.projects.removeAll { $0.path == plan.project.path }
|
||||||
dashboardService.saveRegistry(registry)
|
// saveRegistry throws now — log a write failure but don't abort
|
||||||
|
// the uninstall. Every earlier step already completed (files
|
||||||
|
// removed, skills removed, cron jobs removed, memory stripped,
|
||||||
|
// Keychain cleared); failing here leaves a stale registry row
|
||||||
|
// pointing at a deleted project — cosmetic and easy to fix
|
||||||
|
// from the sidebar.
|
||||||
|
do {
|
||||||
|
try dashboardService.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
Self.logger.warning("uninstall couldn't rewrite projects registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
|
||||||
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,61 @@ final class CronViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runNow(_ job: HermesCronJob) {
|
func runNow(_ job: HermesCronJob) {
|
||||||
runAndReload(["cron", "run", job.id], success: "Scheduled for next tick")
|
// `hermes cron run <id>` only marks the job as due on the next
|
||||||
|
// scheduler tick — it doesn't actually execute. If the Hermes
|
||||||
|
// gateway's scheduler isn't running (common during dev + right
|
||||||
|
// after install), the user's "Run now" click results in zero
|
||||||
|
// visible effect because the tick never comes. We follow up
|
||||||
|
// with `hermes cron tick` which runs all due jobs once and
|
||||||
|
// exits. Redundant-but-harmless when the gateway is running;
|
||||||
|
// the actual trigger when it isn't.
|
||||||
|
//
|
||||||
|
// Feedback model: show a "Agent started" toast as soon as
|
||||||
|
// `cron run` succeeds, WITHOUT waiting for `cron tick` to
|
||||||
|
// return. Agent jobs routinely run past a minute (network IO +
|
||||||
|
// an LLM call + a file rewrite), and earlier versions with a
|
||||||
|
// 60s tick timeout surfaced a misleading "Run failed" toast
|
||||||
|
// every time while the job kept running in the background.
|
||||||
|
// The app's HermesFileWatcher picks up the dashboard.json
|
||||||
|
// rewrite that the agent lands at the end — that's what the
|
||||||
|
// user actually watches for, not this toast.
|
||||||
|
let svc = fileService
|
||||||
|
let jobID = job.id
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
let runResult = svc.runHermesCLI(args: ["cron", "run", jobID], timeout: 30)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if runResult.exitCode != 0 {
|
||||||
|
self.message = "Run failed to queue: \(runResult.output.prefix(200))"
|
||||||
|
self.logger.warning("cron run failed: \(runResult.output)")
|
||||||
|
self.load()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.message = nil
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.message = "Agent started — dashboard will update when it finishes"
|
||||||
|
self.load()
|
||||||
|
}
|
||||||
|
// `cron run` is queued; now force the tick. The 300s
|
||||||
|
// timeout catches truly stuck processes without killing
|
||||||
|
// the long-but-valid agent case that blew up the 60s
|
||||||
|
// version. A timeout here is survivable — the Hermes
|
||||||
|
// scheduler re-runs due jobs on its own cadence — so we
|
||||||
|
// log but don't surface it as a failure toast.
|
||||||
|
try? await Task.sleep(for: .milliseconds(250))
|
||||||
|
let tickResult = svc.runHermesCLI(args: ["cron", "tick"], timeout: 300)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
if tickResult.exitCode != 0 {
|
||||||
|
self.logger.warning("cron tick exited non-zero (job may still complete via scheduler): \(tickResult.output)")
|
||||||
|
}
|
||||||
|
self.load()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||||
|
self?.message = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteJob(_ job: HermesCronJob) {
|
func deleteJob(_ job: HermesCronJob) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class ProjectsViewModel {
|
final class ProjectsViewModel {
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ProjectsViewModel")
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let service: ProjectDashboardService
|
private let service: ProjectDashboardService
|
||||||
|
|
||||||
@@ -39,7 +41,19 @@ final class ProjectsViewModel {
|
|||||||
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
guard !registry.projects.contains(where: { $0.name == name }) else { return }
|
||||||
let entry = ProjectEntry(name: name, path: path)
|
let entry = ProjectEntry(name: name, path: path)
|
||||||
registry.projects.append(entry)
|
registry.projects.append(entry)
|
||||||
service.saveRegistry(registry)
|
// saveRegistry throws now. The VM doesn't currently have a
|
||||||
|
// surface for user-visible errors (there's no alert/toast in
|
||||||
|
// the Projects view), so log at error level to the unified
|
||||||
|
// log and keep the in-memory state consistent with whatever
|
||||||
|
// landed on disk. If the write fails, the added entry won't
|
||||||
|
// persist across launches — the user sees it appear + work
|
||||||
|
// this session, then it's gone at relaunch. Not ideal, but
|
||||||
|
// matches today's UX and flagged for a proper alert later.
|
||||||
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("addProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
selectProject(entry)
|
selectProject(entry)
|
||||||
}
|
}
|
||||||
@@ -47,7 +61,11 @@ final class ProjectsViewModel {
|
|||||||
func removeProject(_ project: ProjectEntry) {
|
func removeProject(_ project: ProjectEntry) {
|
||||||
var registry = service.loadRegistry()
|
var registry = service.loadRegistry()
|
||||||
registry.projects.removeAll { $0.name == project.name }
|
registry.projects.removeAll { $0.name == project.name }
|
||||||
service.saveRegistry(registry)
|
do {
|
||||||
|
try service.saveRegistry(registry)
|
||||||
|
} catch {
|
||||||
|
logger.error("removeProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
projects = registry.projects
|
projects = registry.projects
|
||||||
if selectedProject?.name == project.name {
|
if selectedProject?.name == project.name {
|
||||||
selectedProject = nil
|
selectedProject = nil
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ struct ProjectsView: View {
|
|||||||
@State private var installURLInput = ""
|
@State private var installURLInput = ""
|
||||||
@State private var showingUninstallSheet = false
|
@State private var showingUninstallSheet = false
|
||||||
@State private var configEditorProject: ProjectEntry?
|
@State private var configEditorProject: ProjectEntry?
|
||||||
|
/// Project queued for the "remove from list" confirmation dialog.
|
||||||
|
/// Non-nil while the dialog is up; the `confirmationDialog` binding
|
||||||
|
/// flips based on presence. We store the full entry (not just a
|
||||||
|
/// flag) so the dialog's action closure knows which project to
|
||||||
|
/// drop from the registry.
|
||||||
|
@State private var pendingRemoveFromList: ProjectEntry?
|
||||||
|
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -121,6 +127,44 @@ struct ProjectsView: View {
|
|||||||
project: project
|
project: project
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Confirmation dialog for the sidebar's "Remove from List" action.
|
||||||
|
// The action is registry-only (doesn't touch disk), but the name
|
||||||
|
// historically confused users into thinking it was a full delete.
|
||||||
|
// A confirmation with explicit wording clarifies scope before the
|
||||||
|
// click is destructive-looking but actually harmless.
|
||||||
|
.confirmationDialog(
|
||||||
|
removeFromListDialogTitle,
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { pendingRemoveFromList != nil },
|
||||||
|
set: { if !$0 { pendingRemoveFromList = nil } }
|
||||||
|
),
|
||||||
|
titleVisibility: .visible,
|
||||||
|
presenting: pendingRemoveFromList
|
||||||
|
) { project in
|
||||||
|
Button("Remove from List") {
|
||||||
|
viewModel.removeProject(project)
|
||||||
|
if coordinator.selectedProjectName == project.name {
|
||||||
|
coordinator.selectedProjectName = nil
|
||||||
|
}
|
||||||
|
pendingRemoveFromList = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
pendingRemoveFromList = nil
|
||||||
|
}
|
||||||
|
} message: { project in
|
||||||
|
Text(
|
||||||
|
"\(project.name) will be removed from Scarf's project list. " +
|
||||||
|
"Nothing on disk is touched — the folder, cron job, skills, and memory block all stay. " +
|
||||||
|
"To actually remove installed files, use \"Uninstall Template…\" instead."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Title string for the remove-from-list confirmation dialog. Kept
|
||||||
|
/// as a computed property so the dialog and any future reuse share
|
||||||
|
/// the exact same copy.
|
||||||
|
private var removeFromListDialogTitle: LocalizedStringKey {
|
||||||
|
"Remove from Scarf's project list?"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Toolbar
|
// MARK: - Toolbar
|
||||||
@@ -242,14 +286,23 @@ struct ProjectsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if uninstaller.isTemplateInstalled(project: project) {
|
if uninstaller.isTemplateInstalled(project: project) {
|
||||||
Button("Uninstall Template…", systemImage: "trash") {
|
// "Uninstall Template…" only appears for projects
|
||||||
|
// installed from a `.scarftemplate`. Trailing
|
||||||
|
// ellipsis signals a confirmation sheet follows
|
||||||
|
// (macOS HIG convention); the sheet itself lists
|
||||||
|
// every file/cron/skill that will be removed.
|
||||||
|
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||||
uninstallerViewModel.begin(project: project)
|
uninstallerViewModel.begin(project: project)
|
||||||
showingUninstallSheet = true
|
showingUninstallSheet = true
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
}
|
}
|
||||||
Button("Remove from Scarf", systemImage: "minus.circle") {
|
// "Remove from List" used to be "Remove from Scarf",
|
||||||
viewModel.removeProject(project)
|
// which users read as a full delete. Clarified label +
|
||||||
|
// ellipsis + confirmation dialog all spell out that
|
||||||
|
// this is registry-only; nothing on disk is touched.
|
||||||
|
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||||
|
pendingRemoveFromList = project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,10 +316,16 @@ struct ProjectsView: View {
|
|||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
Spacer()
|
Spacer()
|
||||||
if let selected = viewModel.selectedProject {
|
if let selected = viewModel.selectedProject {
|
||||||
Button(action: { viewModel.removeProject(selected) }) {
|
// Route through the same confirmation dialog as the
|
||||||
|
// context-menu "Remove from List" entry. The minus
|
||||||
|
// icon is a drive-by click target right next to "+" —
|
||||||
|
// confirming before mutating the registry stops the
|
||||||
|
// "I clicked by accident and my project's gone" case.
|
||||||
|
Button(action: { pendingRemoveFromList = selected }) {
|
||||||
Image(systemName: "minus")
|
Image(systemName: "minus")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(8)
|
.padding(8)
|
||||||
|
|||||||
@@ -17,6 +17,26 @@ final class TemplateUninstallerViewModel {
|
|||||||
case failed(String)
|
case failed(String)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot of "what survived the uninstall" — surfaced in the
|
||||||
|
/// success screen so the user understands why the project directory
|
||||||
|
/// is or isn't gone from disk. Computed from the plan right before
|
||||||
|
/// executing it (`plan` itself is nil'd on success, so we can't
|
||||||
|
/// reach back for this info after the fact).
|
||||||
|
struct PreservedOutcome: Sendable {
|
||||||
|
/// True when the uninstaller removed the project dir (nothing
|
||||||
|
/// user-owned was left inside). In this case `preservedPaths`
|
||||||
|
/// is empty and the success view skips the banner entirely.
|
||||||
|
let projectDirRemoved: Bool
|
||||||
|
/// Absolute paths of files the uninstaller refused to touch
|
||||||
|
/// because they weren't installed by the template (typically
|
||||||
|
/// `status-log.md` after the cron ran, or anything the user
|
||||||
|
/// dropped into the project dir manually).
|
||||||
|
let preservedPaths: [String]
|
||||||
|
/// Project dir — echoed back so the success view can show the
|
||||||
|
/// user where the orphan files now live.
|
||||||
|
let projectDir: String
|
||||||
|
}
|
||||||
|
|
||||||
let context: ServerContext
|
let context: ServerContext
|
||||||
private let uninstaller: ProjectTemplateUninstaller
|
private let uninstaller: ProjectTemplateUninstaller
|
||||||
|
|
||||||
@@ -27,11 +47,15 @@ final class TemplateUninstallerViewModel {
|
|||||||
|
|
||||||
var stage: Stage = .idle
|
var stage: Stage = .idle
|
||||||
var plan: TemplateUninstallPlan?
|
var plan: TemplateUninstallPlan?
|
||||||
|
/// Populated on transition to `.succeeded`. Nil whenever the user
|
||||||
|
/// re-enters the flow (cancel/begin both clear it).
|
||||||
|
var preservedOutcome: PreservedOutcome?
|
||||||
|
|
||||||
/// Load the `template.lock.json` for the given project and build a
|
/// Load the `template.lock.json` for the given project and build a
|
||||||
/// removal plan. Moves stage to `.planned` on success.
|
/// removal plan. Moves stage to `.planned` on success.
|
||||||
func begin(project: ProjectEntry) {
|
func begin(project: ProjectEntry) {
|
||||||
stage = .loading
|
stage = .loading
|
||||||
|
preservedOutcome = nil
|
||||||
let uninstaller = uninstaller
|
let uninstaller = uninstaller
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
@@ -53,11 +77,20 @@ final class TemplateUninstallerViewModel {
|
|||||||
guard let plan else { return }
|
guard let plan else { return }
|
||||||
stage = .uninstalling
|
stage = .uninstalling
|
||||||
let uninstaller = uninstaller
|
let uninstaller = uninstaller
|
||||||
|
// Capture the preservation shape before executing — the plan
|
||||||
|
// itself gets nil'd on success and we want the banner to show
|
||||||
|
// whatever was true at the moment of removal.
|
||||||
|
let outcome = PreservedOutcome(
|
||||||
|
projectDirRemoved: plan.projectDirBecomesEmpty,
|
||||||
|
preservedPaths: plan.extraProjectEntries,
|
||||||
|
projectDir: plan.project.path
|
||||||
|
)
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
do {
|
do {
|
||||||
try uninstaller.uninstall(plan: plan)
|
try uninstaller.uninstall(plan: plan)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
self.preservedOutcome = outcome
|
||||||
self.stage = .succeeded(removed: plan.project)
|
self.stage = .succeeded(removed: plan.project)
|
||||||
self.plan = nil
|
self.plan = nil
|
||||||
}
|
}
|
||||||
@@ -71,6 +104,7 @@ final class TemplateUninstallerViewModel {
|
|||||||
|
|
||||||
func cancel() {
|
func cancel() {
|
||||||
plan = nil
|
plan = nil
|
||||||
|
preservedOutcome = nil
|
||||||
stage = .idle
|
stage = .idle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,16 +68,26 @@ struct TemplateConfigSheet: View {
|
|||||||
private var footer: some View {
|
private var footer: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
|
// Caller owns dismissal — this view is used both as a
|
||||||
|
// standalone sheet (ConfigEditorSheet, where the caller
|
||||||
|
// wants dismissal) AND inlined inside the install sheet
|
||||||
|
// (TemplateInstallSheet.configureView, where calling
|
||||||
|
// .dismiss here would tear down the OUTER install sheet
|
||||||
|
// and abort the flow before .planned is reached).
|
||||||
onCancel()
|
onCancel()
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.cancelAction)
|
.keyboardShortcut(.cancelAction)
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(commitLabel) {
|
Button(commitLabel) {
|
||||||
if let finalized = viewModel.commit(project: project) {
|
if let finalized = viewModel.commit(project: project) {
|
||||||
onCommit(finalized)
|
onCommit(finalized)
|
||||||
dismiss()
|
|
||||||
}
|
}
|
||||||
|
// Same dismissal-is-caller's-responsibility rule as
|
||||||
|
// Cancel — inside the install sheet, onCommit transitions
|
||||||
|
// stage to .planned and the outer view re-renders to
|
||||||
|
// show the preview. In the edit sheet, onCommit
|
||||||
|
// transitions the editor VM and its state machine
|
||||||
|
// handles dismissal via the success view's Done button.
|
||||||
}
|
}
|
||||||
.keyboardShortcut(.defaultAction)
|
.keyboardShortcut(.defaultAction)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
@@ -103,7 +113,11 @@ struct TemplateConfigSheet: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if let description = field.description, !description.isEmpty {
|
if let description = field.description, !description.isEmpty {
|
||||||
Text(description)
|
// Inline markdown so descriptions can include
|
||||||
|
// `[Create one](https://…)`-style links to token
|
||||||
|
// generation pages, **bold** emphasis on important
|
||||||
|
// prerequisites, etc.
|
||||||
|
TemplateMarkdown.inlineText(description)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|||||||
@@ -175,7 +175,10 @@ struct TemplateInstallSheet: View {
|
|||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Text(manifest.description)
|
// Inline-only markdown — descriptions are a sentence or two;
|
||||||
|
// bold/italic/code/links are all that reasonable template
|
||||||
|
// authors use there.
|
||||||
|
TemplateMarkdown.inlineText(manifest.description)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
if let author = manifest.author {
|
if let author = manifest.author {
|
||||||
@@ -220,16 +223,40 @@ struct TemplateInstallSheet: View {
|
|||||||
|
|
||||||
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
private func cronSection(plan: TemplateInstallPlan) -> some View {
|
||||||
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
section(title: "Cron jobs (created disabled — you can enable each one manually)", subtitle: nil) {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
ForEach(plan.cronJobs, id: \.name) { job in
|
ForEach(plan.cronJobs, id: \.name) { job in
|
||||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Image(systemName: "clock.arrow.circlepath")
|
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||||
.foregroundStyle(.secondary)
|
Image(systemName: "clock.arrow.circlepath")
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text(job.name).font(.callout.monospaced())
|
|
||||||
Text("schedule: \(job.schedule)")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(job.name).font(.callout.monospaced())
|
||||||
|
Text("schedule: \(job.schedule)")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prompt preview — disclosed in an expandable
|
||||||
|
// group so the preview stays compact when the
|
||||||
|
// user doesn't care to read it. Markdown-rendered
|
||||||
|
// so prompts that include `code`, **bold**, or
|
||||||
|
// enumerated steps look right. Tokens like
|
||||||
|
// {{PROJECT_DIR}} are still visible here — they
|
||||||
|
// get substituted when the installer calls
|
||||||
|
// `hermes cron create`.
|
||||||
|
if let prompt = job.prompt, !prompt.isEmpty {
|
||||||
|
DisclosureGroup("Prompt") {
|
||||||
|
ScrollView {
|
||||||
|
TemplateMarkdown.render(prompt)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 140)
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.4))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.leading, 26)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,11 +329,10 @@ struct TemplateInstallSheet: View {
|
|||||||
if let readme = viewModel.readmeBody {
|
if let readme = viewModel.readmeBody {
|
||||||
section(title: "README", subtitle: nil) {
|
section(title: "README", subtitle: nil) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
Text(readme)
|
TemplateMarkdown.render(readme)
|
||||||
.font(.callout)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
.frame(maxHeight: 200)
|
.frame(maxHeight: 260)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Minimal markdown renderer used by the template install/config UIs.
|
||||||
|
///
|
||||||
|
/// SwiftUI `Text` has built-in inline-markdown support via
|
||||||
|
/// `AttributedString(markdown:)` — bold, italic, inline code, links.
|
||||||
|
/// That's enough for field descriptions + template taglines. For
|
||||||
|
/// longer content (README preview, full doc blocks), this helper adds
|
||||||
|
/// block-level handling: lines starting with `#`/`##`/`###` render
|
||||||
|
/// as bigger bold text; lines starting with `-`/`*`/`1.` render as
|
||||||
|
/// list items with a hanging indent; fenced ``` ``` blocks render as
|
||||||
|
/// monospaced; blank lines become paragraph breaks.
|
||||||
|
///
|
||||||
|
/// Scope is intentionally small. This isn't a full CommonMark
|
||||||
|
/// renderer — it's "enough markdown to make template READMEs look
|
||||||
|
/// right in the install sheet without pulling in a dependency." If
|
||||||
|
/// the set of templates needs more over time, evolve this file or
|
||||||
|
/// graduate to a proper library.
|
||||||
|
enum TemplateMarkdown {
|
||||||
|
|
||||||
|
/// Render a markdown source string as a SwiftUI view. Preserves
|
||||||
|
/// reading order and approximate visual hierarchy. Safe with
|
||||||
|
/// untrusted input — we never execute HTML or scripts.
|
||||||
|
@ViewBuilder
|
||||||
|
static func render(_ source: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
let blocks = parse(source)
|
||||||
|
ForEach(blocks.indices, id: \.self) { i in
|
||||||
|
block(blocks[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inline-only markdown (bold/italic/code/links) as a single
|
||||||
|
/// `Text`. Use for short strings where block structure doesn't
|
||||||
|
/// apply — field labels, one-line descriptions.
|
||||||
|
static func inlineText(_ source: String) -> Text {
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return Text(attr)
|
||||||
|
}
|
||||||
|
return Text(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Block model
|
||||||
|
|
||||||
|
fileprivate enum Block {
|
||||||
|
case paragraph(AttributedString)
|
||||||
|
case heading(level: Int, text: AttributedString)
|
||||||
|
case bullet(AttributedString)
|
||||||
|
case numbered(index: Int, text: AttributedString)
|
||||||
|
case code(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Parser
|
||||||
|
|
||||||
|
fileprivate static func parse(_ source: String) -> [Block] {
|
||||||
|
var blocks: [Block] = []
|
||||||
|
var lines = source.components(separatedBy: "\n")
|
||||||
|
var i = 0
|
||||||
|
while i < lines.count {
|
||||||
|
let line = lines[i]
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Fenced code block.
|
||||||
|
if trimmed.hasPrefix("```") {
|
||||||
|
var body: [String] = []
|
||||||
|
i += 1
|
||||||
|
while i < lines.count {
|
||||||
|
let inner = lines[i]
|
||||||
|
if inner.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
|
||||||
|
i += 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
body.append(inner)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
blocks.append(.code(body.joined(separator: "\n")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading.
|
||||||
|
if let headingMatch = trimmed.firstMatch(of: /^(#{1,6})\s+(.*)$/) {
|
||||||
|
let level = (headingMatch.1).count
|
||||||
|
let text = String(headingMatch.2)
|
||||||
|
blocks.append(.heading(level: level, text: renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bullet list.
|
||||||
|
if let bulletMatch = line.firstMatch(of: /^\s*[-*]\s+(.*)$/) {
|
||||||
|
let text = String(bulletMatch.1)
|
||||||
|
blocks.append(.bullet(renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Numbered list.
|
||||||
|
if let numMatch = line.firstMatch(of: /^\s*(\d+)\.\s+(.*)$/) {
|
||||||
|
let index = Int(String(numMatch.1)) ?? 1
|
||||||
|
let text = String(numMatch.2)
|
||||||
|
blocks.append(.numbered(index: index, text: renderInline(text)))
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blank line — skip.
|
||||||
|
if trimmed.isEmpty {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph — collect contiguous non-blank lines that
|
||||||
|
// aren't headings/lists/fences into one paragraph block.
|
||||||
|
var paragraphLines: [String] = [line]
|
||||||
|
i += 1
|
||||||
|
while i < lines.count {
|
||||||
|
let next = lines[i]
|
||||||
|
let nextTrim = next.trimmingCharacters(in: .whitespaces)
|
||||||
|
if nextTrim.isEmpty { break }
|
||||||
|
if nextTrim.hasPrefix("```") { break }
|
||||||
|
if nextTrim.firstMatch(of: /^#{1,6}\s/) != nil { break }
|
||||||
|
if next.firstMatch(of: /^\s*[-*]\s+/) != nil { break }
|
||||||
|
if next.firstMatch(of: /^\s*\d+\.\s+/) != nil { break }
|
||||||
|
paragraphLines.append(next)
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
let joined = paragraphLines.joined(separator: " ")
|
||||||
|
blocks.append(.paragraph(renderInline(joined)))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse inline markdown (bold, italic, inline code, links) into
|
||||||
|
/// an AttributedString. Falls back to plain text on parse failure.
|
||||||
|
fileprivate static func renderInline(_ source: String) -> AttributedString {
|
||||||
|
if let attr = try? AttributedString(
|
||||||
|
markdown: source,
|
||||||
|
options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
|
||||||
|
) {
|
||||||
|
return attr
|
||||||
|
}
|
||||||
|
return AttributedString(source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Rendering
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
fileprivate static func block(_ b: Block) -> some View {
|
||||||
|
switch b {
|
||||||
|
case .paragraph(let text):
|
||||||
|
Text(text)
|
||||||
|
.font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
case .heading(let level, let text):
|
||||||
|
headingText(text: text, level: level)
|
||||||
|
case .bullet(let text):
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("•").font(.callout)
|
||||||
|
Text(text).font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
case .numbered(let index, let text):
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||||
|
Text("\(index).").font(.callout.monospacedDigit())
|
||||||
|
Text(text).font(.callout)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
case .code(let src):
|
||||||
|
Text(src)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.quaternary.opacity(0.5))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
fileprivate static func headingText(text: AttributedString, level: Int) -> some View {
|
||||||
|
switch level {
|
||||||
|
case 1: Text(text).font(.title2.bold()).padding(.top, 8)
|
||||||
|
case 2: Text(text).font(.title3.bold()).padding(.top, 6)
|
||||||
|
case 3: Text(text).font(.headline).padding(.top, 4)
|
||||||
|
default: Text(text).font(.subheadline.bold()).padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,6 +277,19 @@ struct TemplateUninstallSheet: View {
|
|||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("Removed \(removed.name)")
|
Text("Removed \(removed.name)")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
|
|
||||||
|
// Preserved-files banner. Only renders when the project dir
|
||||||
|
// stayed and at least one file was left behind — that's the
|
||||||
|
// case the user keeps getting surprised by ("I uninstalled
|
||||||
|
// but my project folder is still there?"). Explicit
|
||||||
|
// explanation + file list makes it obvious the files the
|
||||||
|
// user (or the cron job) created are intentionally kept.
|
||||||
|
if let outcome = viewModel.preservedOutcome,
|
||||||
|
outcome.projectDirRemoved == false,
|
||||||
|
outcome.preservedPaths.isEmpty == false {
|
||||||
|
preservedFilesBanner(outcome: outcome)
|
||||||
|
}
|
||||||
|
|
||||||
Button("Done") {
|
Button("Done") {
|
||||||
onCompleted(removed)
|
onCompleted(removed)
|
||||||
dismiss()
|
dismiss()
|
||||||
@@ -285,6 +298,53 @@ struct TemplateUninstallSheet: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orange informational banner listing the files the uninstaller
|
||||||
|
/// left in the project directory. Caps the visible list at 8 rows
|
||||||
|
/// with a "+N more…" tail so a long log (many runs = many status
|
||||||
|
/// file entries) doesn't blow out the sheet height.
|
||||||
|
private func preservedFilesBanner(
|
||||||
|
outcome: TemplateUninstallerViewModel.PreservedOutcome
|
||||||
|
) -> some View {
|
||||||
|
let visible = Array(outcome.preservedPaths.prefix(8))
|
||||||
|
let overflow = outcome.preservedPaths.count - visible.count
|
||||||
|
return VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "folder.badge.questionmark")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
Text("Project folder kept")
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
Text("These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
ForEach(visible, id: \.self) { path in
|
||||||
|
Text(path)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.head)
|
||||||
|
}
|
||||||
|
if overflow > 0 {
|
||||||
|
Text("+ \(overflow) more…")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("Delete \(outcome.projectDir) from Finder if you don't need these files anymore.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 520, alignment: .leading)
|
||||||
|
.padding(12)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color.orange.opacity(0.10))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func failureView(message: String) -> some View {
|
private func failureView(message: String) -> some View {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@
|
|||||||
},
|
},
|
||||||
"(%lld tokens)" : {
|
"(%lld tokens)" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"*" : {
|
||||||
|
"comment" : "A required asterisk.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"/%@" : {
|
"/%@" : {
|
||||||
|
|
||||||
@@ -885,6 +889,10 @@
|
|||||||
},
|
},
|
||||||
"••••••••••" : {
|
"••••••••••" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"+ %lld more…" : {
|
||||||
|
"comment" : "A button that shows the number of files that were left behind by the template uninstaller.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"<%@>" : {
|
"<%@>" : {
|
||||||
|
|
||||||
@@ -2229,6 +2237,9 @@
|
|||||||
"already gone" : {
|
"already gone" : {
|
||||||
"comment" : "A tag for a file that is already gone (no longer in the template).",
|
"comment" : "A tag for a file that is already gone (no longer in the template).",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Also works: %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"API Key" : {
|
"API Key" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -5024,6 +5035,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Configuration saved" : {
|
||||||
|
"comment" : "A title displayed when a configuration is saved.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Configuration…" : {
|
||||||
|
"comment" : "A contextual menu item that opens a configuration editor for a project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Configure" : {
|
"Configure" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5064,6 +5083,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Configure %@" : {
|
||||||
|
"comment" : "The title of the configuration sheet. The argument is the name of the template.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Connect timeout" : {
|
"Connect timeout" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5304,6 +5327,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Continue" : {
|
||||||
|
"comment" : "Button label for continuing with the template configuration.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Continue Last Session" : {
|
"Continue Last Session" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -5584,6 +5611,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Couldn't save" : {
|
||||||
|
"comment" : "A title displayed when a configuration save fails.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Create" : {
|
"Create" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -6637,6 +6668,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Delete %@ from Finder if you don't need these files anymore." : {
|
||||||
|
"comment" : "A note that lets the user delete",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Delete %@?" : {
|
"Delete %@?" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -7657,6 +7692,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Edit configuration" : {
|
||||||
|
"comment" : "A button that opens a configuration editor for a project.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Edit User Profile" : {
|
"Edit User Profile" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -10548,6 +10587,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Internal state inconsistency — please close and re-open." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Invalid URL" : {
|
"Invalid URL" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -11156,6 +11198,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Loading configuration…" : {
|
||||||
|
"comment" : "A message displayed while loading the configuration.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Loading session…" : {
|
"Loading session…" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -12665,6 +12711,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"No configuration" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"No credential pools configured" : {
|
"No credential pools configured" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -12910,6 +12959,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"No fields" : {
|
||||||
|
"comment" : "A label that describes a template with no configuration fields.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"No headers configured." : {
|
"No headers configured." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -14258,6 +14311,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Opens on launch" : {
|
||||||
|
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Optional" : {
|
"Optional" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -15398,6 +15455,9 @@
|
|||||||
},
|
},
|
||||||
"Project directory will also be removed (nothing user-owned left inside)." : {
|
"Project directory will also be removed (nothing user-owned left inside)." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Project folder kept" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Project Name" : {
|
"Project Name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -16127,6 +16187,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Recommended model" : {
|
||||||
|
"comment" : "A label that indicates a recommended model.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Reconnect" : {
|
"Reconnect" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -16458,6 +16522,10 @@
|
|||||||
"comment" : "A label that instructs the user to remove a project from Scarf's list of installed projects.",
|
"comment" : "A label that instructs the user to remove a project from Scarf's list of installed projects.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Remove %@ from Scarf's project list (files are kept on disk)" : {
|
||||||
|
"comment" : "A confirmation dialog that",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Remove %@?" : {
|
"Remove %@?" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -16538,8 +16606,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Remove from Scarf" : {
|
"Remove from List" : {
|
||||||
"comment" : "A context menu option to remove a project from Scarf.",
|
"comment" : "A confirmation dialog that asks whether a user is sure they want to remove a project from Scarf's list.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remove from List (keep files)…" : {
|
||||||
|
"comment" : "A button that removes a project from Scarf's list, but not from disk.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Remove from Scarf's project list?" : {
|
||||||
|
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Remove the entire namespace dir recursively" : {
|
"Remove the entire namespace dir recursively" : {
|
||||||
@@ -18000,6 +18076,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Saved in Keychain — leave empty to keep the stored value." : {
|
||||||
|
"comment" : "A message that appears when a user has filled in a secret but has not yet saved it.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Saving…" : {
|
||||||
|
"comment" : "A label displayed while the configuration is being saved.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Scarf" : {
|
"Scarf" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -18043,6 +18127,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Scarf doesn't auto-switch your active model. Change it in Settings if you'd like." : {
|
||||||
|
"comment" : "A description of the warning about not switching models.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
"Scarf never prompts for passphrases. Add your key to ssh-agent in Terminal, then click Retry. If your key isn't `id_ed25519`, swap the path:" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -19291,6 +19379,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Set as default — open this server when Scarf launches." : {
|
||||||
|
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Settings" : {
|
"Settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -19694,6 +19786,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Show while typing" : {
|
||||||
|
"comment" : "A hint for the user on how to show/hide the secret.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
|
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -21499,6 +21595,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"These files weren't installed by the template (the agent or you created them after install), so Scarf left them in place along with the folder itself." : {
|
||||||
|
"comment" : "A description of the files Scarf left in place when uninstalling a template.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"These list fields must be edited directly in config.yaml." : {
|
"These list fields must be edited directly in config.yaml." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -21538,6 +21638,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"This Mac" : {
|
||||||
|
"comment" : "A description of the local machine.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"This project wasn't installed from a schemaful template." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"This provider has no catalogued models." : {
|
"This provider has no catalogued models." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -21739,6 +21846,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"This template has no configuration fields." : {
|
||||||
|
"comment" : "A description of a template with no configuration fields.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL." : {
|
"This uploads logs, config (with secrets redacted), and system info to Nous Research support infrastructure. Review the output below before sharing the returned URL." : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"de" : {
|
"de" : {
|
||||||
@@ -22518,8 +22629,8 @@
|
|||||||
"comment" : "A button that uninstalls a template.",
|
"comment" : "A button that uninstalls a template.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Uninstall Template…" : {
|
"Uninstall Template (remove installed files)…" : {
|
||||||
"comment" : "A contextual menu item that uninstalls a template.",
|
"comment" : "A button that removes a project's files from the system.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Unknown: %@" : {
|
"Unknown: %@" : {
|
||||||
@@ -23786,6 +23897,10 @@
|
|||||||
},
|
},
|
||||||
"Where should this project live?" : {
|
"Where should this project live?" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Will be saved to the Keychain on commit." : {
|
||||||
|
"comment" : "A description of a secret field that will be saved to the Keychain on commit.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Working" : {
|
"Working" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -2,6 +2,42 @@ import Testing
|
|||||||
import Foundation
|
import Foundation
|
||||||
@testable import scarf
|
@testable import scarf
|
||||||
|
|
||||||
|
/// Cross-suite serialization lock for tests that touch the real
|
||||||
|
/// `~/.hermes/scarf/projects.json`. Swift Testing's `.serialized` trait
|
||||||
|
/// only serializes tests WITHIN a suite — multiple suites still run in
|
||||||
|
/// parallel. Three suites in this file write to the same file and
|
||||||
|
/// previously raced each other silently (saveRegistry used to swallow
|
||||||
|
/// write failures); now that saveRegistry throws, the race surfaces.
|
||||||
|
///
|
||||||
|
/// The lock is acquired by `acquireAndSnapshot()` at the top of each
|
||||||
|
/// registry-touching test and released by `restore(_:)` via the test's
|
||||||
|
/// `defer`. Asymmetric acquire-in-one-fn / release-in-another looks
|
||||||
|
/// unusual but the snapshot/restore pairing is so tight (every test
|
||||||
|
/// defers the restore) that it's reliable in practice.
|
||||||
|
final class TestRegistryLock: @unchecked Sendable {
|
||||||
|
static let shared = TestRegistryLock()
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
/// Acquire the cross-suite lock and snapshot the registry. Pair
|
||||||
|
/// every call with a `defer { TestRegistryLock.restore(snapshot) }`.
|
||||||
|
static func acquireAndSnapshot() -> Data? {
|
||||||
|
shared.lock.lock()
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the registry from snapshot and release the lock.
|
||||||
|
static func restore(_ snapshot: Data?) {
|
||||||
|
defer { shared.lock.unlock() }
|
||||||
|
let path = ServerContext.local.paths.projectsRegistry
|
||||||
|
if let snapshot {
|
||||||
|
try? snapshot.write(to: URL(fileURLWithPath: path))
|
||||||
|
} else {
|
||||||
|
try? FileManager.default.removeItem(atPath: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Exercises the service's ability to unpack, parse, and validate bundles.
|
/// Exercises the service's ability to unpack, parse, and validate bundles.
|
||||||
/// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so
|
/// Doesn't touch the installer — see `ProjectTemplateInstallerTests` — so
|
||||||
/// these don't need write access to ~/.hermes.
|
/// these don't need write access to ~/.hermes.
|
||||||
@@ -346,23 +382,69 @@ import Foundation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cron prompt token substitution
|
||||||
|
|
||||||
|
@Test func substituteCronTokensResolvesProjectDir() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "Read {{PROJECT_DIR}}/.scarf/config.json"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
#expect(resolved == "Read \(plan.projectDir)/.scarf/config.json")
|
||||||
|
// Original placeholder must be fully replaced — a lingering
|
||||||
|
// {{PROJECT_DIR}} would leave the cron job trying to read a
|
||||||
|
// literal file named `{{PROJECT_DIR}}` which doesn't exist.
|
||||||
|
#expect(resolved.contains("{{PROJECT_DIR}}") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func substituteCronTokensResolvesIdAndSlug() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "Log as {{TEMPLATE_ID}} (slug {{TEMPLATE_SLUG}})"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
#expect(resolved.contains(plan.manifest.id))
|
||||||
|
#expect(resolved.contains(plan.manifest.slug))
|
||||||
|
#expect(resolved.contains("{{TEMPLATE_ID}}") == false)
|
||||||
|
#expect(resolved.contains("{{TEMPLATE_SLUG}}") == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func substituteCronTokensLeavesUnknownTokensUntouched() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "{{PROJECT_DIR}} but keep {{UNSUPPORTED}} literal"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
#expect(resolved.contains(plan.projectDir))
|
||||||
|
// Unsupported placeholders pass through verbatim — template
|
||||||
|
// authors will notice in testing that their token didn't get
|
||||||
|
// replaced and either use a supported one or request a new one.
|
||||||
|
#expect(resolved.contains("{{UNSUPPORTED}}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func substituteCronTokensRepeatsWithinString() throws {
|
||||||
|
let plan = try TemplateInstallerViewModelTests.makePlanWithConfigSchema()
|
||||||
|
let raw = "Read {{PROJECT_DIR}}/a and write {{PROJECT_DIR}}/b"
|
||||||
|
let resolved = ProjectTemplateInstaller.substituteCronTokens(raw, plan: plan)
|
||||||
|
// Both occurrences should be replaced — not just the first.
|
||||||
|
// A single-replace bug here would leave the second relative,
|
||||||
|
// causing the same CWD issue this whole feature was meant to
|
||||||
|
// fix.
|
||||||
|
let count = resolved.components(separatedBy: plan.projectDir).count - 1
|
||||||
|
#expect(count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Registry snapshot helpers
|
// MARK: - Registry snapshot helpers
|
||||||
|
|
||||||
/// Read the raw bytes of the current projects.json so we can restore
|
/// 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
|
/// it byte-for-byte after the test. `nil` means the file didn't exist
|
||||||
/// — restore by deleting whatever got created.
|
/// — restore by deleting whatever got created.
|
||||||
|
// Delegates to TestRegistryLock so tests across this suite + the
|
||||||
|
// two other registry-touching suites share one lock. Every
|
||||||
|
// `snapshotRegistry()` call acquires; the paired
|
||||||
|
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||||
|
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||||
|
// the saveRegistry throw surfaces the collision as a test failure.
|
||||||
nonisolated private static func snapshotRegistry() -> Data? {
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.acquireAndSnapshot()
|
||||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.restore(snapshot)
|
||||||
if let snapshot {
|
|
||||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
|
||||||
} else {
|
|
||||||
try? FileManager.default.removeItem(atPath: path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,18 +558,18 @@ import Foundation
|
|||||||
// ProjectTemplateInstallerTests — small helper, not worth a shared
|
// ProjectTemplateInstallerTests — small helper, not worth a shared
|
||||||
// fixture file for one more suite).
|
// fixture file for one more suite).
|
||||||
|
|
||||||
|
// Delegates to TestRegistryLock so tests across this suite + the
|
||||||
|
// two other registry-touching suites share one lock. Every
|
||||||
|
// `snapshotRegistry()` call acquires; the paired
|
||||||
|
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||||
|
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||||
|
// the saveRegistry throw surfaces the collision as a test failure.
|
||||||
nonisolated private static func snapshotRegistry() -> Data? {
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.acquireAndSnapshot()
|
||||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.restore(snapshot)
|
||||||
if let snapshot {
|
|
||||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
|
||||||
} else {
|
|
||||||
try? FileManager.default.removeItem(atPath: path)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,18 +835,123 @@ import Foundation
|
|||||||
|
|
||||||
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
// MARK: - Registry snapshot helpers (dup'd from ProjectTemplateInstallerTests)
|
||||||
|
|
||||||
|
// Delegates to TestRegistryLock so tests across this suite + the
|
||||||
|
// two other registry-touching suites share one lock. Every
|
||||||
|
// `snapshotRegistry()` call acquires; the paired
|
||||||
|
// `restoreRegistry(_:)` defer releases. Without this, parallel
|
||||||
|
// test runs race on `~/.hermes/scarf/projects.json` writes and
|
||||||
|
// the saveRegistry throw surfaces the collision as a test failure.
|
||||||
nonisolated private static func snapshotRegistry() -> Data? {
|
nonisolated private static func snapshotRegistry() -> Data? {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.acquireAndSnapshot()
|
||||||
return try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
nonisolated private static func restoreRegistry(_ snapshot: Data?) {
|
||||||
let path = ServerContext.local.paths.projectsRegistry
|
TestRegistryLock.restore(snapshot)
|
||||||
if let snapshot {
|
}
|
||||||
try? snapshot.write(to: URL(fileURLWithPath: path))
|
}
|
||||||
|
|
||||||
|
/// State-machine tests for `TemplateInstallerViewModel`. The install
|
||||||
|
/// flow's configure step is driven entirely through the VM — the view
|
||||||
|
/// transitions `.awaitingParentDirectory → .awaitingConfig → .planned`
|
||||||
|
/// based on `submitConfig(values:)` / `cancelConfig()` calls. If those
|
||||||
|
/// transitions break, the user lands on the wrong sheet stage (or no
|
||||||
|
/// sheet at all, as in the v1.1.0 regression where the config sheet's
|
||||||
|
/// internal `dismiss()` tore down the outer install sheet before
|
||||||
|
/// submitConfig had a chance to fire).
|
||||||
|
@Suite(.serialized) @MainActor struct TemplateInstallerViewModelTests {
|
||||||
|
|
||||||
|
@Test func submitConfigStashesValuesAndTransitionsToPlanned() throws {
|
||||||
|
let vm = TemplateInstallerViewModel(context: .local)
|
||||||
|
// Seed the VM with an awaiting-config plan (schema-ful).
|
||||||
|
let plan = try Self.makePlanWithConfigSchema()
|
||||||
|
vm.plan = plan
|
||||||
|
vm.stage = .awaitingConfig
|
||||||
|
|
||||||
|
let values: [String: TemplateConfigValue] = [
|
||||||
|
"site_url": .string("https://example.com")
|
||||||
|
]
|
||||||
|
vm.submitConfig(values: values)
|
||||||
|
|
||||||
|
// Stage must advance past the configure step, values must land
|
||||||
|
// on the plan where install() will pick them up.
|
||||||
|
if case .planned = vm.stage {
|
||||||
|
// ok
|
||||||
} else {
|
} else {
|
||||||
try? FileManager.default.removeItem(atPath: path)
|
Issue.record("expected .planned, got \(vm.stage)")
|
||||||
}
|
}
|
||||||
|
#expect(vm.plan?.configValues["site_url"] == .string("https://example.com"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func cancelConfigReturnsToAwaitingParentDirectory() throws {
|
||||||
|
let vm = TemplateInstallerViewModel(context: .local)
|
||||||
|
vm.plan = try Self.makePlanWithConfigSchema()
|
||||||
|
vm.stage = .awaitingConfig
|
||||||
|
|
||||||
|
vm.cancelConfig()
|
||||||
|
|
||||||
|
if case .awaitingParentDirectory = vm.stage {
|
||||||
|
// ok — user can re-pick the parent dir or fully cancel
|
||||||
|
} else {
|
||||||
|
Issue.record("expected .awaitingParentDirectory, got \(vm.stage)")
|
||||||
|
}
|
||||||
|
// Plan is preserved so re-entering the configure step doesn't
|
||||||
|
// re-run buildPlan.
|
||||||
|
#expect(vm.plan != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func submitConfigNoOpWhenPlanIsNil() {
|
||||||
|
let vm = TemplateInstallerViewModel(context: .local)
|
||||||
|
vm.plan = nil
|
||||||
|
vm.stage = .awaitingConfig
|
||||||
|
vm.submitConfig(values: ["k": .string("v")])
|
||||||
|
// With no plan, the call should be silent — no crash, stage
|
||||||
|
// stays where it was. (Defensive guard in submitConfig.)
|
||||||
|
if case .awaitingConfig = vm.stage {
|
||||||
|
// ok
|
||||||
|
} else {
|
||||||
|
Issue.record("expected stage to remain .awaitingConfig when plan is nil; got \(vm.stage)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fixture
|
||||||
|
|
||||||
|
/// Build a `TemplateInstallPlan` carrying a single-field config
|
||||||
|
/// schema. Exists as a local helper rather than a shared one
|
||||||
|
/// because no other suite needs it.
|
||||||
|
nonisolated static func makePlanWithConfigSchema() throws -> TemplateInstallPlan {
|
||||||
|
let schema = TemplateConfigSchema(
|
||||||
|
fields: [
|
||||||
|
.init(key: "site_url", type: .string, label: "Site URL",
|
||||||
|
description: nil, required: true, placeholder: nil,
|
||||||
|
defaultValue: nil, options: nil, minLength: nil,
|
||||||
|
maxLength: nil, pattern: nil, minNumber: nil,
|
||||||
|
maxNumber: nil, step: nil, itemType: nil,
|
||||||
|
minItems: nil, maxItems: nil)
|
||||||
|
],
|
||||||
|
modelRecommendation: nil
|
||||||
|
)
|
||||||
|
let manifest = ProjectTemplateServiceTests.sampleManifest(
|
||||||
|
id: "tester/vm-transitions",
|
||||||
|
configSchema: schema
|
||||||
|
)
|
||||||
|
let tmp = try ProjectTemplateServiceTests.makeTempDir()
|
||||||
|
// Not a real bundle dir — we never unzip or install from this
|
||||||
|
// plan, we only test state transitions that don't touch disk.
|
||||||
|
return TemplateInstallPlan(
|
||||||
|
manifest: manifest,
|
||||||
|
unpackedDir: tmp,
|
||||||
|
projectDir: tmp + "/project",
|
||||||
|
projectFiles: [],
|
||||||
|
skillsNamespaceDir: nil,
|
||||||
|
skillsFiles: [],
|
||||||
|
cronJobs: [],
|
||||||
|
memoryAppendix: nil,
|
||||||
|
memoryPath: ServerContext.local.paths.memoryMD,
|
||||||
|
projectRegistryName: "VM Transitions",
|
||||||
|
configSchema: schema,
|
||||||
|
configValues: [:],
|
||||||
|
manifestCachePath: tmp + "/project/.scarf/manifest.json"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -832,7 +1019,9 @@ import Foundation
|
|||||||
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
let dashboardData = try Data(contentsOf: URL(fileURLWithPath: dashboardPath))
|
||||||
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
let dashboard = try JSONDecoder().decode(ProjectDashboard.self, from: dashboardData)
|
||||||
#expect(dashboard.title == "Site Status")
|
#expect(dashboard.title == "Site Status")
|
||||||
#expect(dashboard.sections.count == 3)
|
// Four sections: Current Status (stats), Watched Sites (list),
|
||||||
|
// Live Site Preview (webview — drives the Site tab), How to Use (text).
|
||||||
|
#expect(dashboard.sections.count == 4)
|
||||||
|
|
||||||
// First section should have three stat widgets that the cron job
|
// First section should have three stat widgets that the cron job
|
||||||
// updates by value. Assert titles + types so the AGENTS.md contract
|
// updates by value. Assert titles + types so the AGENTS.md contract
|
||||||
@@ -844,15 +1033,34 @@ import Foundation
|
|||||||
#expect(statTitles.contains("Sites Down"))
|
#expect(statTitles.contains("Sites Down"))
|
||||||
#expect(statTitles.contains("Last Checked"))
|
#expect(statTitles.contains("Last Checked"))
|
||||||
|
|
||||||
|
// Live Site Preview section must contain exactly one webview
|
||||||
|
// widget. The presence of any webview widget is what makes Scarf
|
||||||
|
// expose the Site tab next to Dashboard, so losing this section
|
||||||
|
// would silently drop a user-visible feature. The cron job
|
||||||
|
// rewrites this widget's `url` to the first configured site on
|
||||||
|
// every run — AGENTS.md documents the contract.
|
||||||
|
let previewSection = dashboard.sections[2]
|
||||||
|
#expect(previewSection.title == "Live Site Preview")
|
||||||
|
let webviews = previewSection.widgets.filter { $0.type == "webview" }
|
||||||
|
#expect(webviews.count == 1)
|
||||||
|
#expect(webviews.first?.title == "First Watched Site")
|
||||||
|
#expect((webviews.first?.url ?? "").isEmpty == false)
|
||||||
|
|
||||||
// Cron prompt references .scarf/config.json (where values.sites
|
// Cron prompt references .scarf/config.json (where values.sites
|
||||||
// + values.timeout_seconds live) and the dashboard/log it writes.
|
// + values.timeout_seconds live), the dashboard/log it writes,
|
||||||
// If either stops being referenced, the cron wouldn't know which
|
// and the {{PROJECT_DIR}} placeholder the installer resolves
|
||||||
// data to read or where to write results.
|
// at install time. If either stops being referenced, the cron
|
||||||
|
// wouldn't know which data to read or where to write results.
|
||||||
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
let cronPrompt = inspection.cronJobs.first?.prompt ?? ""
|
||||||
#expect(cronPrompt.contains("config.json"))
|
#expect(cronPrompt.contains("config.json"))
|
||||||
#expect(cronPrompt.contains("values.sites"))
|
#expect(cronPrompt.contains("values.sites"))
|
||||||
#expect(cronPrompt.contains("dashboard.json"))
|
#expect(cronPrompt.contains("dashboard.json"))
|
||||||
#expect(cronPrompt.contains("status-log.md"))
|
#expect(cronPrompt.contains("status-log.md"))
|
||||||
|
// {{PROJECT_DIR}} must remain UNRESOLVED in the bundle — the
|
||||||
|
// installer substitutes it at install time. If someone
|
||||||
|
// accidentally baked an absolute path into the template, that
|
||||||
|
// path would follow every install to every user's machine.
|
||||||
|
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the example bundle path robustly. Unit-test working dirs
|
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ Optional:
|
|||||||
|
|
||||||
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
|
- `instructions/CLAUDE.md`, `instructions/GEMINI.md`, `instructions/.cursorrules`, `instructions/.github/copilot-instructions.md` — agent-specific shims beyond `AGENTS.md`.
|
||||||
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
|
- `skills/<skill-name>/SKILL.md` — shipped skills, installed into `~/.hermes/skills/templates/<slug>/` on the user's side.
|
||||||
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`.
|
- `cron/jobs.json` — an array of cron job specs. Each has `name`, `schedule` (e.g. `0 9 * * *` or `every 2h`), `prompt`, optional `deliver`, `skills[]`, `repeat`. The prompt may use these install-time placeholders — the installer substitutes them before registering the cron job with Hermes:
|
||||||
|
- `{{PROJECT_DIR}}` — absolute path of the newly-installed project dir. **Required for any cron prompt that reads or writes project files** — Hermes doesn't set a CWD when firing cron jobs, so relative paths (`.scarf/config.json`) won't resolve. Write `{{PROJECT_DIR}}/.scarf/config.json` instead.
|
||||||
|
- `{{TEMPLATE_ID}}` — the `owner/name` id from your manifest.
|
||||||
|
- `{{TEMPLATE_SLUG}}` — the sanitised slug used for the project dir name + skills namespace.
|
||||||
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
|
- `memory/append.md` — markdown appended to the user's `MEMORY.md` between template-specific markers. Use sparingly — most templates don't need this.
|
||||||
|
|
||||||
### 4. Build the bundle
|
### 4. Build the bundle
|
||||||
|
|||||||
Binary file not shown.
@@ -37,7 +37,7 @@ No `sites.txt` anymore — sites come from `.scarf/config.json`.
|
|||||||
|
|
||||||
## What to do when the cron job fires
|
## What to do when the cron job fires
|
||||||
|
|
||||||
The cron job runs this project's "Check site status" prompt. When invoked:
|
The cron prompt Scarf registers for this project carries **absolute paths** (the installer substitutes `{{PROJECT_DIR}}` at install time) — you don't need to figure out the project's location yourself. Use whatever absolute paths appear in the prompt you received; if you're working in the project's interactive chat instead, the paths below are relative to the project root.
|
||||||
|
|
||||||
1. Read `.scarf/config.json`. Extract `values.sites` (array of URLs) and `values.timeout_seconds` (number). If `sites` is empty or missing, write a `status-log.md` entry noting "no sites configured — open Configuration to add some" and leave the dashboard untouched.
|
1. Read `.scarf/config.json`. Extract `values.sites` (array of URLs) and `values.timeout_seconds` (number). If `sites` is empty or missing, write a `status-log.md` entry noting "no sites configured — open Configuration to add some" and leave the dashboard untouched.
|
||||||
2. For each URL in `sites`, make an HTTP GET request with the configured timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
|
2. For each URL in `sites`, make an HTTP GET request with the configured timeout. Follow up to 3 redirects. Treat any 2xx or 3xx response as **up**, anything else (including timeouts and DNS failures) as **down**.
|
||||||
@@ -56,6 +56,7 @@ The cron job runs this project's "Check site status" prompt. When invoked:
|
|||||||
- `Sites Down` stat widget: `value` = count of down results.
|
- `Sites Down` stat widget: `value` = count of down results.
|
||||||
- `Last Checked` stat widget: `value` = the ISO-8601 timestamp you just wrote.
|
- `Last Checked` stat widget: `value` = the ISO-8601 timestamp you just wrote.
|
||||||
- `Watched Sites` list widget `items`: one entry per URL with `text` = URL and `status` = `"up"` or `"down"` (lowercase).
|
- `Watched Sites` list widget `items`: one entry per URL with `text` = URL and `status` = `"up"` or `"down"` (lowercase).
|
||||||
|
- `First Watched Site` **webview widget** (in the "Live Site Preview" section): set its `url` field to the **first** URL from `values.sites`. This is what the user sees rendered in the Scarf **Site** tab. If `values.sites` is empty, leave the webview's existing `url` alone.
|
||||||
6. If the cron job has a `deliver` target set, emit a one-line summary (`3 up, 1 down — example.com timed out`) as the agent's final response so the delivery mechanism picks it up.
|
6. If the cron job has a `deliver` target set, emit a one-line summary (`3 up, 1 down — example.com timed out`) as the agent's final response so the delivery mechanism picks it up.
|
||||||
|
|
||||||
## What not to do
|
## What not to do
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Check site status",
|
"name": "Check site status",
|
||||||
"schedule": "0 9 * * *",
|
"schedule": "0 9 * * *",
|
||||||
"prompt": "Run the site status check for this project. Follow the instructions in AGENTS.md: read .scarf/config.json to get values.sites (the URL list) and values.timeout_seconds, HTTP GET each URL with the configured timeout, prepend a results section to status-log.md (creating it with the stub header if it doesn't exist yet), and update the three stat widgets plus the Watched Sites list items in .scarf/dashboard.json. When done, reply with a one-line summary like '3 up, 1 down — example.com timed out'."
|
"prompt": "Run the site status check for the Scarf project at {{PROJECT_DIR}}. Read {{PROJECT_DIR}}/.scarf/config.json to get `values.sites` (the URL list) and `values.timeout_seconds` (the per-URL HTTP timeout). HTTP GET each URL with that timeout, following up to 3 redirects; treat 2xx/3xx as up and anything else (including timeouts and DNS failures) as down. Prepend a new timestamped results section to {{PROJECT_DIR}}/status-log.md — create the file with a one-line header if it doesn't exist yet. Update {{PROJECT_DIR}}/.scarf/dashboard.json: set the Sites Up / Sites Down / Last Checked stat widgets' `value` fields; replace the 'Watched Sites' list widget's `items` array with one entry per URL (text = URL, status = \"up\" or \"down\"); and if `values.sites` is non-empty, set the 'First Watched Site' webview widget's `url` field to the FIRST URL from `values.sites` (otherwise leave the webview's existing url alone). Preserve every other field in dashboard.json as-is. Reply with a one-line summary like '3 up, 1 down — example.com timed out'."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"title": "Site Status",
|
"title": "Site Status",
|
||||||
"description": "Daily uptime check for your watched URLs. The stat widgets and list update automatically when the cron job runs.",
|
"description": "Daily uptime check for your watched URLs. The stat widgets, the sites list, and the Site tab's preview URL all update automatically when the cron job runs. Switch to the Site tab to see your first watched site live.",
|
||||||
"theme": { "accent": "green" },
|
"theme": { "accent": "green" },
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
@@ -40,14 +40,25 @@
|
|||||||
"widgets": [
|
"widgets": [
|
||||||
{
|
{
|
||||||
"type": "list",
|
"type": "list",
|
||||||
"title": "Configured Sites (from sites.txt)",
|
"title": "Watched Sites (populated after first run)",
|
||||||
"items": [
|
"items": [
|
||||||
{ "text": "https://example.com", "status": "unknown" },
|
{ "text": "Run the check once to populate — the agent reads your Configuration and fills this list with live status.", "status": "pending" }
|
||||||
{ "text": "https://example.org", "status": "unknown" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Live Site Preview",
|
||||||
|
"columns": 1,
|
||||||
|
"widgets": [
|
||||||
|
{
|
||||||
|
"type": "webview",
|
||||||
|
"title": "First Watched Site",
|
||||||
|
"url": "https://awizemann.github.io/scarf/",
|
||||||
|
"height": 420
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "How to Use",
|
"title": "How to Use",
|
||||||
"columns": 1,
|
"columns": 1,
|
||||||
@@ -56,7 +67,7 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"title": "Quick Start",
|
"title": "Quick Start",
|
||||||
"format": "markdown",
|
"format": "markdown",
|
||||||
"content": "**1.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**2.** Edit `sites.txt` in this project's folder to replace the placeholder URLs with the sites you actually want to watch.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The dashboard refreshes and a new entry appears at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
"content": "**1.** Review your configuration — click the **slider icon** (top-right of this dashboard) to open Configuration. The sites you enter there are what the cron job will check.\n\n**2.** Enable the `[tmpl:awizemann/site-status-checker] Check site status` cron job in the Cron sidebar. It ships paused — nothing runs until you say so.\n\n**3.** Ask your agent: *\"Run the site status check now.\"* The Watched Sites list populates, the stat widgets update, the Site tab's URL switches to your first watched site, and a new entry lands at the top of `status-log.md`.\n\n**4.** Daily at 9 AM the cron job fires automatically. Change the schedule in the Cron sidebar if you want a different cadence.\n\nSwitch to the **Site** tab (next to Dashboard, above) to see your first watched site rendered in a browser. Useful to eyeball a site when the status says up but something still looks off.\n\nSee `README.md` and `AGENTS.md` in the project root for the full spec."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
"name": "Alan Wizemann",
|
"name": "Alan Wizemann",
|
||||||
"url": "https://github.com/awizemann/scarf"
|
"url": "https://github.com/awizemann/scarf"
|
||||||
},
|
},
|
||||||
"bundleSha256": "ce68cc20cc67fe688a7ddf0638d35dce3247ba7ed234e6f9d99a1ad3964a81e0",
|
"bundleSha256": "0a20802a8830a7cfdd1afa2888e42e113c9a17a37439384a3037d32ad1f24c1f",
|
||||||
"bundleSize": 6797,
|
"bundleSize": 7569,
|
||||||
"category": "monitoring",
|
"category": "monitoring",
|
||||||
"config": {
|
"config": {
|
||||||
"modelRecommendation": {
|
"modelRecommendation": {
|
||||||
|
|||||||
Reference in New Issue
Block a user