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 {
|
||||
|
||||
Reference in New Issue
Block a user