diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift index 7011739..d899d32 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift @@ -86,7 +86,7 @@ public actor ProcessACPChannel: ACPChannel { self.stderr = errStream self.stderrContinuation = errContinuation - await startReaders() + startReaders() installTerminationHandler() } @@ -111,7 +111,7 @@ public actor ProcessACPChannel: ACPChannel { self.stderr = errStream self.stderrContinuation = errContinuation - await startReaders() + startReaders() installTerminationHandler() } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/BackupManifest.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/BackupManifest.swift new file mode 100644 index 0000000..97813ec --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/BackupManifest.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Top-level manifest for a `.scarfbackup` archive. +/// +/// **Archive layout** (`.scarfbackup` is a plain ZIP): +/// ``` +/// .scarfbackup +/// ├── manifest.json — this struct, JSON-encoded +/// ├── hermes.tar.gz — gzipped tar of `~/.hermes/` (minus exclusions) +/// └── projects/ +/// ├── .tar.gz — one inner tarball per registered project +/// └── ... +/// ``` +/// +/// **Why two layers (outer ZIP + inner tarballs).** The inner tarballs are +/// produced by streaming `tar -czf - …` over SSH — that's the only way to +/// keep memory bounded for multi-GB hermes homes. The outer ZIP exists so +/// the manifest sits at a fixed, easy-to-inspect location and so users on +/// macOS can double-click in Finder and see the structure. ZIP also has a +/// central directory at the end, which makes "validate without extracting" +/// cheap. +/// +/// **What rides along.** Hermes home (state.db + sessions + skills + cron + +/// memories + scarf sidecars + plugins/profiles), each project's full file +/// tree (the user's code), and the manifest itself. **What does NOT ride +/// along by default**: `auth.json` (provider credentials), `mcp-tokens/` +/// (per-host OAuth bearer tokens), `logs/` (size, low restore value), +/// `state.db-wal` / `state.db-shm` (in-flight WAL siblings — we checkpoint +/// before the archive). The `options` block records exactly which +/// exclusions were applied so the restore flow can warn the user. +public struct BackupManifest: Codable, Sendable, Equatable { + /// Bumped when the on-disk shape changes incompatibly. v1 is the only + /// shape today; restores refuse anything they don't recognize. + public var schemaVersion: Int + /// Magic string. Lets a future Scarf reject `.zip` files that aren't + /// our backups before unpacking them as if they were. + public var kind: String + /// ISO-8601 UTC timestamp the archive was produced. + public var createdAt: String + /// Identifies the server the backup came from. The display name is for + /// the restore preview sheet; serverID is for de-dupe and lineage. + public var source: Source + /// Hermes home tree metadata. Always present (even an empty Hermes + /// install ships an empty tarball — the restore replaces nothing + /// rather than refusing). + public var hermes: HermesTree + /// One entry per registered project at backup time. Empty array + /// when the user never registered any projects. + public var projects: [ProjectEntry] + /// What was included / excluded from the Hermes tree. Flagged so the + /// restore preview honestly reports "auth.json was not in this + /// backup — you'll re-authenticate after restore". + public var options: Options + + public init( + schemaVersion: Int = BackupManifest.currentSchemaVersion, + kind: String = BackupManifest.kindMagic, + createdAt: String, + source: Source, + hermes: HermesTree, + projects: [ProjectEntry], + options: Options + ) { + self.schemaVersion = schemaVersion + self.kind = kind + self.createdAt = createdAt + self.source = source + self.hermes = hermes + self.projects = projects + self.options = options + } + + public static let currentSchemaVersion = 1 + public static let kindMagic = "scarf-server-backup" + + public struct Source: Codable, Sendable, Equatable { + public var serverID: String + public var displayName: String + public var host: String + public var user: String? + /// Output of `hermes --version` on the source host at backup + /// time. Restore warns if the target installs an older version + /// (state.db schema differences could break things silently). + public var hermesVersion: String? + + public init(serverID: String, displayName: String, host: String, user: String?, hermesVersion: String?) { + self.serverID = serverID + self.displayName = displayName + self.host = host + self.user = user + self.hermesVersion = hermesVersion + } + } + + public struct HermesTree: Codable, Sendable, Equatable { + /// Absolute path of `~/.hermes/` on the source host (e.g. + /// `/root/.hermes` or `/home/alan/.hermes`). Used by restore to + /// detect path drift when targeting a different user account. + public var homePath: String + /// Path inside the outer ZIP (always `hermes.tar.gz`). + public var tarballPath: String + /// Compressed bytes — for the preview sheet's size summary. + public var tarballSize: Int64 + /// Hex SHA-256 of the inner tarball. Restore verifies before + /// extracting; corruption surfaces as a single bad path + /// rather than a half-extracted home. + public var tarballSHA256: String + + public init(homePath: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) { + self.homePath = homePath + self.tarballPath = tarballPath + self.tarballSize = tarballSize + self.tarballSHA256 = tarballSHA256 + } + } + + public struct ProjectEntry: Codable, Sendable, Equatable { + /// Stable UUID for the project. Used to namespace the inner + /// tarball so a project with `name = "scratch"` in two + /// different directories doesn't collide. + public var id: String + public var name: String + /// Absolute path on the source host. Restore re-anchors this if + /// the target has a different home (e.g. backup from `/root`, + /// restore to `/home/ubuntu`). + public var path: String + /// Path inside the outer ZIP (e.g. `projects/.tar.gz`). + public var tarballPath: String + public var tarballSize: Int64 + public var tarballSHA256: String + + public init(id: String, name: String, path: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) { + self.id = id + self.name = name + self.path = path + self.tarballPath = tarballPath + self.tarballSize = tarballSize + self.tarballSHA256 = tarballSHA256 + } + } + + public struct Options: Codable, Sendable, Equatable { + public var includeAuth: Bool + public var includeMcpTokens: Bool + public var includeLogs: Bool + /// True if `sqlite3 PRAGMA wal_checkpoint(TRUNCATE)` was run on + /// the remote before tarballing the Hermes home. False means the + /// archive may contain a `state.db` mid-write — usually fine + /// (SQLite tolerates restarted reads from a quiesced DB) but + /// flagged for forensics. + public var checkpointedWAL: Bool + + public init(includeAuth: Bool, includeMcpTokens: Bool, includeLogs: Bool, checkpointedWAL: Bool) { + self.includeAuth = includeAuth + self.includeMcpTokens = includeMcpTokens + self.includeLogs = includeLogs + self.checkpointedWAL = checkpointedWAL + } + + public static let safeDefault = Options( + includeAuth: false, + includeMcpTokens: false, + includeLogs: false, + checkpointedWAL: true + ) + } +} + +/// Canonical layout strings — referenced by both the producer and the +/// consumer so the on-disk paths stay in sync. +public enum BackupArchiveLayout { + public static let manifestPath = "manifest.json" + public static let hermesTarballPath = "hermes.tar.gz" + public static let projectsTarballPrefix = "projects/" + public static let archiveExtension = "scarfbackup" + + /// Returns `projects/.tar.gz`. The id is the `ProjectEntry.id` + /// (stable UUID), not the project name — names are renamed all the + /// time and would collide. + public static func projectTarballPath(for id: String) -> String { + projectsTarballPrefix + id + ".tar.gz" + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift new file mode 100644 index 0000000..2d1836f --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteBackupService.swift @@ -0,0 +1,530 @@ +import Foundation +import CryptoKit +#if canImport(os) +import os +#endif + +/// Streams a Hermes home + project trees off a (local or remote) server +/// into a single `.scarfbackup` archive on disk. +/// +/// **Why not just run `hermes backup`.** Hermes's CLI captures `~/.hermes/` +/// only; project file trees (the user's actual code) live outside that +/// home and aren't included. A "rebuild this droplet from scratch" flow +/// needs both. This service does both — Hermes home as one inner tarball, +/// each registered project as its own — and writes a manifest pinning the +/// source server, hermes version, and per-tarball SHA-256s so restore can +/// detect corruption before it half-extracts. +/// +/// **Memory profile.** Tarballs stream over SSH (`tar -czf -`) and into +/// disk-backed temp files chunk-by-chunk via `streamRawBytes`. We never +/// hold a multi-GB buffer in RAM. The final ZIP step shells out to +/// `/usr/bin/zip`, which also streams from disk. +/// +/// **Cleanup.** The temp dir lives under +/// `FileManager.default.temporaryDirectory` and is removed on every exit +/// path (success, failure, cancellation) via `defer`. +public final class RemoteBackupService: @unchecked Sendable { + #if canImport(os) + private static let logger = Logger(subsystem: "com.scarf", category: "RemoteBackupService") + #endif + + public let context: ServerContext + + public init(context: ServerContext) { + self.context = context + } + + /// Coarse stages the UI binds to. The service publishes one of these + /// per meaningful state change so a progress sheet can render + /// "Archiving Hermes home — 412 MB so far" without polling. + public enum Progress: Sendable, Equatable { + case preflight + case checkpointingDB + case archivingHermes(bytesWritten: Int64) + case archivingProject(name: String, bytesWritten: Int64) + case bundling + case finalizing + } + + public enum BackupError: Error, LocalizedError { + case preflightFailed(String) + case remoteCommandFailed(String) + case localIO(String) + case zipFailed(String) + case cancelled + + public var errorDescription: String? { + switch self { + case .preflightFailed(let m): return "Backup preflight failed: \(m)" + case .remoteCommandFailed(let m): return "Remote command failed during backup: \(m)" + case .localIO(let m): return "Local file I/O failed during backup: \(m)" + case .zipFailed(let m): return "Couldn't assemble the backup archive: \(m)" + case .cancelled: return "Backup cancelled." + } + } + } + + /// What the UI displays before any archiving starts. Populated by + /// `preflight()` so the user can see (and confirm) total size + + /// project count + hermes version before committing 4 minutes of + /// SSH traffic. + public struct PreflightSummary: Sendable, Equatable { + public var hermesVersion: String? + public var hermesHomePath: String + public var hermesHomeBytes: Int64? + public var projects: [ProjectSummary] + public var sqliteAvailable: Bool + + public struct ProjectSummary: Sendable, Equatable { + public var id: String + public var name: String + public var path: String + public var sizeBytes: Int64? + public var reachable: Bool + } + + public var totalSizeBytes: Int64? { + let parts: [Int64] = [hermesHomeBytes ?? 0] + projects.compactMap { $0.sizeBytes } + let sum = parts.reduce(0, +) + return sum > 0 ? sum : nil + } + } + + public struct BackupResult: Sendable { + public var manifest: BackupManifest + public var archiveURL: URL + public var archiveSize: Int64 + } + + /// Probe the remote (or local) before committing to the full + /// archive. Cheap — three short SSH calls and one file read. Safe + /// to call repeatedly; nothing is mutated on the source side. + public func preflight() async throws -> PreflightSummary { + let transport = context.makeTransport() + + // 1. Resolve $HOME so the absolute paths in the manifest are + // canonical (e.g. `/home/alan/.hermes`, not the + // `~`-prefixed `HermesPathSet.home`). + let homeResult = try transport.runProcess( + executable: "/bin/bash", + args: ["-lc", "echo \"$HOME\""], + stdin: nil, + timeout: 30 + ) + guard homeResult.exitCode == 0 else { + throw BackupError.preflightFailed("Couldn't resolve remote $HOME (exit \(homeResult.exitCode)): \(homeResult.stderrString)") + } + let resolvedHome = homeResult.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + + // 2. Hermes version. Optional — older builds may not implement + // `--version`. Empty/missing isn't fatal; the manifest just + // won't carry a version stamp. + let versionResult = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", "hermes --version 2>/dev/null || true"], + stdin: nil, + timeout: 30 + ) + let hermesVersion: String? = { + guard let r = versionResult, r.exitCode == 0 else { return nil } + let trimmed = r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + }() + + // 3. Hermes home size + canonical path. `context.paths.home` + // can be `~/.hermes` for remotes that didn't pin + // `SSHConfig.remoteHome`; tar doesn't expand `~`, so we + // resolve every path against the just-fetched $HOME + // BEFORE storing it in the summary. `tar -C '~'` would + // fail with "No such file or directory" otherwise (and + // `du -sb '~/.hermes' 2>/dev/null` swallows the same + // error silently — that's why preflight looked green). + let hermesHome = Self.expandTilde(context.paths.home, home: resolvedHome) + let hermesSize = Self.estimateBytes(transport: transport, path: hermesHome) + + // 4. Enumerate projects via the existing transport-aware + // service. Empty registry → empty list, not an error. + // Same tilde expansion as above so project paths stored + // in `~/.hermes/scarf/projects.json` with `~/projects/foo` + // don't blow up later in `tar -C`. + let registry = ProjectDashboardService(context: context).loadRegistry() + var projectSummaries: [PreflightSummary.ProjectSummary] = [] + for project in registry.projects where !project.archived { + let expanded = Self.expandTilde(project.path, home: resolvedHome) + let reachable = transport.fileExists(expanded) + let bytes = reachable ? Self.estimateBytes(transport: transport, path: expanded) : nil + projectSummaries.append(PreflightSummary.ProjectSummary( + id: project.path, // path is the registry's stable handle + name: project.name, + path: expanded, + sizeBytes: bytes, + reachable: reachable + )) + } + + // 5. Is `sqlite3` on PATH? Drives the WAL-checkpoint toggle. + // Missing → we still archive, just without quiescing. + let sqliteCheck = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", "command -v sqlite3 >/dev/null 2>&1 && echo yes || echo no"], + stdin: nil, + timeout: 30 + ) + let sqliteAvailable = sqliteCheck?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) == "yes" + + return PreflightSummary( + hermesVersion: hermesVersion, + hermesHomePath: hermesHome, + hermesHomeBytes: hermesSize, + projects: projectSummaries, + sqliteAvailable: sqliteAvailable + ) + } + + /// Replace a leading `~` or `~/` with the resolved remote home. + /// Tar (and most non-shell tools) don't expand tildes — only the + /// shell does, and we deliberately single-quote paths in the + /// command string for whitespace-safety, which then suppresses + /// shell expansion. So we expand here, in Swift, with a + /// known-good `$HOME` value. + static func expandTilde(_ path: String, home: String) -> String { + guard !home.isEmpty else { return path } + if path == "~" { return home } + if path.hasPrefix("~/") { return home + String(path.dropFirst(1)) } + return path + } + + /// Run the full backup: stream Hermes home + each project tarball, + /// build the manifest, ZIP everything into `archiveURL`. Caller + /// holds the `Task` and can cancel; cooperative checks fire between + /// stages. + public func run( + preflight: PreflightSummary, + options: BackupManifest.Options, + archiveURL: URL, + progress: @Sendable @escaping (Progress) -> Void + ) async throws -> BackupResult { + let transport = context.makeTransport() + + let workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("scarf-backup-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: workDir) } + + try Task.checkCancellation() + progress(.preflight) + + // Stage 1: WAL checkpoint (best effort). Build the state.db + // path from the already-expanded hermesHomePath rather than + // `context.paths.stateDB`, which can still carry a literal + // `~` for remotes that didn't pin `remoteHome` — sqlite3 + // would fail to open the file and leave the WAL un-flushed. + var checkpointed = false + if options.checkpointedWAL && preflight.sqliteAvailable { + progress(.checkpointingDB) + let stateDB = preflight.hermesHomePath + "/state.db" + let cmd = "sqlite3 \(Self.shellQuote(stateDB)) 'PRAGMA wal_checkpoint(TRUNCATE);' || true" + let result = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", cmd], + stdin: nil, + timeout: 60 + ) + checkpointed = (result?.exitCode == 0) + } + + // Stage 2: Hermes home tarball. + try Task.checkCancellation() + let hermesTarball = workDir.appendingPathComponent("hermes.tar.gz") + let hermesExcludes = Self.hermesExcludes(options: options) + let hermesTarCmd = Self.tarCommand( + workDir: preflight.hermesHomePath.deletingLastPathComponent_String(), + target: ".hermes", + excludes: hermesExcludes + ) + let hermesHash = try await streamToFile( + transport: transport, + command: hermesTarCmd, + destination: hermesTarball + ) { written in + progress(.archivingHermes(bytesWritten: written)) + } + let hermesSize = (try? FileManager.default.attributesOfItem(atPath: hermesTarball.path)[.size] as? Int64) ?? 0 + + // Stage 3: per-project tarballs. + let projectsDir = workDir.appendingPathComponent("projects", isDirectory: true) + try FileManager.default.createDirectory(at: projectsDir, withIntermediateDirectories: true) + + var projectEntries: [BackupManifest.ProjectEntry] = [] + for summary in preflight.projects where summary.reachable { + try Task.checkCancellation() + let projID = Self.stableID(forPath: summary.path) + let outerName = "\(projID).tar.gz" + let dest = projectsDir.appendingPathComponent(outerName) + let parent = (summary.path as NSString).deletingLastPathComponent + let leaf = (summary.path as NSString).lastPathComponent + let cmd = Self.tarCommand( + workDir: parent, + target: leaf, + excludes: Self.projectExcludes() + ) + let hash = try await streamToFile( + transport: transport, + command: cmd, + destination: dest + ) { written in + progress(.archivingProject(name: summary.name, bytesWritten: written)) + } + let size = (try? FileManager.default.attributesOfItem(atPath: dest.path)[.size] as? Int64) ?? 0 + projectEntries.append(BackupManifest.ProjectEntry( + id: projID, + name: summary.name, + path: summary.path, + tarballPath: BackupArchiveLayout.projectTarballPath(for: projID), + tarballSize: size, + tarballSHA256: hash + )) + } + + // Stage 4: build manifest, write to workDir. + try Task.checkCancellation() + let manifest = BackupManifest( + createdAt: ISO8601DateFormatter().string(from: Date()), + source: BackupManifest.Source( + serverID: context.id.uuidString, + displayName: context.displayName, + host: Self.host(for: context), + user: Self.user(for: context), + hermesVersion: preflight.hermesVersion + ), + hermes: BackupManifest.HermesTree( + homePath: preflight.hermesHomePath, + tarballPath: BackupArchiveLayout.hermesTarballPath, + tarballSize: hermesSize, + tarballSHA256: hermesHash + ), + projects: projectEntries, + options: BackupManifest.Options( + includeAuth: options.includeAuth, + includeMcpTokens: options.includeMcpTokens, + includeLogs: options.includeLogs, + checkpointedWAL: checkpointed + ) + ) + let manifestData: Data + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + manifestData = try encoder.encode(manifest) + } catch { + throw BackupError.localIO("Couldn't encode manifest: \(error.localizedDescription)") + } + let manifestURL = workDir.appendingPathComponent(BackupArchiveLayout.manifestPath) + do { + try manifestData.write(to: manifestURL, options: .atomic) + } catch { + throw BackupError.localIO("Couldn't write manifest: \(error.localizedDescription)") + } + + // Stage 5: ZIP everything in workDir into the user-chosen + // destination. Atomic via temp file + rename so a half-written + // archive isn't visible. + try Task.checkCancellation() + progress(.bundling) + let tempArchive = archiveURL.deletingLastPathComponent() + .appendingPathComponent(".\(archiveURL.lastPathComponent).inflight-\(UUID().uuidString).zip") + try Self.zipDirectory(workDir: workDir, into: tempArchive) + progress(.finalizing) + do { + if FileManager.default.fileExists(atPath: archiveURL.path) { + try FileManager.default.removeItem(at: archiveURL) + } + try FileManager.default.moveItem(at: tempArchive, to: archiveURL) + } catch { + try? FileManager.default.removeItem(at: tempArchive) + throw BackupError.localIO("Couldn't move archive into place: \(error.localizedDescription)") + } + + let archiveSize = (try? FileManager.default.attributesOfItem(atPath: archiveURL.path)[.size] as? Int64) ?? 0 + return BackupResult( + manifest: manifest, + archiveURL: archiveURL, + archiveSize: archiveSize + ) + } + + // MARK: - Streaming + + /// Spawn a remote (or local) `bash -lc ` and pump its stdout + /// into `destination`, computing SHA-256 incrementally as bytes + /// arrive. Returns the hex digest. The process gets a fresh + /// `bash -lc` shell on each invocation — same login-shell story + /// as `streamRawBytes` so PATH picks up pipx installs etc. + private func streamToFile( + transport: any ServerTransport, + command: String, + destination: URL, + onProgress: @Sendable @escaping (Int64) -> Void + ) async throws -> String { + FileManager.default.createFile(atPath: destination.path, contents: nil) + guard let fh = try? FileHandle(forWritingTo: destination) else { + throw BackupError.localIO("Couldn't open \(destination.lastPathComponent) for writing") + } + defer { try? fh.close() } + var hasher = SHA256() + var written: Int64 = 0 + let stream = transport.streamRawBytes( + executable: "/bin/bash", + args: ["-lc", command] + ) + do { + for try await chunk in stream { + try Task.checkCancellation() + try fh.write(contentsOf: chunk) + hasher.update(data: chunk) + written += Int64(chunk.count) + onProgress(written) + } + } catch is CancellationError { + throw BackupError.cancelled + } catch let err as TransportError { + throw BackupError.remoteCommandFailed(err.localizedDescription) + } catch { + throw BackupError.remoteCommandFailed(error.localizedDescription) + } + let digest = hasher.finalize() + return digest.map { String(format: "%02x", $0) }.joined() + } + + // MARK: - Tar / shell helpers + + private static func tarCommand(workDir: String, target: String, excludes: [String]) -> String { + var parts: [String] = ["tar -czf -"] + for ex in excludes { + parts.append("--exclude=\(shellQuote(ex))") + } + parts.append("-C \(shellQuote(workDir))") + parts.append(shellQuote(target)) + return parts.joined(separator: " ") + } + + /// Always-on Hermes-tree exclusions, regardless of options: + /// SQLite WAL siblings (would carry mid-flight writes) and runtime + /// state files (`gateway_state.json`). + private static func hermesExcludes(options: BackupManifest.Options) -> [String] { + var excludes: [String] = [ + ".hermes/state.db-wal", + ".hermes/state.db-shm", + ".hermes/gateway_state.json", + ] + if !options.includeAuth { excludes.append(".hermes/auth.json") } + if !options.includeMcpTokens { excludes.append(".hermes/mcp-tokens") } + if !options.includeLogs { excludes.append(".hermes/logs") } + return excludes + } + + /// Default project-tree exclusions: things that don't restore well + /// (compiled object stores, virtualenvs that hard-code absolute + /// paths, system-specific build outputs). Users can opt in via + /// the future "include build artefacts" toggle in the Backup + /// sheet — for now we always exclude these. + private static func projectExcludes() -> [String] { + [ + "*/node_modules", + "*/.venv", + "*/venv", + "*/__pycache__", + "*/.git/objects", + "*/.next", + "*/dist", + "*/.DS_Store", + ] + } + + /// Single-quote a path / argument for embedding in a `bash -lc` + /// string. Uses POSIX-safe single quotes with escape for embedded + /// quotes (`'` → `'\''`). + private static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Convenience: same idea as ServerContext.host, but tolerates the + /// local case (no host) by returning `"localhost"`. + private static func host(for context: ServerContext) -> String { + if case .ssh(let cfg) = context.kind { + return cfg.host + } + return "localhost" + } + + private static func user(for context: ServerContext) -> String? { + if case .ssh(let cfg) = context.kind { + return cfg.user + } + return nil + } + + /// `du -sb` (GNU) is the most portable way to get raw bytes — + /// on macOS `du -sk` returns kilobytes. Returns nil if neither + /// works. + private static func estimateBytes(transport: any ServerTransport, path: String) -> Int64? { + let cmd = "du -sb \(shellQuote(path)) 2>/dev/null | awk '{print $1}'" + guard let r = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", cmd], + stdin: nil, + timeout: 60 + ), r.exitCode == 0 else { return nil } + let s = r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + return Int64(s) + } + + /// Stable ID for a project. The project registry tracks projects + /// by absolute path, but paths can differ between source and + /// target (different `$HOME`). We hash the path to get a stable + /// 16-hex-char identifier that's safe to use as a tarball + /// filename. Collisions are vanishingly unlikely — a Mac's path + /// space is small and SHA-256 truncated to 64 bits has good + /// properties for non-adversarial input. + private static func stableID(forPath path: String) -> String { + let digest = SHA256.hash(data: Data(path.utf8)) + let bytes = digest.map { String(format: "%02x", $0) }.joined() + return String(bytes.prefix(16)) + } + + /// Shell out to `/usr/bin/zip` to assemble the outer archive. + /// macOS ships `zip` at this fixed path so we don't need a PATH + /// search. `-r` recurse, `-q` quiet, `-X` strip extended attrs + /// for reproducibility. + private static func zipDirectory(workDir: URL, into archive: URL) throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/zip") + proc.currentDirectoryURL = workDir + proc.arguments = ["-rqX", archive.path, "."] + let errPipe = Pipe() + proc.standardError = errPipe + proc.standardOutput = Pipe() + do { + try proc.run() + } catch { + throw BackupError.zipFailed("Couldn't launch zip: \(error.localizedDescription)") + } + proc.waitUntilExit() + if proc.terminationStatus != 0 { + let tail = (try? errPipe.fileHandleForReading.readToEnd()) + .flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? "" + throw BackupError.zipFailed("zip exited \(proc.terminationStatus): \(tail)") + } + } +} + +// MARK: - Path helpers + +private extension String { + /// `(somePath as NSString).deletingLastPathComponent` lifted to a + /// String extension. Used during preflight to derive the + /// remote `$HOME` from `$HOME/.hermes`. + func deletingLastPathComponent_String() -> String { + (self as NSString).deletingLastPathComponent + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift new file mode 100644 index 0000000..868b730 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/RemoteRestoreService.swift @@ -0,0 +1,493 @@ +import Foundation +import CryptoKit +#if canImport(os) +import os +#endif + +/// Reverses a `.scarfbackup` archive into a target server: validates, +/// streams tarballs into place over SSH, and re-anchors path-bearing +/// JSON sidecars so the restored Hermes home references the new layout. +/// +/// **Validation gates.** No bytes are written to the target until the +/// manifest's `kind` magic + `schemaVersion` match, and every inner +/// tarball's SHA-256 matches what the manifest claims. A corrupt +/// archive surfaces a single named-path error instead of a half-extracted +/// home. +/// +/// **Path re-anchoring.** Project absolute paths in +/// `~/.hermes/scarf/projects.json` reference the source server's home +/// (e.g. `/root/projects/foo`). After extraction the project lives at +/// `/foo`, so the restore rewrites `path` for each +/// entry. Same logic for `/.scarf/manifest.json` if it carries +/// self-references. +/// +/// **Cron paused on restore.** Every job in `cron/jobs.json` is flipped +/// to `enabled = false` after restore. Restored cron jobs may carry +/// stale credentials (Slack tokens, webhooks) or run on schedules the +/// user no longer wants — auto-running them on a fresh droplet is +/// surprising. The user re-enables what they want from the Cron view. +public final class RemoteRestoreService: @unchecked Sendable { + #if canImport(os) + private static let logger = Logger(subsystem: "com.scarf", category: "RemoteRestoreService") + #endif + + public let context: ServerContext + + public init(context: ServerContext) { + self.context = context + } + + public enum Progress: Sendable, Equatable { + case validating + case verifyingHashes + case planning + case restoringHermes(bytesPushed: Int64) + case restoringProject(name: String, bytesPushed: Int64) + case reanchoringPaths + case pausingCron + case finalizing + } + + public enum RestoreError: Error, LocalizedError { + case archiveUnreadable(String) + case unsupportedSchema(Int) + case wrongKind(String) + case integrityCheckFailed(path: String, expected: String, actual: String) + case remoteCommandFailed(String) + case localIO(String) + case cancelled + + public var errorDescription: String? { + switch self { + case .archiveUnreadable(let m): return "Couldn't read the backup archive: \(m)" + case .unsupportedSchema(let v): return "Backup uses schema v\(v), which this version of Scarf doesn't recognize." + case .wrongKind(let k): return "This file isn't a Scarf server backup (kind: \(k))." + case .integrityCheckFailed(let p, let exp, let act): return "Backup is corrupt — \(p) hash mismatch (expected \(exp.prefix(12))…, got \(act.prefix(12))…)." + case .remoteCommandFailed(let m): return "Remote command failed during restore: \(m)" + case .localIO(let m): return "Local file I/O failed during restore: \(m)" + case .cancelled: return "Restore cancelled." + } + } + } + + /// What `inspect()` returns to drive the restore-plan sheet. The + /// caller picks `targetProjectsRoot`, optionally tweaks the cron + /// pause toggle, then calls `run()` with the same archive URL. + public struct InspectionResult: Sendable { + public var manifest: BackupManifest + public var workDir: URL // unzipped temp dir; reused by run() + public var targetHomeResolved: String? + public var targetHermesVersion: String? + } + + public struct RestoreOptions: Sendable { + /// Where to drop project tarballs. Each project lands at + /// `/`. Defaults to + /// `/projects` when not specified. + public var targetProjectsRoot: String? + /// Override the resolved target home (rarely needed; the + /// default is whatever `bash -lc 'echo $HOME'` returned). + public var targetHomeOverride: String? + /// Pause every cron job after restore. Strongly recommended + /// (the user re-enables intentionally). + public var pauseCronJobs: Bool + + public init( + targetProjectsRoot: String? = nil, + targetHomeOverride: String? = nil, + pauseCronJobs: Bool = true + ) { + self.targetProjectsRoot = targetProjectsRoot + self.targetHomeOverride = targetHomeOverride + self.pauseCronJobs = pauseCronJobs + } + } + + public struct RestoreResult: Sendable { + public var manifest: BackupManifest + public var hermesHome: String + public var projectsRestored: [RestoredProject] + public var cronJobsPaused: Int + + public struct RestoredProject: Sendable { + public var name: String + public var sourcePath: String + public var targetPath: String + } + } + + /// Unzip + manifest-validate + hash-verify in a temp dir. Cheap + /// enough to call from a sheet's appearance handler so the user + /// sees a populated preview before committing. + public func inspect(archiveURL: URL) async throws -> InspectionResult { + let workDir = FileManager.default.temporaryDirectory + .appendingPathComponent("scarf-restore-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: workDir, withIntermediateDirectories: true) + + // Unzip outer archive. + try Self.unzipArchive(at: archiveURL, into: workDir) + + // Decode + validate manifest. + let manifestURL = workDir.appendingPathComponent(BackupArchiveLayout.manifestPath) + guard let data = try? Data(contentsOf: manifestURL) else { + throw RestoreError.archiveUnreadable("missing manifest.json") + } + let manifest: BackupManifest + do { + manifest = try JSONDecoder().decode(BackupManifest.self, from: data) + } catch { + throw RestoreError.archiveUnreadable("manifest.json malformed: \(error.localizedDescription)") + } + guard manifest.kind == BackupManifest.kindMagic else { + throw RestoreError.wrongKind(manifest.kind) + } + guard manifest.schemaVersion == BackupManifest.currentSchemaVersion else { + throw RestoreError.unsupportedSchema(manifest.schemaVersion) + } + + // Hash-verify every inner tarball before any remote bytes are + // pushed. + try await Self.verifyHash(file: workDir.appendingPathComponent(manifest.hermes.tarballPath), expected: manifest.hermes.tarballSHA256) + for project in manifest.projects { + try await Self.verifyHash(file: workDir.appendingPathComponent(project.tarballPath), expected: project.tarballSHA256) + } + + // Probe the target for $HOME + hermes version. Doesn't fail + // restore if the probe times out — the user can still pick + // an override. + let transport = context.makeTransport() + let homeProbe = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", "echo \"$HOME\""], + stdin: nil, + timeout: 30 + ) + let resolvedHome = homeProbe?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + let versionProbe = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", "hermes --version 2>/dev/null || true"], + stdin: nil, + timeout: 30 + ) + let resolvedVersion = versionProbe?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) + + return InspectionResult( + manifest: manifest, + workDir: workDir, + targetHomeResolved: (resolvedHome?.isEmpty == false) ? resolvedHome : nil, + targetHermesVersion: (resolvedVersion?.isEmpty == false) ? resolvedVersion : nil + ) + } + + /// Run the restore. Pushes tarballs, re-anchors paths, optionally + /// pauses cron. Caller owns the `workDir` URL from `inspect()` and + /// is responsible for cleanup if `run` throws — on success this + /// method removes the temp dir. + public func run( + inspection: InspectionResult, + options: RestoreOptions, + progress: @Sendable @escaping (Progress) -> Void + ) async throws -> RestoreResult { + defer { try? FileManager.default.removeItem(at: inspection.workDir) } + let transport = context.makeTransport() + let manifest = inspection.manifest + + try Task.checkCancellation() + progress(.planning) + + let targetHome = options.targetHomeOverride + ?? inspection.targetHomeResolved + ?? (manifest.hermes.homePath as NSString).deletingLastPathComponent + let projectsRoot = options.targetProjectsRoot ?? (targetHome + "/projects") + + // Make sure the projects root exists so `tar -xzf` doesn't + // fail on a missing -C target. + let mkdirCmd = "mkdir -p \(Self.shellQuote(projectsRoot))" + let mkdirResult = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", mkdirCmd], + stdin: nil, + timeout: 30 + ) + if let r = mkdirResult, r.exitCode != 0 { + throw RestoreError.remoteCommandFailed("mkdir \(projectsRoot) failed: \(r.stderrString)") + } + + // Stage 1: hermes home. Pushes into $HOME so the inner + // `.hermes/...` paths land at `/.hermes/...`. + try Task.checkCancellation() + let hermesTar = inspection.workDir.appendingPathComponent(manifest.hermes.tarballPath) + try await pushTarball( + transport: transport, + tarball: hermesTar, + extractInto: targetHome + ) { written in + progress(.restoringHermes(bytesPushed: written)) + } + + // Stage 2: per-project tarballs. + var restoredProjects: [RestoreResult.RestoredProject] = [] + for project in manifest.projects { + try Task.checkCancellation() + let tar = inspection.workDir.appendingPathComponent(project.tarballPath) + try await pushTarball( + transport: transport, + tarball: tar, + extractInto: projectsRoot + ) { written in + progress(.restoringProject(name: project.name, bytesPushed: written)) + } + let basename = (project.path as NSString).lastPathComponent + restoredProjects.append(RestoreResult.RestoredProject( + name: project.name, + sourcePath: project.path, + targetPath: projectsRoot + "/" + basename + )) + } + + // Stage 3: re-anchor `~/.hermes/scarf/projects.json` so the + // restored Hermes references the new project paths instead + // of the source droplet's paths. + try Task.checkCancellation() + progress(.reanchoringPaths) + try await reanchorProjectsRegistry( + transport: transport, + targetHome: targetHome, + mapping: Dictionary( + uniqueKeysWithValues: restoredProjects.map { ($0.sourcePath, $0.targetPath) } + ) + ) + + // Stage 4: pause cron jobs. + var paused = 0 + if options.pauseCronJobs { + try Task.checkCancellation() + progress(.pausingCron) + paused = try await pauseAllCronJobs(transport: transport, targetHome: targetHome) + } + + progress(.finalizing) + return RestoreResult( + manifest: manifest, + hermesHome: targetHome + "/.hermes", + projectsRestored: restoredProjects, + cronJobsPaused: paused + ) + } + + // MARK: - Push (tarball -> remote stdin) + + /// Stream a local `.tar.gz` into `tar -xzf - -C ` on the + /// destination. We use `transport.makeProcess` so the command is + /// shell-wrapped the same way the rest of the app talks to remotes + /// (`bash -lc` for SSH, direct invocation for local). + private func pushTarball( + transport: any ServerTransport, + tarball: URL, + extractInto target: String, + onProgress: @Sendable @escaping (Int64) -> Void + ) async throws { + #if os(iOS) + throw RestoreError.remoteCommandFailed("Remote restore is not supported on iOS in this build.") + #else + let cmd = "tar -xzf - -C \(Self.shellQuote(target))" + let proc = transport.makeProcess(executable: "/bin/bash", args: ["-lc", cmd]) + + // standardInput: read end of an OS pipe whose write end we + // pump from the local tarball file. Going through a pipe (vs + // setting standardInput to a FileHandle directly) gives us + // cooperative chunk-by-chunk control + cancellation. + let inPipe = Pipe() + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardInput = inPipe + proc.standardOutput = outPipe + proc.standardError = errPipe + + do { + try proc.run() + } catch { + throw RestoreError.remoteCommandFailed("Couldn't start remote tar: \(error.localizedDescription)") + } + + let writer = inPipe.fileHandleForWriting + let reader: FileHandle + do { + reader = try FileHandle(forReadingFrom: tarball) + } catch { + try? writer.close() + proc.terminate() + throw RestoreError.localIO("Couldn't open tarball: \(error.localizedDescription)") + } + defer { try? reader.close() } + + var written: Int64 = 0 + let chunkSize = 64 * 1024 + do { + while true { + try Task.checkCancellation() + let chunk = reader.readData(ofLength: chunkSize) + if chunk.isEmpty { break } + try writer.write(contentsOf: chunk) + written += Int64(chunk.count) + onProgress(written) + } + } catch is CancellationError { + try? writer.close() + proc.terminate() + throw RestoreError.cancelled + } catch { + try? writer.close() + proc.terminate() + throw RestoreError.localIO("Couldn't pump tarball into remote: \(error.localizedDescription)") + } + try? writer.close() // signals EOF to the remote tar + + proc.waitUntilExit() + if proc.terminationStatus != 0 { + let tail = (try? errPipe.fileHandleForReading.readToEnd()) + .flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? "" + throw RestoreError.remoteCommandFailed("tar -x exited \(proc.terminationStatus): \(tail)") + } + #endif + } + + // MARK: - Path re-anchor + + /// Rewrite each entry's `path` in `~/.hermes/scarf/projects.json` + /// from source-host paths to target-host paths. We do this on the + /// remote rather than mutating the tarball locally — the Hermes + /// home tarball can be GBs and re-packing would double the + /// transfer cost. Python is universally present on droplets and + /// keeps the JSON shape intact (preserves keys we don't know + /// about). + private func reanchorProjectsRegistry( + transport: any ServerTransport, + targetHome: String, + mapping: [String: String] + ) async throws { + guard !mapping.isEmpty else { return } + let registryPath = targetHome + "/.hermes/scarf/projects.json" + let mappingJSON: String + do { + let data = try JSONSerialization.data(withJSONObject: mapping) + mappingJSON = String(data: data, encoding: .utf8) ?? "{}" + } catch { + throw RestoreError.localIO("Couldn't encode path mapping: \(error.localizedDescription)") + } + let script = """ + import json, os, sys + path = os.path.expanduser(\(Self.pythonQuote(registryPath))) + if not os.path.exists(path): + sys.exit(0) + try: + with open(path) as f: data = json.load(f) + except Exception as e: + print(f"projects.json parse failed: {e}", file=sys.stderr); sys.exit(1) + mapping = json.loads(\(Self.pythonQuote(mappingJSON))) + for entry in data.get('projects', []): + old = entry.get('path') + if old in mapping: entry['path'] = mapping[old] + with open(path, 'w') as f: json.dump(data, f, indent=2) + """ + let cmd = "python3 -c \(Self.shellQuote(script))" + let result = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", cmd], + stdin: nil, + timeout: 60 + ) + if let r = result, r.exitCode != 0 { + throw RestoreError.remoteCommandFailed("Path re-anchor failed: \(r.stderrString)") + } + } + + /// Set `enabled: false` on every cron job. Returns the count + /// flipped (0 if jobs.json is absent). + private func pauseAllCronJobs(transport: any ServerTransport, targetHome: String) async throws -> Int { + let path = targetHome + "/.hermes/cron/jobs.json" + let script = """ + import json, os, sys + path = os.path.expanduser(\(Self.pythonQuote(path))) + if not os.path.exists(path): + print(0); sys.exit(0) + with open(path) as f: data = json.load(f) + count = 0 + for job in data.get('jobs', []): + if job.get('enabled', False): + job['enabled'] = False + count += 1 + with open(path, 'w') as f: json.dump(data, f, indent=2) + print(count) + """ + let cmd = "python3 -c \(Self.shellQuote(script))" + let result = try? transport.runProcess( + executable: "/bin/bash", + args: ["-lc", cmd], + stdin: nil, + timeout: 60 + ) + if let r = result, r.exitCode == 0 { + let count = Int(r.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 + return count + } + return 0 + } + + // MARK: - Helpers + + private static func unzipArchive(at archive: URL, into dest: URL) throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + proc.arguments = ["-q", archive.path, "-d", dest.path] + let errPipe = Pipe() + proc.standardError = errPipe + proc.standardOutput = Pipe() + do { + try proc.run() + } catch { + throw RestoreError.archiveUnreadable("Couldn't launch unzip: \(error.localizedDescription)") + } + proc.waitUntilExit() + if proc.terminationStatus != 0 { + let tail = (try? errPipe.fileHandleForReading.readToEnd()) + .flatMap { $0.flatMap { String(data: $0, encoding: .utf8) } } ?? "" + throw RestoreError.archiveUnreadable("unzip exited \(proc.terminationStatus): \(tail)") + } + } + + /// Hash a local file in 1 MB chunks. We avoid loading the whole + /// file into memory because tarballs can be multi-GB. + private static func verifyHash(file: URL, expected: String) async throws { + guard let fh = try? FileHandle(forReadingFrom: file) else { + throw RestoreError.archiveUnreadable("missing inner file: \(file.lastPathComponent)") + } + defer { try? fh.close() } + var hasher = SHA256() + let chunkSize = 1024 * 1024 + while true { + let chunk = fh.readData(ofLength: chunkSize) + if chunk.isEmpty { break } + hasher.update(data: chunk) + } + let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined() + if actual != expected { + throw RestoreError.integrityCheckFailed(path: file.lastPathComponent, expected: expected, actual: actual) + } + } + + private static func shellQuote(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + /// Python source-literal quoting. Triple-quoted with backslash + /// escapes for embedded triple-quotes, backslashes, and the + /// language's own escape sequences. Used to safely embed JSON + + /// path strings into a `python3 -c '...'` invocation. + private static func pythonQuote(_ s: String) -> String { + let escaped = s + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"\"\"", with: "\\\"\\\"\\\"") + return "\"\"\"" + escaped + "\"\"\"" + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift index 3d7049c..e0733e3 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift @@ -176,6 +176,55 @@ public struct LocalTransport: ServerTransport { } #endif + public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream { + #if os(iOS) + return AsyncThrowingStream { $0.finish() } + #else + return AsyncThrowingStream { continuation in + Task.detached { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executable) + proc.arguments = args + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + do { + try proc.run() + } catch { + continuation.finish(throwing: error) + return + } + try? outPipe.fileHandleForWriting.close() + try? errPipe.fileHandleForWriting.close() + let handle = outPipe.fileHandleForReading + while true { + let chunk = handle.availableData + if chunk.isEmpty { break } + continuation.yield(chunk) + } + proc.waitUntilExit() + let stderrTail: String + if proc.terminationStatus != 0 { + stderrTail = (try? errPipe.fileHandleForReading.readToEnd()) + .flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? "" + } else { + stderrTail = "" + } + try? outPipe.fileHandleForReading.close() + try? errPipe.fileHandleForReading.close() + if proc.terminationStatus != 0 { + continuation.finish(throwing: TransportError.commandFailed( + exitCode: proc.terminationStatus, stderr: stderrTail + )) + } else { + continuation.finish() + } + } + } + #endif + } + public func streamLines(executable: String, args: [String]) -> AsyncThrowingStream { #if os(iOS) // LocalTransport doesn't run on iOS at runtime — the iOS app diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift index 60992f2..77d2244 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift @@ -523,6 +523,69 @@ public struct SSHTransport: ServerTransport { #endif } + public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream { + #if os(iOS) + return AsyncThrowingStream { $0.finish() } + #else + return AsyncThrowingStream { continuation in + Task.detached { [self] in + ensureControlDir() + // Same `bash -lc` wrapping as `streamLines` so PATH picks + // up profile-only entries (pipx, asdf, conda). The + // difference here is we yield raw `Data` chunks — no + // newline framing, no UTF-8 decoding. Required for + // backup tarballs. + let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ") + var sshArgv = sshArgs() + sshArgv.insert("-T", at: 0) + sshArgv.append(hostSpec) + sshArgv.append("bash") + sshArgv.append("-lc") + sshArgv.append(Self.shellQuote(cmd)) + let proc = Process() + proc.executableURL = URL(fileURLWithPath: sshBinary) + proc.arguments = sshArgv + proc.environment = Self.sshSubprocessEnvironment() + let outPipe = Pipe() + let errPipe = Pipe() + proc.standardOutput = outPipe + proc.standardError = errPipe + do { + try proc.run() + } catch { + continuation.finish(throwing: error) + return + } + try? outPipe.fileHandleForWriting.close() + try? errPipe.fileHandleForWriting.close() + let handle = outPipe.fileHandleForReading + while true { + let chunk = handle.availableData + if chunk.isEmpty { break } + continuation.yield(chunk) + } + proc.waitUntilExit() + let stderrTail: String + if proc.terminationStatus != 0 { + stderrTail = (try? errPipe.fileHandleForReading.readToEnd()) + .flatMap { String(data: $0 ?? Data(), encoding: .utf8) } ?? "" + } else { + stderrTail = "" + } + try? outPipe.fileHandleForReading.close() + try? errPipe.fileHandleForReading.close() + if proc.terminationStatus != 0 { + continuation.finish(throwing: TransportError.classifySSHFailure( + host: config.host, exitCode: proc.terminationStatus, stderr: stderrTail + )) + } else { + continuation.finish() + } + } + } + #endif + } + /// Injection point for ssh/scp subprocess environment enrichment. /// /// On the Mac app, this is wired at startup to diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift index 08e75a8..a2fd544 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift @@ -81,6 +81,21 @@ public protocol ServerTransport: Sendable { args: [String] ) -> AsyncThrowingStream + /// Binary-safe streaming exec. Same shape as `streamLines` but yields + /// arbitrary `Data` chunks of stdout instead of newline-delimited + /// strings. Required by the backup feature: `tar -czf -` produces + /// gzipped tar bytes that must NOT be decoded as UTF-8 / split on + /// `\n` — `streamLines` would silently corrupt the archive. + /// + /// Stream finishes on EOF / clean exit; errors with + /// `TransportError.commandFailed` on non-zero exit (carrying the + /// captured stderr tail). Chunk sizes are whatever the underlying + /// pipe returns from `availableData`, typically 4–64 KB on macOS. + nonisolated func streamRawBytes( + executable: String, + args: [String] + ) -> AsyncThrowingStream + // MARK: - SQLite /// Return a local filesystem URL pointing at a fresh, consistent copy of @@ -110,6 +125,25 @@ public protocol ServerTransport: Sendable { nonisolated func watchPaths(_ paths: [String]) -> AsyncStream } +public extension ServerTransport { + /// Default: backup-class binary streaming isn't implemented for + /// every transport (notably the iOS `CitadelServerTransport`, + /// which doesn't expose a raw stdout pipe). Concrete Mac + /// transports override this. The fallback yields a stream that + /// throws on first iteration so callers fail fast rather than + /// hanging silently. + nonisolated func streamRawBytes( + executable: String, + args: [String] + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + continuation.finish(throwing: TransportError.other( + message: "streamRawBytes is not supported on this transport" + )) + } + } +} + /// Stat-style file metadata. `nil` (return value) means the file does not /// exist or couldn't be queried. public struct FileStat: Sendable, Hashable { diff --git a/scarf/scarf/Features/Servers/ViewModels/BackupServerViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/BackupServerViewModel.swift new file mode 100644 index 0000000..ac9ba1b --- /dev/null +++ b/scarf/scarf/Features/Servers/ViewModels/BackupServerViewModel.swift @@ -0,0 +1,146 @@ +import Foundation +import Observation +import ScarfCore +import os + +/// Drives `BackupServerSheet`. Splits the user-facing flow into three +/// phases (preflight → run → done | failed) so the sheet renders one +/// coherent screen per phase. The actual backup work runs as a `Task` +/// that this VM owns; cancellation tears the SSH stream down via +/// `Task.checkCancellation()` checks inside `RemoteBackupService.run`. +@Observable +@MainActor +final class BackupServerViewModel { + enum Phase: Equatable { + case loading + case ready(RemoteBackupService.PreflightSummary) + case running(RemoteBackupService.Progress) + case done(RemoteBackupService.BackupResult) + case failed(String) + + static func == (lhs: Phase, rhs: Phase) -> Bool { + switch (lhs, rhs) { + case (.loading, .loading): return true + case (.ready(let a), .ready(let b)): return a == b + case (.running(let a), .running(let b)): return a == b + case (.done, .done): return true + case (.failed(let a), .failed(let b)): return a == b + default: return false + } + } + } + + private static let logger = Logger(subsystem: "com.scarf", category: "BackupServerViewModel") + + let context: ServerContext + var phase: Phase = .loading + var includeAuth = false + var includeMcpTokens = false + var includeLogs = false + var bytesPushedHermes: Int64 = 0 + var bytesPushedCurrentProject: Int64 = 0 + var currentProjectName: String? + + private var workTask: Task? + + init(context: ServerContext) { + self.context = context + } + + func start() async { + let service = RemoteBackupService(context: context) + do { + let summary = try await service.preflight() + phase = .ready(summary) + } catch { + phase = .failed(error.localizedDescription) + Self.logger.error("Backup preflight failed: \(error.localizedDescription, privacy: .public)") + } + } + + func runBackup(to destination: URL, summary: RemoteBackupService.PreflightSummary) { + let options = BackupManifest.Options( + includeAuth: includeAuth, + includeMcpTokens: includeMcpTokens, + includeLogs: includeLogs, + checkpointedWAL: summary.sqliteAvailable + ) + phase = .running(.preflight) + // Two-step capture: the outer task gets [weak self] so a sheet + // dismiss-mid-run doesn't pin the VM; once the task starts we + // promote to a strong reference so the @Sendable progress + // callback (called off-actor by the service) can hop back via + // an unowned hop without the Swift 6 capture warning. + let weakSelf = WeakBox(self) + workTask = Task { @MainActor in + guard let viewModel = weakSelf.value else { return } + let service = RemoteBackupService(context: viewModel.context) + do { + let result = try await service.run( + preflight: summary, + options: options, + archiveURL: destination, + progress: { step in + Task { @MainActor in + weakSelf.value?.applyProgress(step) + } + } + ) + viewModel.phase = .done(result) + } catch is CancellationError { + viewModel.phase = .failed("Cancelled.") + } catch { + viewModel.phase = .failed(error.localizedDescription) + Self.logger.error("Backup run failed: \(error.localizedDescription, privacy: .public)") + } + } + } + + /// Tiny weak-reference box that's `Sendable` even when its + /// referent isn't (the value is fetched on the actor). Lets us + /// pass a "weak self" handle through `@Sendable` closures + /// without the Swift 6 var-self warning. + private final class WeakBox: @unchecked Sendable { + weak var value: BackupServerViewModel? + init(_ v: BackupServerViewModel) { self.value = v } + } + + func cancel() { + workTask?.cancel() + workTask = nil + } + + private func applyProgress(_ step: RemoteBackupService.Progress) { + switch step { + case .archivingHermes(let n): + bytesPushedHermes = n + case .archivingProject(let name, let n): + currentProjectName = name + bytesPushedCurrentProject = n + default: + break + } + phase = .running(step) + } + + /// Default filename for the save panel — `-.scarfbackup`. + /// Slug-cased so it survives Finder display. + var defaultArchiveName: String { + let stamp = Self.timestamp() + let slug = context.displayName + .lowercased() + .replacingOccurrences(of: " ", with: "-") + .filter { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + let leaf = slug.isEmpty ? "scarf" : slug + return "\(leaf)-\(stamp).scarfbackup" + } + + private static func timestamp() -> String { + let f = DateFormatter() + f.calendar = Calendar(identifier: .iso8601) + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone.current + f.dateFormat = "yyyy-MM-dd-HHmmss" + return f.string(from: Date()) + } +} diff --git a/scarf/scarf/Features/Servers/ViewModels/RestoreServerViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/RestoreServerViewModel.swift new file mode 100644 index 0000000..a0221c0 --- /dev/null +++ b/scarf/scarf/Features/Servers/ViewModels/RestoreServerViewModel.swift @@ -0,0 +1,105 @@ +import Foundation +import Observation +import ScarfCore +import os + +/// Drives `RestoreServerSheet`. Mirrors `BackupServerViewModel`: the +/// flow is pickArchive → inspect → confirm → run → done | failed. +@Observable +@MainActor +final class RestoreServerViewModel { + enum Phase: Equatable { + case awaitingFile + case inspecting + case ready(RemoteRestoreService.InspectionResult) + case running(RemoteRestoreService.Progress) + case done(RemoteRestoreService.RestoreResult) + case failed(String) + + static func == (lhs: Phase, rhs: Phase) -> Bool { + switch (lhs, rhs) { + case (.awaitingFile, .awaitingFile): return true + case (.inspecting, .inspecting): return true + case (.ready, .ready): return true + case (.running(let a), .running(let b)): return a == b + case (.done, .done): return true + case (.failed(let a), .failed(let b)): return a == b + default: return false + } + } + } + + private static let logger = Logger(subsystem: "com.scarf", category: "RestoreServerViewModel") + + let context: ServerContext + var phase: Phase = .awaitingFile + var pauseCronJobs = true + var targetProjectsRoot: String = "" + + private var workTask: Task? + + init(context: ServerContext) { + self.context = context + } + + func inspect(archiveURL: URL) async { + phase = .inspecting + let service = RemoteRestoreService(context: context) + do { + let result = try await service.inspect(archiveURL: archiveURL) + // Default the projects root to `/projects`. + if targetProjectsRoot.isEmpty { + let home = result.targetHomeResolved ?? (result.manifest.hermes.homePath as NSString).deletingLastPathComponent + targetProjectsRoot = home + "/projects" + } + phase = .ready(result) + } catch { + phase = .failed(error.localizedDescription) + Self.logger.error("Restore inspect failed: \(error.localizedDescription, privacy: .public)") + } + } + + func runRestore(inspection: RemoteRestoreService.InspectionResult) { + let opts = RemoteRestoreService.RestoreOptions( + targetProjectsRoot: targetProjectsRoot.isEmpty ? nil : targetProjectsRoot, + pauseCronJobs: pauseCronJobs + ) + phase = .running(.planning) + // Same two-step capture pattern as BackupServerViewModel: + // weak handle in the outer Task, strong promotion inside, so + // the @Sendable progress callback hops back via the box + // without the Swift 6 var-self warning. + let weakSelf = WeakBox(self) + workTask = Task { @MainActor in + guard let viewModel = weakSelf.value else { return } + let service = RemoteRestoreService(context: viewModel.context) + do { + let result = try await service.run( + inspection: inspection, + options: opts, + progress: { step in + Task { @MainActor in + weakSelf.value?.phase = .running(step) + } + } + ) + viewModel.phase = .done(result) + } catch is CancellationError { + viewModel.phase = .failed("Cancelled.") + } catch { + viewModel.phase = .failed(error.localizedDescription) + Self.logger.error("Restore run failed: \(error.localizedDescription, privacy: .public)") + } + } + } + + private final class WeakBox: @unchecked Sendable { + weak var value: RestoreServerViewModel? + init(_ v: RestoreServerViewModel) { self.value = v } + } + + func cancel() { + workTask?.cancel() + workTask = nil + } +} diff --git a/scarf/scarf/Features/Servers/Views/BackupServerSheet.swift b/scarf/scarf/Features/Servers/Views/BackupServerSheet.swift new file mode 100644 index 0000000..b859524 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/BackupServerSheet.swift @@ -0,0 +1,275 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers +import ScarfCore +import ScarfDesign + +/// Sheet for running a full backup of a remote (or local) server. Walks +/// the user through preflight → confirm scope → run → done. +struct BackupServerSheet: View { + let context: ServerContext + @State private var viewModel: BackupServerViewModel + @Environment(\.dismiss) private var dismiss + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: BackupServerViewModel(context: context)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + ScrollView { + content + .padding(20) + } + Divider() + footer + } + .frame(width: 560, height: 540) + .task { + if case .loading = viewModel.phase { + await viewModel.start() + } + } + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "arrow.down.doc") + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text("Back up server").scarfStyle(.headline) + Text(verbatim: context.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + @ViewBuilder + private var content: some View { + switch viewModel.phase { + case .loading: + VStack(spacing: 12) { + ProgressView() + Text("Probing the server…").foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + + case .ready(let summary): + readyView(summary: summary) + + case .running(let step): + runningView(step: step) + + case .done(let result): + doneView(result: result) + + case .failed(let message): + failedView(message: message) + } + } + + private func readyView(summary: RemoteBackupService.PreflightSummary) -> some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Scope").font(.subheadline).bold().foregroundStyle(.secondary) + Text("Backs up the Hermes home (`~/.hermes/`) and every registered project so this server can be reconstructed from scratch.") + .font(.callout) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 6) { + row(label: "Hermes version", value: summary.hermesVersion ?? "(unknown)") + row(label: "Hermes home", value: summary.hermesHomePath, mono: true) + row(label: "Hermes home size", value: Self.formatBytes(summary.hermesHomeBytes)) + row(label: "Projects", value: "\(summary.projects.count) registered") + if !summary.projects.isEmpty { + let total: Int64 = summary.projects.compactMap { $0.sizeBytes }.reduce(0, +) + row(label: "Projects size", value: Self.formatBytes(total)) + } + if !summary.sqliteAvailable { + row(label: "WAL checkpoint", value: "skipped (sqlite3 not on remote PATH)") + } + } + + if !summary.projects.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Projects to include").font(.subheadline).bold().foregroundStyle(.secondary) + ForEach(summary.projects, id: \.path) { p in + HStack(spacing: 6) { + Image(systemName: p.reachable ? "folder.fill" : "exclamationmark.triangle.fill") + .foregroundStyle(p.reachable ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.orange)) + .font(.caption) + Text(verbatim: p.name).font(.callout) + Spacer() + Text(Self.formatBytes(p.sizeBytes)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + + VStack(alignment: .leading, spacing: 6) { + Text("Optional inclusions").font(.subheadline).bold().foregroundStyle(.secondary) + Toggle(isOn: $viewModel.includeAuth) { + VStack(alignment: .leading, spacing: 2) { + Text("Include `auth.json`").font(.callout) + Text("Provider credentials (Anthropic/OpenAI/Nous keys). **Off by default** — they're sensitive and you'll likely re-auth on the new droplet anyway.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Toggle(isOn: $viewModel.includeLogs) { + VStack(alignment: .leading, spacing: 2) { + Text("Include logs").font(.callout) + Text("`agent.log`, `errors.log`, `gateway.log`. Useful for forensics; usually skipped to keep archive size down.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + + private func runningView(step: RemoteBackupService.Progress) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + ProgressView() + Text(stepLabel(step)).font(.subheadline) + } + switch step { + case .archivingHermes(let n): + Text("Hermes home: \(Self.formatBytes(n)) so far") + .font(.caption) + .foregroundStyle(.secondary) + case .archivingProject(let name, let n): + Text(verbatim: "\(name): \(Self.formatBytes(n)) so far") + .font(.caption) + .foregroundStyle(.secondary) + default: + EmptyView() + } + } + .padding(.vertical, 30) + } + + private func doneView(result: RemoteBackupService.BackupResult) -> some View { + VStack(alignment: .leading, spacing: 14) { + Label("Backup complete", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.headline) + row(label: "Archive", value: result.archiveURL.lastPathComponent, mono: true) + row(label: "Size", value: Self.formatBytes(result.archiveSize)) + row(label: "Hermes version", value: result.manifest.source.hermesVersion ?? "(unknown)") + row(label: "Projects", value: "\(result.manifest.projects.count)") + HStack { + Button("Show in Finder") { + NSWorkspace.shared.activateFileViewerSelecting([result.archiveURL]) + } + .buttonStyle(.bordered) + Spacer() + } + } + } + + private func failedView(message: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label("Backup failed", systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + .font(.headline) + ScrollView { + Text(verbatim: message) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 180) + } + } + + private var footer: some View { + HStack { + switch viewModel.phase { + case .running: + Button("Cancel", role: .destructive) { + viewModel.cancel() + } + default: + Button("Close") { dismiss() } + } + Spacer() + switch viewModel.phase { + case .ready(let summary): + Button("Back up…") { presentSavePanel(summary: summary) } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + case .failed: + Button("Try again") { Task { await viewModel.start() } } + .keyboardShortcut(.defaultAction) + default: + EmptyView() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private func presentSavePanel(summary: RemoteBackupService.PreflightSummary) { + let panel = NSSavePanel() + panel.title = "Save Backup" + panel.prompt = "Back Up" + panel.nameFieldStringValue = viewModel.defaultArchiveName + if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { + let backupDir = documentsURL.appendingPathComponent("Scarf Backups", isDirectory: true) + try? FileManager.default.createDirectory(at: backupDir, withIntermediateDirectories: true) + panel.directoryURL = backupDir + } + panel.allowedContentTypes = [Self.scarfBackupType] + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + viewModel.runBackup(to: url, summary: summary) + } + + /// `.scarfbackup` declared inline (project doesn't have a shared + /// UTType bundle yet). `archive` parent type so Finder treats it + /// like any other archive bundle. + private static let scarfBackupType: UTType = { + if let t = UTType(filenameExtension: BackupArchiveLayout.archiveExtension) { return t } + return UTType.archive + }() + + private static func formatBytes(_ bytes: Int64?) -> String { + guard let bytes else { return "—" } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } + + private func stepLabel(_ step: RemoteBackupService.Progress) -> String { + switch step { + case .preflight: return "Preparing…" + case .checkpointingDB: return "Checkpointing state.db…" + case .archivingHermes: return "Archiving Hermes home…" + case .archivingProject(let name, _): return "Archiving project: \(name)…" + case .bundling: return "Bundling archive…" + case .finalizing: return "Finalizing…" + } + } + + @ViewBuilder + private func row(label: String, value: String, mono: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(label).font(.caption).foregroundStyle(.secondary).frame(width: 120, alignment: .leading) + Text(verbatim: value) + .font(mono ? .system(.caption, design: .monospaced) : .callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/scarf/scarf/Features/Servers/Views/ManageServersView.swift b/scarf/scarf/Features/Servers/Views/ManageServersView.swift index 9bab2c6..95d3473 100644 --- a/scarf/scarf/Features/Servers/Views/ManageServersView.swift +++ b/scarf/scarf/Features/Servers/Views/ManageServersView.swift @@ -12,6 +12,8 @@ struct ManageServersView: View { @State private var pendingRemoveID: ServerID? @State private var diagnosticsContext: ServerContext? @State private var importAlert: ImportAlertState? + @State private var backupContext: ServerContext? + @State private var restoreContext: ServerContext? /// Lightweight wrapper around the after-import message so we can /// present a single SwiftUI `.alert` for both success summaries @@ -44,6 +46,18 @@ struct ManageServersView: View { )) { wrapper in RemoteDiagnosticsView(context: wrapper.context) } + .sheet(item: Binding( + get: { backupContext.map { IdentifiableContext(context: $0) } }, + set: { backupContext = $0?.context } + )) { wrapper in + BackupServerSheet(context: wrapper.context) + } + .sheet(item: Binding( + get: { restoreContext.map { IdentifiableContext(context: $0) } }, + set: { restoreContext = $0?.context } + )) { wrapper in + RestoreServerSheet(context: wrapper.context) + } .confirmationDialog( "Remove this server?", isPresented: Binding( @@ -208,6 +222,7 @@ struct ManageServersView: View { .foregroundStyle(.secondary) } Spacer() + actionsMenu(for: ServerContext.local, removable: false) } .padding(.vertical, 4) @@ -225,21 +240,7 @@ struct ManageServersView: View { } } Spacer() - Button { - diagnosticsContext = entry.context - } label: { - Image(systemName: "stethoscope") - } - .buttonStyle(.borderless) - .help("Run remote diagnostics — check exactly which files are readable on this server.") - Button { - pendingRemoveID = entry.id - } label: { - Image(systemName: "trash") - } - .buttonStyle(.borderless) - .foregroundStyle(.red) - .help("Remove this server from Scarf.") + actionsMenu(for: entry.context, removable: true) } .padding(.vertical, 4) } @@ -247,6 +248,50 @@ struct ManageServersView: View { .listStyle(.inset) } + /// Per-row actions menu. Consolidates Backup / Restore / + /// Diagnostics / Remove behind a single ellipsis so the row stays + /// readable as the count of available actions grows. Local + /// servers can be backed up + restored just like remotes + /// (running `tar` against `~/.hermes`) but can't be removed — + /// the local entry is synthesized, not registry-backed. + @ViewBuilder + private func actionsMenu(for context: ServerContext, removable: Bool) -> some View { + Menu { + Button { + backupContext = context + } label: { + Label("Back Up…", systemImage: "arrow.down.doc") + } + Button { + restoreContext = context + } label: { + Label("Restore from Backup…", systemImage: "arrow.up.doc") + } + if context.isRemote { + Divider() + Button { + diagnosticsContext = context + } label: { + Label("Diagnostics…", systemImage: "stethoscope") + } + } + if removable { + Divider() + Button(role: .destructive) { + pendingRemoveID = context.id + } label: { + Label("Remove Server…", systemImage: "trash") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("Backup, restore, or remove this server.") + } + /// A star button that marks the open-on-launch default. Filled + yellow /// on the current default row (disabled, since clicking would be a /// no-op); outline + secondary elsewhere, clicking promotes that row diff --git a/scarf/scarf/Features/Servers/Views/RestoreServerSheet.swift b/scarf/scarf/Features/Servers/Views/RestoreServerSheet.swift new file mode 100644 index 0000000..fe3a8c8 --- /dev/null +++ b/scarf/scarf/Features/Servers/Views/RestoreServerSheet.swift @@ -0,0 +1,292 @@ +import SwiftUI +import AppKit +import UniformTypeIdentifiers +import ScarfCore +import ScarfDesign + +/// Sheet for restoring a `.scarfbackup` onto a server. Walks the user +/// through file pick → inspect (manifest preview + hash verify) → +/// confirm scope → run → done. +struct RestoreServerSheet: View { + let context: ServerContext + @State private var viewModel: RestoreServerViewModel + @Environment(\.dismiss) private var dismiss + + init(context: ServerContext) { + self.context = context + _viewModel = State(initialValue: RestoreServerViewModel(context: context)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + ScrollView { + content + .padding(20) + } + Divider() + footer + } + .frame(width: 580, height: 560) + .task { + if case .awaitingFile = viewModel.phase { + presentOpenPanel() + } + } + } + + private var header: some View { + HStack(spacing: 10) { + Image(systemName: "arrow.up.doc") + .font(.title2) + VStack(alignment: .leading, spacing: 2) { + Text("Restore from backup").scarfStyle(.headline) + Text(verbatim: "Target: \(context.displayName)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + @ViewBuilder + private var content: some View { + switch viewModel.phase { + case .awaitingFile: + VStack(spacing: 12) { + Image(systemName: "tray.and.arrow.up") + .font(.system(size: 32)) + .foregroundStyle(.secondary) + Text("Pick a `.scarfbackup` file to inspect.").foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + + case .inspecting: + VStack(spacing: 12) { + ProgressView() + Text("Validating archive + verifying hashes…").foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + + case .ready(let inspection): + readyView(inspection: inspection) + + case .running(let step): + runningView(step: step) + + case .done(let result): + doneView(result: result) + + case .failed(let message): + failedView(message: message) + } + } + + private func readyView(inspection: RemoteRestoreService.InspectionResult) -> some View { + let m = inspection.manifest + return VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Source").font(.subheadline).bold().foregroundStyle(.secondary) + row(label: "Server", value: m.source.displayName) + row(label: "Host", value: m.source.host, mono: true) + row(label: "Hermes version", value: m.source.hermesVersion ?? "(unknown)") + row(label: "Backup time", value: m.createdAt) + row(label: "Hermes size", value: ByteCountFormatter.string(fromByteCount: m.hermes.tarballSize, countStyle: .file)) + row(label: "Projects", value: "\(m.projects.count)") + } + + VStack(alignment: .leading, spacing: 6) { + Text("Target").font(.subheadline).bold().foregroundStyle(.secondary) + row(label: "Server", value: context.displayName) + if let v = inspection.targetHermesVersion { + row(label: "Hermes version", value: v) + } + if let h = inspection.targetHomeResolved { + row(label: "Home", value: h, mono: true) + } + } + + if !m.projects.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Projects landing path").font(.subheadline).bold().foregroundStyle(.secondary) + HStack { + TextField("e.g. /home/ubuntu/projects", text: $viewModel.targetProjectsRoot) + .textFieldStyle(.roundedBorder) + .font(.system(.callout, design: .monospaced)) + } + Text("Each project lands at `/`. Existing files at the same path will be overwritten.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + VStack(alignment: .leading, spacing: 6) { + Toggle(isOn: $viewModel.pauseCronJobs) { + VStack(alignment: .leading, spacing: 2) { + Text("Pause cron jobs after restore").font(.callout) + Text("Restored cron jobs may carry stale credentials or schedules you no longer want. Pausing them lets you re-enable intentionally from the Cron view.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // Warning panel for sensitive contents. + VStack(alignment: .leading, spacing: 6) { + if !m.options.includeAuth { + HStack(spacing: 6) { + Image(systemName: "info.circle").foregroundStyle(.secondary) + Text("`auth.json` was excluded — re-authenticate AI providers after restore.").font(.caption).foregroundStyle(.secondary) + } + } + if !m.options.includeMcpTokens { + HStack(spacing: 6) { + Image(systemName: "info.circle").foregroundStyle(.secondary) + Text("MCP tokens were excluded — re-authenticate any MCP servers (Spotify, Google Workspace, etc.) after restore.").font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + + private func runningView(step: RemoteRestoreService.Progress) -> some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + ProgressView() + Text(stepLabel(step)).font(.subheadline) + } + switch step { + case .restoringHermes(let n): + Text("Hermes home: \(ByteCountFormatter.string(fromByteCount: n, countStyle: .file)) pushed") + .font(.caption) + .foregroundStyle(.secondary) + case .restoringProject(let name, let n): + Text(verbatim: "\(name): \(ByteCountFormatter.string(fromByteCount: n, countStyle: .file)) pushed") + .font(.caption) + .foregroundStyle(.secondary) + default: + EmptyView() + } + } + .padding(.vertical, 30) + } + + private func doneView(result: RemoteRestoreService.RestoreResult) -> some View { + VStack(alignment: .leading, spacing: 14) { + Label("Restore complete", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.headline) + row(label: "Hermes home", value: result.hermesHome, mono: true) + row(label: "Projects", value: "\(result.projectsRestored.count) restored") + if result.cronJobsPaused > 0 { + row(label: "Cron jobs paused", value: "\(result.cronJobsPaused)") + } + if !result.projectsRestored.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Restored to").font(.caption).foregroundStyle(.secondary) + ForEach(result.projectsRestored, id: \.targetPath) { p in + Text(verbatim: "\(p.name) → \(p.targetPath)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + Text("Re-authenticate AI providers and any MCP servers from Settings if those weren't included in the backup.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + private func failedView(message: String) -> some View { + VStack(alignment: .leading, spacing: 12) { + Label("Restore failed", systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + .font(.headline) + ScrollView { + Text(verbatim: message) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 180) + } + } + + private var footer: some View { + HStack { + switch viewModel.phase { + case .running: + Button("Cancel", role: .destructive) { + viewModel.cancel() + } + default: + Button("Close") { dismiss() } + } + Spacer() + switch viewModel.phase { + case .ready(let inspection): + Button("Restore") { viewModel.runRestore(inspection: inspection) } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(viewModel.targetProjectsRoot.isEmpty) + case .failed: + Button("Pick another file") { presentOpenPanel() } + .keyboardShortcut(.defaultAction) + case .awaitingFile: + Button("Pick a backup…") { presentOpenPanel() } + .keyboardShortcut(.defaultAction) + default: + EmptyView() + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private func presentOpenPanel() { + let panel = NSOpenPanel() + panel.title = "Choose Backup" + panel.prompt = "Inspect" + panel.allowedContentTypes = [Self.scarfBackupType, .zip] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { + // User cancelled — keep the awaitingFile phase so the + // sheet's "Pick a backup…" button stays available. + return + } + Task { await viewModel.inspect(archiveURL: url) } + } + + private static let scarfBackupType: UTType = { + if let t = UTType(filenameExtension: BackupArchiveLayout.archiveExtension) { return t } + return UTType.archive + }() + + private func stepLabel(_ step: RemoteRestoreService.Progress) -> String { + switch step { + case .validating: return "Validating archive…" + case .verifyingHashes: return "Verifying hashes…" + case .planning: return "Planning…" + case .restoringHermes: return "Restoring Hermes home…" + case .restoringProject(let name, _): return "Restoring project: \(name)…" + case .reanchoringPaths: return "Re-anchoring project paths…" + case .pausingCron: return "Pausing cron jobs…" + case .finalizing: return "Finalizing…" + } + } + + @ViewBuilder + private func row(label: String, value: String, mono: Bool = false) -> some View { + HStack(alignment: .firstTextBaseline) { + Text(label).font(.caption).foregroundStyle(.secondary).frame(width: 120, alignment: .leading) + Text(verbatim: value) + .font(mono ? .system(.caption, design: .monospaced) : .callout) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index 2cda375..a4b178f 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -979,6 +979,14 @@ "comment" : "A description of the sign-in flow for a given provider.", "isCommentAutoGenerated" : true }, + "`agent.log`, `errors.log`, `gateway.log`. Useful for forensics; usually skipped to keep archive size down." : { + "comment" : "A description of the logs included in a backup.", + "isCommentAutoGenerated" : true + }, + "`auth.json` was excluded — re-authenticate AI providers after restore." : { + "comment" : "A warning that will be shown in a restore sheet if", + "isCommentAutoGenerated" : true + }, "`npx` not found on the Hermes host." : { }, @@ -3188,6 +3196,18 @@ } } }, + "Back up server" : { + "comment" : "A title for a backup server sheet.", + "isCommentAutoGenerated" : true + }, + "Back up…" : { + "comment" : "A button that triggers a backup of a remote (or local) server.", + "isCommentAutoGenerated" : true + }, + "Back Up…" : { + "comment" : "A label for backing up a server.", + "isCommentAutoGenerated" : true + }, "Backend" : { "localizations" : { "de" : { @@ -3228,6 +3248,10 @@ } } }, + "Backs up the Hermes home (`~/.hermes/`) and every registered project so this server can be reconstructed from scratch." : { + "comment" : "A description of the scope of a full backup.", + "isCommentAutoGenerated" : true + }, "Backup & Restore" : { "localizations" : { "de" : { @@ -3268,6 +3292,14 @@ } } }, + "Backup complete" : { + "comment" : "A label that indicates that a backup has completed.", + "isCommentAutoGenerated" : true + }, + "Backup failed" : { + "comment" : "A label that indicates that a backup failed.", + "isCommentAutoGenerated" : true + }, "Backup Now" : { "localizations" : { "de" : { @@ -3308,6 +3340,10 @@ } } }, + "Backup, restore, or remove this server." : { + "comment" : "A tooltip for the \"Backup, restore, or remove this server.\" button.", + "isCommentAutoGenerated" : true + }, "Becomes the key under mcp_servers: in config.yaml." : { "localizations" : { "de" : { @@ -5654,7 +5690,11 @@ } } }, - "Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard." : { + "Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/ and renames the shadow .hermes/ aside as .hermes.scarf-bak./ so it stops binding. Run it on the remote, then refresh the Dashboard." : { + "comment" : "A tooltip for the \"Copy fix command\" button.", + "isCommentAutoGenerated" : true + }, + "Copies a one-liner that renames this project's shadow .hermes/ aside as .hermes.scarf-bak./ so Hermes' CLI stops binding to it as $HERMES_HOME. Run it on the remote, then refresh the Dashboard." : { "comment" : "A tooltip for the \"Copy fix command\" button.", "isCommentAutoGenerated" : true }, @@ -7272,6 +7312,9 @@ } } } + }, + "Diagnostics…" : { + }, "Disable" : { "localizations" : { @@ -7496,6 +7539,10 @@ }, "Duplicate" : { + }, + "e.g. /home/ubuntu/projects" : { + "comment" : "A placeholder for a path to the root of a user's projects.", + "isCommentAutoGenerated" : true }, "e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : { "comment" : "A placeholder for a remote backup path.", @@ -7785,6 +7832,10 @@ } } }, + "Each project lands at `/`. Existing files at the same path will be overwritten." : { + "comment" : "A warning that projects will be overwritten during a restore.", + "isCommentAutoGenerated" : true + }, "Edit" : { "localizations" : { "de" : { @@ -8764,6 +8815,14 @@ "comment" : "A title for the error screen when exporting a template fails.", "isCommentAutoGenerated" : true }, + "Export or import the list of remote servers. SSH keys aren't included — you copy those separately." : { + "comment" : "A help message for the export/import button.", + "isCommentAutoGenerated" : true + }, + "Export Servers…" : { + "comment" : "A button that exports a list of servers.", + "isCommentAutoGenerated" : true + }, "Export..." : { "localizations" : { "de" : { @@ -9859,6 +9918,14 @@ } } }, + "Hermes home: %@ pushed" : { + "comment" : "A label that shows the size of the Hermes home directory that has been pushed to the server. The argument is the size of the Hermes home directory in bytes.", + "isCommentAutoGenerated" : true + }, + "Hermes home: %@ so far" : { + "comment" : "A label that shows the amount of data that has been backed up so far. The argument is the amount of data that has been backed up.", + "isCommentAutoGenerated" : true + }, "Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually." : { "localizations" : { "de" : { @@ -10161,6 +10228,14 @@ } } }, + "Hide sessions list" : { + "comment" : "A label for hiding the sessions list.", + "isCommentAutoGenerated" : true + }, + "Hide tool inspector" : { + "comment" : "A label for hiding the tool inspector.", + "isCommentAutoGenerated" : true + }, "Home Assistant Docs" : { }, @@ -10538,6 +10613,10 @@ } } }, + "Import Servers…" : { + "comment" : "A button that imports a list of servers from a file.", + "isCommentAutoGenerated" : true + }, "Inactive" : { "localizations" : { "de" : { @@ -10618,8 +10697,16 @@ } } }, + "Include `auth.json`" : { + "comment" : "A checkbox label for including the `auth.json` file.", + "isCommentAutoGenerated" : true + }, "Include Cron Jobs" : { + }, + "Include logs" : { + "comment" : "A checkbox label for including logs in a backup.", + "isCommentAutoGenerated" : true }, "Include Skills" : { "comment" : "A heading for a section of a template export sheet that lets the user select which skills to include in the generated template.", @@ -11622,6 +11709,9 @@ "Loading earlier…" : { "comment" : "A label displayed while loading older messages.", "isCommentAutoGenerated" : true + }, + "Loading providers…" : { + }, "Loading session…" : { "localizations" : { @@ -12174,6 +12264,10 @@ } } }, + "MCP tokens were excluded — re-authenticate any MCP servers (Spotify, Google Workspace, etc.) after restore." : { + "comment" : "A warning message displayed in a restore sheet.", + "isCommentAutoGenerated" : true + }, "Memory" : { "localizations" : { "de" : { @@ -14984,6 +15078,10 @@ } } }, + "Optional inclusions" : { + "comment" : "A heading for optional inclusions in a backup.", + "isCommentAutoGenerated" : true + }, "Optional. Sets the LLM model for this turn." : { "comment" : "A label for the LLM model override field in the slash command editor.", "isCommentAutoGenerated" : true @@ -15385,6 +15483,10 @@ } } }, + "Pause cron jobs after restore" : { + "comment" : "A checkbox to pause cron jobs after restore.", + "isCommentAutoGenerated" : true + }, "Pending Approvals" : { "localizations" : { "de" : { @@ -15585,6 +15687,14 @@ } } }, + "Pick a `.scarfbackup` file to inspect." : { + "comment" : "A description of the action to pick a `.scarfbackup` file.", + "isCommentAutoGenerated" : true + }, + "Pick a backup…" : { + "comment" : "A button that opens a file picker to select a `.scarfbackup` file.", + "isCommentAutoGenerated" : true + }, "Pick a model to start chatting" : { "comment" : "A heading for the chat model picker sheet.", "isCommentAutoGenerated" : true @@ -15629,6 +15739,10 @@ } } }, + "Pick another file" : { + "comment" : "A button that lets the user pick a new backup file.", + "isCommentAutoGenerated" : true + }, "Pick from catalog" : { "comment" : "A button to select a model from the catalog.", "isCommentAutoGenerated" : true @@ -16004,6 +16118,9 @@ } } } + }, + "Probing the server…" : { + }, "Profile" : { "localizations" : { @@ -16228,11 +16345,18 @@ } } } + }, + "Projects landing path" : { + }, "Projects registry" : { "comment" : "Section title for the section that lists the projects registry.", "isCommentAutoGenerated" : true }, + "Projects to include" : { + "comment" : "A heading for the list of projects to be backed up.", + "isCommentAutoGenerated" : true + }, "Prompt" : { "localizations" : { "de" : { @@ -16357,6 +16481,10 @@ } } }, + "Provider credentials (Anthropic/OpenAI/Nous keys). **Off by default** — they're sensitive and you'll likely re-auth on the new droplet anyway." : { + "comment" : "A description of the credentials that will be backed up.", + "isCommentAutoGenerated" : true + }, "Push to Talk" : { "localizations" : { "de" : { @@ -16682,6 +16810,10 @@ } } }, + "Re-authenticate AI providers and any MCP servers from Settings if those weren't included in the backup." : { + "comment" : "A message that instructs the user to re-authenticate AI providers and MCP servers if they weren't included in the backup.", + "isCommentAutoGenerated" : true + }, "Re-run" : { "localizations" : { "de" : { @@ -17303,11 +17435,16 @@ "comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.", "isCommentAutoGenerated" : true }, + "Remove Server…" : { + "comment" : "A label for a button that removes a server.", + "isCommentAutoGenerated" : true + }, "Remove the entire namespace dir recursively" : { "comment" : "A description of a template uninstall action.", "isCommentAutoGenerated" : true }, "Remove this server from Scarf." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -18024,6 +18161,18 @@ } } }, + "Restore complete" : { + "comment" : "A label that indicates that a restore has completed.", + "isCommentAutoGenerated" : true + }, + "Restore failed" : { + "comment" : "A label displayed when a restore fails.", + "isCommentAutoGenerated" : true + }, + "Restore from backup" : { + "comment" : "A title for a screen that lets the user restore a", + "isCommentAutoGenerated" : true + }, "Restore from backup?" : { "localizations" : { "de" : { @@ -18064,6 +18213,10 @@ } } }, + "Restore from Backup…" : { + "comment" : "A label for a button that restores a server from a backup.", + "isCommentAutoGenerated" : true + }, "Restore from remote backup" : { "comment" : "A heading for a sheet that lets the user restore from a remote backup.", "isCommentAutoGenerated" : true @@ -18108,6 +18261,14 @@ } } }, + "Restored cron jobs may carry stale credentials or schedules you no longer want. Pausing them lets you re-enable intentionally from the Cron view." : { + "comment" : "A warning message that appears in the Restore Server Sheet if the user has chosen to pause cron jobs after restoring a backup.", + "isCommentAutoGenerated" : true + }, + "Restored to" : { + "comment" : "A label displayed under a list of restored projects.", + "isCommentAutoGenerated" : true + }, "Result" : { "extractionState" : "stale", "localizations" : { @@ -18612,6 +18773,7 @@ } }, "Run remote diagnostics — check exactly which files are readable on this server." : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -19009,6 +19171,10 @@ "comment" : "A description of the purpose of the cron jobs.", "isCommentAutoGenerated" : true }, + "Scope" : { + "comment" : "A label displayed above the scope of the backup.", + "isCommentAutoGenerated" : true + }, "Search" : { "localizations" : { "de" : { @@ -20530,6 +20696,14 @@ } } }, + "Show sessions list" : { + "comment" : "A label for a button that shows the sessions list.", + "isCommentAutoGenerated" : true + }, + "Show tool inspector" : { + "comment" : "A button that shows the tool inspector.", + "isCommentAutoGenerated" : true + }, "Show values" : { "localizations" : { "de" : { @@ -21970,6 +22144,10 @@ "comment" : "A label for a field that allows the user to enter a list of tags, separated by commas.", "isCommentAutoGenerated" : true }, + "Target" : { + "comment" : "A heading for the target server of a restore.", + "isCommentAutoGenerated" : true + }, "Telegram Setup Docs" : { }, @@ -24103,6 +24281,9 @@ "v%@" : { "comment" : "A version number.", "isCommentAutoGenerated" : true + }, + "Validating archive + verifying hashes…" : { + }, "value" : {