feat(servers): backup + restore for any Scarf server

Adds an end-to-end "back up this server's full Hermes state" flow
with a verifiable archive format and a matching restore that pushes
it onto a fresh droplet. Tested against a 570 MB local Hermes home
+ 5 projects, then iterated against a real DigitalOcean droplet.

Architecture
- `.scarfbackup` is a ZIP containing `manifest.json` (schema v1,
  source server + hermes version + per-tarball SHA-256), one
  `hermes.tar.gz` (gzipped tar of `~/.hermes/`), and one
  `projects/<id>.tar.gz` per registered project. Streams via
  `tar -czf - …` over SSH; never buffers a full archive in memory.
- New `streamRawBytes(executable:args:)` on `ServerTransport`
  (Local + SSH impls) yields binary `Data` chunks. `streamLines`
  splits on `\n` and would corrupt tar output — needed a
  binary-safe sibling.
- `RemoteBackupService` runs preflight (resolves $HOME, probes
  hermes version, enumerates projects via the existing
  `ProjectDashboardService`, sizes each via `du -sb`, checks for
  `sqlite3`), optionally runs `PRAGMA wal_checkpoint(TRUNCATE)`
  to quiesce state.db, streams each tarball with incremental
  SHA-256, then ZIP-bundles via `/usr/bin/zip`. Atomic
  temp-then-rename so a partial archive never appears at the
  user-chosen destination.
- `RemoteRestoreService` unzips into a temp dir, validates the
  manifest's `kind` magic + `schemaVersion`, hash-verifies every
  inner tarball BEFORE pushing any bytes to the target, then
  streams each tarball into `tar -xzf - -C …` over SSH stdin.
  Post-restore: rewrites `~/.hermes/scarf/projects.json` with
  source→target path mappings via a small `python3 -c` script,
  and pauses every cron job (`enabled: false`) so restored jobs
  don't surprise-fire on a fresh droplet.

Defaults + safety
- Excluded from the backup unless explicitly opted in:
  `auth.json` (provider creds), `mcp-tokens/` (per-host OAuth),
  `logs/`. Always excluded: `state.db-{wal,shm}`,
  `gateway_state.json`, and standard project junk
  (`node_modules`, `.venv`, `.git/objects`, `__pycache__`,
  `.next`, `dist`).
- Manifest records `options.includeAuth/includeMcpTokens/
  includeLogs/checkpointedWAL` honestly so restore can warn
  the user about what they'll need to re-establish manually.
- All paths are tilde-expanded against the resolved remote
  `$HOME` before being passed to `tar`/`sqlite3`.
  `tar -C '~/projects'` would otherwise fail with
  "No such file or directory" because `shellQuote` wraps the
  path in single quotes and tar doesn't expand tildes itself.

UI
- Per-row ellipsis menu on `ManageServersView` consolidates
  Back Up… / Restore from Backup… / Diagnostics… / Remove…
  Keeps the row visually clean as actions grow. Local server
  gets Back Up + Restore (no Remove or Diagnostics).
- `BackupServerSheet` walks loading → ready (size + project
  list + auth/logs toggles) → running (byte-counter progress
  per stage) → done (Show in Finder) | failed (Try again).
- `RestoreServerSheet` walks awaitingFile → inspecting →
  ready (source-vs-target preview, projects-root chooser,
  cron-pause toggle, "auth was excluded" notes) → running →
  done | failed.
- Both view models use a `WeakBox` two-step capture pattern so
  the @Sendable progress callback hops back into MainActor
  without the Swift 6 var-self warning on nested closures.

Cleanup folded in
- Drops two no-op `await`s on sync `startReaders()` in
  `ProcessACPChannel` (warning surfaced after the Phase 1 ACP
  changes; cleanest to fix in the same Transport-layer touch).

Verified
- Local round-trip via a Swift CLI harness:
  preflight → backup → unzip listing matches manifest →
  on-disk SHA-256 matches manifest claim for every tarball.
- Real DigitalOcean droplet: backup completes after the
  tilde-expansion fix; restore preserves projects + sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-30 17:51:10 +02:00
