fix(dashboard): shadow Hermes-home consolidation actually clears the warning

The "Project-local Hermes home shadowing global setup" banner has a
"Copy fix command" button that produced a one-liner the user could
paste on the remote. The old command only `cp`'d the project's
`auth.json` into the global `~/.hermes/`; it never touched the
project-local `.hermes/` directory. Hermes' CLI binds to the
*closest* `.hermes/` as `$HERMES_HOME`, so the directory still being
there meant it still shadowed — the detector's
`fileExists(<project>/.hermes)` correctly kept returning true and
the warning didn't go away after the user "fixed" it. They got
stuck.

Fix: rename the project-local `.hermes/` to
`.hermes.scarf-bak.<UTC-stamp>/` after the auth copy. Hermes scans
for a directory literally named `.hermes`, so the rename is enough
to stop binding without losing user data — `state.db`, sessions,
skills all survive untouched in the renamed folder. The user can
inspect / delete the `.bak` later when confident. `mv` over
`rm -rf` because a project's shadow can hold uncommitted session
history; deletion would be unrecoverable, the rename is reversible.

Also removes the `if shadow.hasAuthJSON` gate around the "Copy fix
command" button — a state-only shadow (no creds, just `state.db`)
still binds as `$HERMES_HOME` and needs the same rename to clear
the warning. The button now always shows; the help-tooltip text
branches on `hasAuthJSON` to describe what the command will do.

Help-text now spells out the rename so the user knows where their
data went before they paste anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-30 17:51:33 +02:00
parent 7b864d77d5
commit 421e6030df
2 changed files with 64 additions and 23 deletions
@@ -101,14 +101,55 @@ public struct ProjectHermesShadowDetector: Sendable {
return found
}
/// Suggested shell command the user can copy-paste / run on the remote
/// to consolidate a shadow's auth.json into their global Hermes home.
/// Skips state.db / sessions / skills migration intentionally those
/// require Hermes to be quiesced and risk data loss; the user should
/// decide what to keep on a case-by-case basis. We give them the
/// load-bearing one-liner (auth) and let them handle the rest.
/// Suggested shell one-liner that consolidates a project shadow into
/// the global Hermes home AND clears the warning on the next
/// refresh. Two ordered steps:
///
/// 1. Copy `auth.json` into the global home (only when present).
/// Hermes credentials live in this single file; preserving them
/// is the load-bearing part of "consolidate" every other
/// project-local file is either replaceable or scoped to the
/// project anyway.
/// 2. Rename the project-local `.hermes/` to
/// `.hermes.scarf-bak.<UTC-stamp>/`. Hermes' CLI stops seeing it
/// as `$HERMES_HOME` (it scans for a dir literally named
/// `.hermes`), so the global home wins from now on. The
/// user's project-local data `state.db`, `sessions/`,
/// `skills/` survives untouched in the renamed folder, so
/// they can inspect/recover/delete it later without us making
/// that decision for them.
///
/// **Why not delete instead of rename.** A project's shadow can
/// hold uncommitted session history the user hasn't audited yet.
/// `rm -rf` would be unrecoverable; the rename keeps everything
/// addressable while still removing the shadow effect. The user
/// can delete the `.bak` once they're confident.
///
/// Returns a single shell line, suitable for the user to paste
/// into a remote terminal. The rename uses `date -u +%Y%m%d-%H%M%S`
/// for a deterministic UTC suffix so two consecutive consolidations
/// don't collide on the same second.
public static func consolidationCommand(for shadow: Shadow, hermesHome: String) -> String? {
guard shadow.hasAuthJSON else { return nil }
return "cp \(shadow.shadowPath)/auth.json \(hermesHome)/auth.json && chmod 600 \(hermesHome)/auth.json"
var parts: [String] = []
if shadow.hasAuthJSON {
parts.append("mkdir -p \(shellQuote(hermesHome))")
parts.append("cp \(shellQuote(shadow.shadowPath + "/auth.json")) \(shellQuote(hermesHome + "/auth.json"))")
parts.append("chmod 600 \(shellQuote(hermesHome + "/auth.json"))")
}
// The rename is unconditional: even shadows without auth.json
// still bind as $HERMES_HOME and need to move out of the way.
// `$(date -u +%Y%m%d-%H%M%S)` runs on the remote shell when
// the user pastes the command, producing the timestamp at
// exec time rather than at command-construction time.
parts.append("mv \(shellQuote(shadow.shadowPath)) \(shellQuote(shadow.shadowPath))\".scarf-bak.$(date -u +%Y%m%d-%H%M%S)\"")
return parts.joined(separator: " && ")
}
/// Single-quote a path for embedding in a `bash -c ''` string.
/// POSIX-safe single quotes with escape for embedded quotes
/// (`'` `'\\''`). Matches the convention in
/// `RemoteBackupService.shellQuote`.
private static func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
}
@@ -196,24 +196,24 @@ struct DashboardView: View {
}
}
Spacer()
if shadow.hasAuthJSON {
Button("Copy fix command") {
Task { @MainActor in
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
for: shadow,
hermesHome: home
) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(cmd, forType: .string)
}
Button("Copy fix command") {
Task { @MainActor in
let home = await viewModel.context.resolvedUserHome() + "/.hermes"
if let cmd = ProjectHermesShadowDetector.consolidationCommand(
for: shadow,
hermesHome: home
) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(cmd, forType: .string)
}
}
.buttonStyle(ScarfSecondaryButton())
.controlSize(.small)
.help("Copies a one-liner that consolidates this project's auth.json into your global ~/.hermes/. Run it on the remote, then refresh the Dashboard.")
}
.buttonStyle(ScarfSecondaryButton())
.controlSize(.small)
.help(shadow.hasAuthJSON
? "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."
: "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.")
}
.padding(ScarfSpace.s2)
.background(