mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -86,7 +86,7 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
self.stderr = errStream
|
||||
self.stderrContinuation = errContinuation
|
||||
|
||||
await startReaders()
|
||||
startReaders()
|
||||
installTerminationHandler()
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ public actor ProcessACPChannel: ACPChannel {
|
||||
self.stderr = errStream
|
||||
self.stderrContinuation = errContinuation
|
||||
|
||||
await startReaders()
|
||||
startReaders()
|
||||
installTerminationHandler()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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> {
|
||||
#if os(iOS)
|
||||
// LocalTransport doesn't run on iOS at runtime — the iOS app
|
||||
|
||||
@@ -523,6 +523,69 @@ public struct SSHTransport: ServerTransport {
|
||||
#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.
|
||||
///
|
||||
/// On the Mac app, this is wired at startup to
|
||||
|
||||
@@ -81,6 +81,21 @@ public protocol ServerTransport: Sendable {
|
||||
args: [String]
|
||||
) -> 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 4–64 KB on macOS.
|
||||
nonisolated func streamRawBytes(
|
||||
executable: String,
|
||||
args: [String]
|
||||
) -> AsyncThrowingStream<Data, Error>
|
||||
|
||||
// MARK: - SQLite
|
||||
|
||||
/// Return a local filesystem URL pointing at a fresh, consistent copy of
|
||||
@@ -110,6 +125,25 @@ public protocol ServerTransport: Sendable {
|
||||
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<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
|
||||
/// exist or couldn't be queried.
|
||||
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 diagnosticsContext: ServerContext?
|
||||
@State private var importAlert: ImportAlertState?
|
||||
@State private var backupContext: ServerContext?
|
||||
@State private var restoreContext: ServerContext?
|
||||
|
||||
/// Lightweight wrapper around the after-import message so we can
|
||||
/// present a single SwiftUI `.alert` for both success summaries
|
||||
@@ -44,6 +46,18 @@ struct ManageServersView: View {
|
||||
)) { wrapper in
|
||||
RemoteDiagnosticsView(context: wrapper.context)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { backupContext.map { IdentifiableContext(context: $0) } },
|
||||
set: { backupContext = $0?.context }
|
||||
)) { wrapper in
|
||||
BackupServerSheet(context: wrapper.context)
|
||||
}
|
||||
.sheet(item: Binding(
|
||||
get: { restoreContext.map { IdentifiableContext(context: $0) } },
|
||||
set: { restoreContext = $0?.context }
|
||||
)) { wrapper in
|
||||
RestoreServerSheet(context: wrapper.context)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Remove this server?",
|
||||
isPresented: Binding(
|
||||
@@ -208,6 +222,7 @@ struct ManageServersView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
actionsMenu(for: ServerContext.local, removable: false)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
|
||||
@@ -225,21 +240,7 @@ struct ManageServersView: View {
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
diagnosticsContext = entry.context
|
||||
} label: {
|
||||
Image(systemName: "stethoscope")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Run remote diagnostics — check exactly which files are readable on this server.")
|
||||
Button {
|
||||
pendingRemoveID = entry.id
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.foregroundStyle(.red)
|
||||
.help("Remove this server from Scarf.")
|
||||
actionsMenu(for: entry.context, removable: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
@@ -247,6 +248,50 @@ struct ManageServersView: View {
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
/// Per-row actions menu. Consolidates Backup / Restore /
|
||||
/// Diagnostics / Remove behind a single ellipsis so the row stays
|
||||
/// readable as the count of available actions grows. Local
|
||||
/// servers can be backed up + restored just like remotes
|
||||
/// (running `tar` against `~/.hermes`) but can't be removed —
|
||||
/// the local entry is synthesized, not registry-backed.
|
||||
@ViewBuilder
|
||||
private func actionsMenu(for context: ServerContext, removable: Bool) -> some View {
|
||||
Menu {
|
||||
Button {
|
||||
backupContext = context
|
||||
} label: {
|
||||
Label("Back Up…", systemImage: "arrow.down.doc")
|
||||
}
|
||||
Button {
|
||||
restoreContext = context
|
||||
} label: {
|
||||
Label("Restore from Backup…", systemImage: "arrow.up.doc")
|
||||
}
|
||||
if context.isRemote {
|
||||
Divider()
|
||||
Button {
|
||||
diagnosticsContext = context
|
||||
} label: {
|
||||
Label("Diagnostics…", systemImage: "stethoscope")
|
||||
}
|
||||
}
|
||||
if removable {
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
pendingRemoveID = context.id
|
||||
} label: {
|
||||
Label("Remove Server…", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.help("Backup, restore, or remove this server.")
|
||||
}
|
||||
|
||||
/// A star button that marks the open-on-launch default. Filled + yellow
|
||||
/// on the current default row (disabled, since clicking would be a
|
||||
/// no-op); outline + secondary elsewhere, clicking promotes that row
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -979,6 +979,14 @@
|
||||
"comment" : "A description of the sign-in flow for a given provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"`agent.log`, `errors.log`, `gateway.log`. Useful for forensics; usually skipped to keep archive size down." : {
|
||||
"comment" : "A description of the logs included in a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"`auth.json` was excluded — re-authenticate AI providers after restore." : {
|
||||
"comment" : "A warning that will be shown in a restore sheet if",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"`npx` not found on the Hermes host." : {
|
||||
|
||||
},
|
||||
@@ -3188,6 +3196,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Back up server" : {
|
||||
"comment" : "A title for a backup server sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Back up…" : {
|
||||
"comment" : "A button that triggers a backup of a remote (or local) server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Back Up…" : {
|
||||
"comment" : "A label for backing up a server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Backend" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3228,6 +3248,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Backs up the Hermes home (`~/.hermes/`) and every registered project so this server can be reconstructed from scratch." : {
|
||||
"comment" : "A description of the scope of a full backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Backup & Restore" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3268,6 +3292,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Backup complete" : {
|
||||
"comment" : "A label that indicates that a backup has completed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Backup failed" : {
|
||||
"comment" : "A label that indicates that a backup failed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Backup Now" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3308,6 +3340,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Backup, restore, or remove this server." : {
|
||||
"comment" : "A tooltip for the \"Backup, restore, or remove this server.\" button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Becomes the key under mcp_servers: in config.yaml." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5654,7 +5690,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard." : {
|
||||
"Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/ and renames the shadow .hermes/ aside as .hermes.scarf-bak.<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.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
@@ -7272,6 +7312,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Diagnostics…" : {
|
||||
|
||||
},
|
||||
"Disable" : {
|
||||
"localizations" : {
|
||||
@@ -7496,6 +7539,10 @@
|
||||
},
|
||||
"Duplicate" : {
|
||||
|
||||
},
|
||||
"e.g. /home/ubuntu/projects" : {
|
||||
"comment" : "A placeholder for a path to the root of a user's projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"e.g. ~/.hermes-backups/hermes-2026-04-28.zip" : {
|
||||
"comment" : "A placeholder for a remote backup path.",
|
||||
@@ -7785,6 +7832,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Each project lands at `<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" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -8764,6 +8815,14 @@
|
||||
"comment" : "A title for the error screen when exporting a template fails.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Export or import the list of remote servers. SSH keys aren't included — you copy those separately." : {
|
||||
"comment" : "A help message for the export/import button.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Export Servers…" : {
|
||||
"comment" : "A button that exports a list of servers.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Export..." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9859,6 +9918,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hermes home: %@ pushed" : {
|
||||
"comment" : "A label that shows the size of the Hermes home directory that has been pushed to the server. The argument is the size of the Hermes home directory in bytes.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hermes home: %@ so far" : {
|
||||
"comment" : "A label that shows the amount of data that has been backed up so far. The argument is the amount of data that has been backed up.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hermes needs a global webhook secret and port before subscriptions can receive traffic. Run the gateway setup wizard or edit ~/.hermes/config.yaml manually." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -10161,6 +10228,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hide sessions list" : {
|
||||
"comment" : "A label for hiding the sessions list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hide tool inspector" : {
|
||||
"comment" : "A label for hiding the tool inspector.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Home Assistant Docs" : {
|
||||
|
||||
},
|
||||
@@ -10538,6 +10613,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Import Servers…" : {
|
||||
"comment" : "A button that imports a list of servers from a file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Inactive" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -10618,8 +10697,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Include `auth.json`" : {
|
||||
"comment" : "A checkbox label for including the `auth.json` file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Include Cron Jobs" : {
|
||||
|
||||
},
|
||||
"Include logs" : {
|
||||
"comment" : "A checkbox label for including logs in a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Include Skills" : {
|
||||
"comment" : "A heading for a section of a template export sheet that lets the user select which skills to include in the generated template.",
|
||||
@@ -11622,6 +11709,9 @@
|
||||
"Loading earlier…" : {
|
||||
"comment" : "A label displayed while loading older messages.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Loading providers…" : {
|
||||
|
||||
},
|
||||
"Loading session…" : {
|
||||
"localizations" : {
|
||||
@@ -12174,6 +12264,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"MCP tokens were excluded — re-authenticate any MCP servers (Spotify, Google Workspace, etc.) after restore." : {
|
||||
"comment" : "A warning message displayed in a restore sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Memory" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14984,6 +15078,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Optional inclusions" : {
|
||||
"comment" : "A heading for optional inclusions in a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Optional. Sets the LLM model for this turn." : {
|
||||
"comment" : "A label for the LLM model override field in the slash command editor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -15385,6 +15483,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pause cron jobs after restore" : {
|
||||
"comment" : "A checkbox to pause cron jobs after restore.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pending Approvals" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15585,6 +15687,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pick a `.scarfbackup` file to inspect." : {
|
||||
"comment" : "A description of the action to pick a `.scarfbackup` file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pick a backup…" : {
|
||||
"comment" : "A button that opens a file picker to select a `.scarfbackup` file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pick a model to start chatting" : {
|
||||
"comment" : "A heading for the chat model picker sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -15629,6 +15739,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pick another file" : {
|
||||
"comment" : "A button that lets the user pick a new backup file.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pick from catalog" : {
|
||||
"comment" : "A button to select a model from the catalog.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -16004,6 +16118,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Probing the server…" : {
|
||||
|
||||
},
|
||||
"Profile" : {
|
||||
"localizations" : {
|
||||
@@ -16228,11 +16345,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Projects landing path" : {
|
||||
|
||||
},
|
||||
"Projects registry" : {
|
||||
"comment" : "Section title for the section that lists the projects registry.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Projects to include" : {
|
||||
"comment" : "A heading for the list of projects to be backed up.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Prompt" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16357,6 +16481,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Provider credentials (Anthropic/OpenAI/Nous keys). **Off by default** — they're sensitive and you'll likely re-auth on the new droplet anyway." : {
|
||||
"comment" : "A description of the credentials that will be backed up.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Push to Talk" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16682,6 +16810,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Re-authenticate AI providers and any MCP servers from Settings if those weren't included in the backup." : {
|
||||
"comment" : "A message that instructs the user to re-authenticate AI providers and MCP servers if they weren't included in the backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Re-run" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -17303,11 +17435,16 @@
|
||||
"comment" : "Title of a dialog that asks the user to confirm removing a project from Scarf's project list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove Server…" : {
|
||||
"comment" : "A label for a button that removes a server.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove the entire namespace dir recursively" : {
|
||||
"comment" : "A description of a template uninstall action.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Remove this server from Scarf." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -18024,6 +18161,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restore complete" : {
|
||||
"comment" : "A label that indicates that a restore has completed.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore failed" : {
|
||||
"comment" : "A label displayed when a restore fails.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore from backup" : {
|
||||
"comment" : "A title for a screen that lets the user restore a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore from backup?" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -18064,6 +18213,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restore from Backup…" : {
|
||||
"comment" : "A label for a button that restores a server from a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restore from remote backup" : {
|
||||
"comment" : "A heading for a sheet that lets the user restore from a remote backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -18108,6 +18261,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Restored cron jobs may carry stale credentials or schedules you no longer want. Pausing them lets you re-enable intentionally from the Cron view." : {
|
||||
"comment" : "A warning message that appears in the Restore Server Sheet if the user has chosen to pause cron jobs after restoring a backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Restored to" : {
|
||||
"comment" : "A label displayed under a list of restored projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Result" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
@@ -18612,6 +18773,7 @@
|
||||
}
|
||||
},
|
||||
"Run remote diagnostics — check exactly which files are readable on this server." : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -19009,6 +19171,10 @@
|
||||
"comment" : "A description of the purpose of the cron jobs.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Scope" : {
|
||||
"comment" : "A label displayed above the scope of the backup.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Search" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -20530,6 +20696,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show sessions list" : {
|
||||
"comment" : "A label for a button that shows the sessions list.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show tool inspector" : {
|
||||
"comment" : "A button that shows the tool inspector.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show values" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -21970,6 +22144,10 @@
|
||||
"comment" : "A label for a field that allows the user to enter a list of tags, separated by commas.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Target" : {
|
||||
"comment" : "A heading for the target server of a restore.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Telegram Setup Docs" : {
|
||||
|
||||
},
|
||||
@@ -24103,6 +24281,9 @@
|
||||
"v%@" : {
|
||||
"comment" : "A version number.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Validating archive + verifying hashes…" : {
|
||||
|
||||
},
|
||||
"value" : {
|
||||
|
||||
|
||||
Reference in New Issue
Block a user