parent 11946aad67
commit 7b864d77d5
13 changed files with 2414 additions and 18 deletions
@@ -86,7 +86,7 @@ public actor ProcessACPChannel: ACPChannel {
self.stderr = errStream self.stderr = errStream
self.stderrContinuation = errContinuation self.stderrContinuation = errContinuation
await startReaders() startReaders()
installTerminationHandler() installTerminationHandler()
} }
@@ -111,7 +111,7 @@ public actor ProcessACPChannel: ACPChannel {
self.stderr = errStream self.stderr = errStream
self.stderrContinuation = errContinuation self.stderrContinuation = errContinuation
await startReaders() startReaders()
installTerminationHandler() installTerminationHandler()
} }
@@ -0,0 +1,183 @@
import Foundation
/// Top-level manifest for a `.scarfbackup` archive.
///
/// **Archive layout** (`.scarfbackup` is a plain ZIP):
/// ```
/// <name>.scarfbackup
/// manifest.json this struct, JSON-encoded
/// hermes.tar.gz gzipped tar of `~/.hermes/` (minus exclusions)
/// projects/
/// <project-id>.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/<id>.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/<id>.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"
}
}
@@ -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 <cmd>` 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
}
}
@@ -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
/// `<targetProjectsRoot>/foo`, so the restore rewrites `path` for each
/// entry. Same logic for `<project>/.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
/// `<targetProjectsRoot>/<basename>`. Defaults to
/// `<targetHome>/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 `<targetHome>/.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 <target>` 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 + "\"\"\""
}
}
@@ -176,6 +176,55 @@ public struct LocalTransport: ServerTransport {
} }
#endif #endif
public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream<Data, Error> {
#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<String, Error> { public func streamLines(executable: String, args: [String]) -> AsyncThrowingStream<String, Error> {
#if os(iOS) #if os(iOS)
// LocalTransport doesn't run on iOS at runtime the iOS app // LocalTransport doesn't run on iOS at runtime the iOS app
@@ -523,6 +523,69 @@ public struct SSHTransport: ServerTransport {
#endif #endif
} }
public func streamRawBytes(executable: String, args: [String]) -> AsyncThrowingStream<Data, Error> {
#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. /// Injection point for ssh/scp subprocess environment enrichment.
/// ///
/// On the Mac app, this is wired at startup to /// On the Mac app, this is wired at startup to
@@ -81,6 +81,21 @@ public protocol ServerTransport: Sendable {
args: [String] args: [String]
) -> AsyncThrowingStream<String, Error> ) -> AsyncThrowingStream<String, Error>
/// 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 464 KB on macOS.
nonisolated func streamRawBytes(
executable: String,
args: [String]
) -> AsyncThrowingStream<Data, Error>
// MARK: - SQLite // MARK: - SQLite
/// Return a local filesystem URL pointing at a fresh, consistent copy of /// 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<WatchEvent> nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
} }
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<Data, Error> {
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 /// Stat-style file metadata. `nil` (return value) means the file does not
/// exist or couldn't be queried. /// exist or couldn't be queried.
public struct FileStat: Sendable, Hashable { public struct FileStat: Sendable, Hashable {
@@ -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<Void, Never>?
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 `<displayName>-<date>.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())
}
}
@@ -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<Void, Never>?
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 `<targetHome>/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
}
}
@@ -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)
}
}
}
@@ -12,6 +12,8 @@ struct ManageServersView: View {
@State private var pendingRemoveID: ServerID? @State private var pendingRemoveID: ServerID?
@State private var diagnosticsContext: ServerContext? @State private var diagnosticsContext: ServerContext?
@State private var importAlert: ImportAlertState? @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 /// Lightweight wrapper around the after-import message so we can
/// present a single SwiftUI `.alert` for both success summaries /// present a single SwiftUI `.alert` for both success summaries
@@ -44,6 +46,18 @@ struct ManageServersView: View {
)) { wrapper in )) { wrapper in
RemoteDiagnosticsView(context: wrapper.context) 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( .confirmationDialog(
"Remove this server?", "Remove this server?",
isPresented: Binding( isPresented: Binding(
@@ -208,6 +222,7 @@ struct ManageServersView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
actionsMenu(for: ServerContext.local, removable: false)
} }
.padding(.vertical, 4) .padding(.vertical, 4)
@@ -225,21 +240,7 @@ struct ManageServersView: View {
} }
} }
Spacer() Spacer()
Button { actionsMenu(for: entry.context, removable: true)
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.")
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
@@ -247,6 +248,50 @@ struct ManageServersView: View {
.listStyle(.inset) .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 /// A star button that marks the open-on-launch default. Filled + yellow
/// on the current default row (disabled, since clicking would be a /// on the current default row (disabled, since clicking would be a
/// no-op); outline + secondary elsewhere, clicking promotes that row /// no-op); outline + secondary elsewhere, clicking promotes that row
@@ -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 `<this path>/<project name>`. 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)
}
}
}
+182 -1
View File
@@ -979,6 +979,14 @@
"comment" : "A description of the sign-in flow for a given provider.", "comment" : "A description of the sign-in flow for a given provider.",
"isCommentAutoGenerated" : true "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." : { "`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" : { "Backend" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Backup & Restore" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Backup Now" : {
"localizations" : { "localizations" : {
"de" : { "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." : { "Becomes the key under mcp_servers: in config.yaml." : {
"localizations" : { "localizations" : {
"de" : { "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.<timestamp>/ 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.<timestamp>/ 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.", "comment" : "A tooltip for the \"Copy fix command\" button.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
@@ -7272,6 +7312,9 @@
} }
} }
} }
},
"Diagnostics…" : {
}, },
"Disable" : { "Disable" : {
"localizations" : { "localizations" : {
@@ -7496,6 +7539,10 @@
}, },
"Duplicate" : { "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" : { "e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : {
"comment" : "A placeholder for a remote backup path.", "comment" : "A placeholder for a remote backup path.",
@@ -7785,6 +7832,10 @@
} }
} }
}, },
"Each project lands at `<this path>/<project name>`. Existing files at the same path will be overwritten." : {
"comment" : "A warning that projects will be overwritten during a restore.",
"isCommentAutoGenerated" : true
},
"Edit" : { "Edit" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -8764,6 +8815,14 @@
"comment" : "A title for the error screen when exporting a template fails.", "comment" : "A title for the error screen when exporting a template fails.",
"isCommentAutoGenerated" : true "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..." : { "Export..." : {
"localizations" : { "localizations" : {
"de" : { "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." : { "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" : { "localizations" : {
"de" : { "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" : { "Home Assistant Docs" : {
}, },
@@ -10538,6 +10613,10 @@
} }
} }
}, },
"Import Servers…" : {
"comment" : "A button that imports a list of servers from a file.",
"isCommentAutoGenerated" : true
},
"Inactive" : { "Inactive" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -10618,8 +10697,16 @@
} }
} }
}, },
"Include `auth.json`" : {
"comment" : "A checkbox label for including the `auth.json` file.",
"isCommentAutoGenerated" : true
},
"Include Cron Jobs" : { "Include Cron Jobs" : {
},
"Include logs" : {
"comment" : "A checkbox label for including logs in a backup.",
"isCommentAutoGenerated" : true
}, },
"Include Skills" : { "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.", "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…" : { "Loading earlier…" : {
"comment" : "A label displayed while loading older messages.", "comment" : "A label displayed while loading older messages.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Loading providers…" : {
}, },
"Loading session…" : { "Loading session…" : {
"localizations" : { "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" : { "Memory" : {
"localizations" : { "localizations" : {
"de" : { "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." : { "Optional. Sets the LLM model for this turn." : {
"comment" : "A label for the LLM model override field in the slash command editor.", "comment" : "A label for the LLM model override field in the slash command editor.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -15385,6 +15483,10 @@
} }
} }
}, },
"Pause cron jobs after restore" : {
"comment" : "A checkbox to pause cron jobs after restore.",
"isCommentAutoGenerated" : true
},
"Pending Approvals" : { "Pending Approvals" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Pick a model to start chatting" : {
"comment" : "A heading for the chat model picker sheet.", "comment" : "A heading for the chat model picker sheet.",
"isCommentAutoGenerated" : true "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" : { "Pick from catalog" : {
"comment" : "A button to select a model from the catalog.", "comment" : "A button to select a model from the catalog.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@@ -16004,6 +16118,9 @@
} }
} }
} }
},
"Probing the server…" : {
}, },
"Profile" : { "Profile" : {
"localizations" : { "localizations" : {
@@ -16228,11 +16345,18 @@
} }
} }
} }
},
"Projects landing path" : {
}, },
"Projects registry" : { "Projects registry" : {
"comment" : "Section title for the section that lists the projects registry.", "comment" : "Section title for the section that lists the projects registry.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Projects to include" : {
"comment" : "A heading for the list of projects to be backed up.",
"isCommentAutoGenerated" : true
},
"Prompt" : { "Prompt" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Push to Talk" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Re-run" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -17303,11 +17435,16 @@
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.", "comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Remove Server…" : {
"comment" : "A label for a button that removes a server.",
"isCommentAutoGenerated" : true
},
"Remove the entire namespace dir recursively" : { "Remove the entire namespace dir recursively" : {
"comment" : "A description of a template uninstall action.", "comment" : "A description of a template uninstall action.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Remove this server from Scarf." : { "Remove this server from Scarf." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "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?" : { "Restore from backup?" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Restore from remote backup" : {
"comment" : "A heading for a sheet that lets the user restore from a remote backup.", "comment" : "A heading for a sheet that lets the user restore from a remote backup.",
"isCommentAutoGenerated" : true "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" : { "Result" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -18612,6 +18773,7 @@
} }
}, },
"Run remote diagnostics — check exactly which files are readable on this server." : { "Run remote diagnostics — check exactly which files are readable on this server." : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -19009,6 +19171,10 @@
"comment" : "A description of the purpose of the cron jobs.", "comment" : "A description of the purpose of the cron jobs.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Scope" : {
"comment" : "A label displayed above the scope of the backup.",
"isCommentAutoGenerated" : true
},
"Search" : { "Search" : {
"localizations" : { "localizations" : {
"de" : { "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" : { "Show values" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -21970,6 +22144,10 @@
"comment" : "A label for a field that allows the user to enter a list of tags, separated by commas.", "comment" : "A label for a field that allows the user to enter a list of tags, separated by commas.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
}, },
"Target" : {
"comment" : "A heading for the target server of a restore.",
"isCommentAutoGenerated" : true
},
"Telegram Setup Docs" : { "Telegram Setup Docs" : {
}, },
@@ -24103,6 +24281,9 @@
"v%@" : { "v%@" : {
"comment" : "A version number.", "comment" : "A version number.",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
},
"Validating archive + verifying hashes…" : {
}, },
"value" : { "value" : {