diff --git a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift index 0bca7bf..170b86e 100644 --- a/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift +++ b/scarf/Packages/ScarfIOS/Sources/ScarfIOS/CitadelServerTransport.swift @@ -407,8 +407,76 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db" // Double-quote paths; $HOME expansion happens inside double quotes. let rewritten = Self.rewriteHomeRelative(remotePath) - let backupScript = #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"# - _ = try await client.executeCommand(backupScript + " 2>&1") + + // Prepend the same PATH prefix `asyncRunProcess` uses so `sqlite3` + // resolves on hosts where it lives in /usr/local/bin or + // /opt/homebrew/bin (issue #56). Citadel's bare exec channel + // inherits a stripped PATH (typically `/usr/bin:/bin` on Linux); + // without this, statically-linked or custom-prefix sqlite3 + // installs fail "command not found" at exit 127. + let backupScript = + #"PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH" "# + + #"sqlite3 "\#(rewritten)" ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"# + + // Drive `executeCommandStream` instead of `executeCommand` so we + // capture stderr regardless of exit code (issue #56). Pre-fix + // a non-zero exit threw `CommandFailed` and discarded the buffer + // — surfaced as the unhelpful "Citadel.SSHClient.CommandFailed + // error 1" banner. Now we propagate the real stderr so + // `HermesDataService.humanize` can translate "sqlite3: command + // not found" / "no such file" / "permission denied" into the + // dashboard banner with actionable copy. + let stream: AsyncThrowingStream + do { + stream = try await client.executeCommandStream(backupScript) + } catch { + throw NSError( + domain: "CitadelServerTransport", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to start snapshot stream: \(error.localizedDescription)"] + ) + } + var stdout = Data() + var stderr = Data() + var exitCode: Int32 = 0 + do { + for try await chunk in stream { + switch chunk { + case .stdout(var buf): + if let s = buf.readString(length: buf.readableBytes) { + stdout.append(Data(s.utf8)) + } + case .stderr(var buf): + if let s = buf.readString(length: buf.readableBytes) { + stderr.append(Data(s.utf8)) + } + } + } + } catch let failed as SSHClient.CommandFailed { + exitCode = Int32(failed.exitCode) + } catch { + stderr.append(Data(error.localizedDescription.utf8)) + exitCode = -1 + } + if exitCode != 0 { + // Combine stdout + stderr into the error message — sqlite3 + // sometimes prints "Error: ..." on stdout depending on the + // remote shell. HermesDataService.humanize keys off + // substrings like "sqlite3: command not found", + // "permission denied", "no such file", so as long as one of + // them ends up in the message we get a useful banner. + let messageBytes = stderr.isEmpty ? stdout : stderr + let message = String(data: messageBytes, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + throw NSError( + domain: "CitadelServerTransport", + code: Int(exitCode), + userInfo: [ + NSLocalizedDescriptionKey: message.isEmpty + ? "Snapshot exited \(exitCode) with no output (likely sqlite3 missing on remote)" + : message + ] + ) + } // SFTP-download the remote tmp into our local snapshot cache. let sftp = try await connectionHolder.sftp()