feat(desktop): migrate legacy data dir and support dev/prod side-by-side (#168)

This commit is contained in:
Wes
2026-03-24 16:28:48 -07:00
committed by GitHub
parent 491a5160e5
commit a302aadb34
9 changed files with 334 additions and 46 deletions

View File

@@ -431,7 +431,7 @@ pub async fn create_managed_agent(
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| managed_agent_avatar_url(agent.agent_command.as_str()));
let profile_sync_error = match sync_managed_agent_profile(
let profile_sync_error = (sync_managed_agent_profile(
&state,
&resolved_relay_url,
&agent_keys,
@@ -440,11 +440,8 @@ pub async fn create_managed_agent(
&name,
avatar_url.as_deref(),
)
.await
{
Ok(()) => None,
Err(error) => Some(error),
};
.await)
.err();
// ── Phase 5: provider deploy (async, outside lock) ───────────────────────
let spawn_error = if input.spawn_after_create && input.backend != BackendKind::Local {

View File

@@ -2,6 +2,7 @@ mod app_state;
mod commands;
mod events;
mod managed_agents;
mod migration;
mod models;
mod relay;
mod util;
@@ -189,6 +190,11 @@ pub fn run() {
.setup(|app| {
let app_handle = app.handle().clone();
// Migrate data from the legacy `com.wesb.sprout` directory before
// resolving identity, so the persisted key is available at the new
// path on first launch after the identifier change.
migration::migrate_legacy_data_dir(&app_handle);
// Resolve persisted identity key (env var → file → generate+save).
// This is fatal — the app should not start with an ephemeral identity
// that will be lost on restart, as that silently breaks channel

View File

@@ -244,10 +244,10 @@ fn split_config_key(key: &str) -> Vec<String> {
// Start a new word on: (a) transition from lowercase to uppercase, or
// (b) uppercase followed by lowercase (end of acronym run, e.g. "APIKey" → "API" + "Key").
let prev_lower =
!current.is_empty() && current.chars().last().map_or(false, |c| c.is_lowercase());
!current.is_empty() && current.chars().last().is_some_and(|c| c.is_lowercase());
let acronym_end = !current.is_empty()
&& current.chars().last().map_or(false, |c| c.is_uppercase())
&& chars.get(i + 1).map_or(false, |c| c.is_lowercase());
&& current.chars().last().is_some_and(|c| c.is_uppercase())
&& chars.get(i + 1).is_some_and(|c| c.is_lowercase());
if prev_lower || acronym_end {
words.push(current.to_lowercase());
current.clear();

View File

@@ -2,9 +2,10 @@ use std::{path::PathBuf, process::Child};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum BackendKind {
#[default]
Local,
Provider {
id: String,
@@ -12,12 +13,6 @@ pub enum BackendKind {
},
}
impl Default for BackendKind {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersonaRecord {
pub id: String,

View File

@@ -0,0 +1,286 @@
//! Per-file migration from the legacy `com.wesb.sprout` app data directory
//! to the current `xyz.block.sprout.app` directory.
//!
//! On each launch, for every known file in [`LEGACY_FILES`]:
//! - If the file **already exists** at the new path → skip (it's its own guard).
//! - If the file exists at the old path but not the new → copy it over.
//!
//! Each `std::fs::copy` is atomic per-file, so there is no partial-migration
//! problem and no sentinel file is needed.
//!
//! The legacy directory is intentionally **not** deleted — users can clean it
//! up manually once they're satisfied everything works.
//!
//! Errors are logged but never fatal; the app must still start even if
//! individual file copies fail.
//!
//! **Note on dev/prod side-by-side:** Both the production build
//! (`xyz.block.sprout.app`) and the dev build (`xyz.block.sprout.app.dev`)
//! will attempt to migrate from the same legacy `com.wesb.sprout` directory,
//! resulting in duplicated identity keys. To avoid this, set the
//! `SPROUT_PRIVATE_KEY` env var when running the dev build — this bypasses
//! file-based identity resolution entirely.
use std::path::{Path, PathBuf};
use tauri::Manager;
const LEGACY_DATA_DIR_NAME: &str = "com.wesb.sprout";
/// Known files to migrate from the legacy data directory.
///
/// Agent logs and `.window-state.json` are excluded — logs are ephemeral and
/// the window-state plugin recreates its file automatically.
const LEGACY_FILES: &[&str] = &[
"identity.key",
"agents/managed-agents.json",
"agents/personas.json",
"agents/teams.json",
];
/// Compute the legacy `com.wesb.sprout` data directory path by replacing the
/// last component of the current app data directory.
fn legacy_data_dir(current: &Path) -> Option<PathBuf> {
current.parent().map(|p| p.join(LEGACY_DATA_DIR_NAME))
}
/// Copy a single file from `old_dir/rel` to `new_dir/rel`, creating parent
/// directories as needed. Skips the file if it already exists at the
/// destination.
///
/// Returns `true` if the file was copied, `false` if skipped or missing.
fn migrate_file(old_dir: &Path, new_dir: &Path, rel: &str) -> bool {
let src = old_dir.join(rel);
let dst = new_dir.join(rel);
if dst.exists() {
return false; // Already present — nothing to do.
}
if !src.exists() {
return false; // Nothing to migrate.
}
// Ensure parent directories exist (e.g. `agents/`).
if let Some(parent) = dst.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!(
"sprout-desktop: migration: failed to create {}: {e}",
parent.display()
);
return false;
}
}
match std::fs::copy(&src, &dst) {
Ok(_) => {
eprintln!("sprout-desktop: migration: copied {rel}");
true
}
Err(e) => {
eprintln!("sprout-desktop: migration: failed to copy {rel}: {e}");
false
}
}
}
/// Migrate known files from the legacy `com.wesb.sprout` app data directory
/// to the current directory.
///
/// Called in `setup()` **before** `resolve_persisted_identity` so the persisted
/// key is available at the new path on first launch after the identifier change.
pub fn migrate_legacy_data_dir(app: &tauri::AppHandle) {
let current_dir = match app.path().app_data_dir() {
Ok(dir) => dir,
Err(e) => {
eprintln!("sprout-desktop: migration: cannot resolve app data dir: {e}");
return;
}
};
let old_dir = match legacy_data_dir(&current_dir) {
Some(dir) => dir,
None => {
eprintln!("sprout-desktop: migration: cannot compute legacy data dir (no parent)");
return;
}
};
if !old_dir.exists() {
return; // Nothing to migrate.
}
let mut copied = 0u32;
for rel in LEGACY_FILES {
if migrate_file(&old_dir, &current_dir, rel) {
copied += 1;
}
}
if copied > 0 {
eprintln!("sprout-desktop: migration: {copied} file(s) migrated from legacy data dir");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_data_dir_replaces_last_component() {
let current = PathBuf::from("/Users/me/Library/Application Support/xyz.block.sprout.app");
let legacy = legacy_data_dir(&current).unwrap();
assert_eq!(
legacy,
PathBuf::from("/Users/me/Library/Application Support/com.wesb.sprout")
);
}
#[test]
fn legacy_data_dir_returns_none_for_root() {
let current = PathBuf::from("/");
let _ = legacy_data_dir(&current);
}
/// Helper: create a temp dir structure mimicking the old `com.wesb.sprout`
/// layout and return `(parent_dir, old_dir, new_dir)`.
fn setup_legacy_layout() -> (tempfile::TempDir, PathBuf, PathBuf) {
let parent = tempfile::tempdir().unwrap();
let old_dir = parent.path().join(LEGACY_DATA_DIR_NAME);
let new_dir = parent.path().join("xyz.block.sprout.app");
std::fs::create_dir_all(old_dir.join("agents")).unwrap();
std::fs::write(old_dir.join("identity.key"), "nsec1-fake-key-data").unwrap();
std::fs::write(
old_dir.join("agents/managed-agents.json"),
r#"[{"id":"a1"}]"#,
)
.unwrap();
std::fs::write(old_dir.join("agents/personas.json"), "[]").unwrap();
std::fs::write(old_dir.join("agents/teams.json"), "[]").unwrap();
(parent, old_dir, new_dir)
}
#[test]
fn migrate_file_copies_when_missing_at_dest() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
assert!(migrate_file(&old_dir, &new_dir, "identity.key"));
assert_eq!(
std::fs::read_to_string(new_dir.join("identity.key")).unwrap(),
"nsec1-fake-key-data"
);
}
#[test]
fn migrate_file_creates_parent_dirs() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
// agents/ doesn't exist in new_dir yet.
assert!(migrate_file(&old_dir, &new_dir, "agents/teams.json"));
assert_eq!(
std::fs::read_to_string(new_dir.join("agents/teams.json")).unwrap(),
"[]"
);
}
#[test]
fn migrate_file_skips_when_dest_exists() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
// Pre-create the file at the new location with different content.
std::fs::create_dir_all(&new_dir).unwrap();
std::fs::write(new_dir.join("identity.key"), "nsec1-new-key").unwrap();
// Should skip — returns false.
assert!(!migrate_file(&old_dir, &new_dir, "identity.key"));
// Original content preserved.
assert_eq!(
std::fs::read_to_string(new_dir.join("identity.key")).unwrap(),
"nsec1-new-key"
);
}
#[test]
fn migrate_file_skips_when_source_missing() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
// File that doesn't exist in old dir.
assert!(!migrate_file(&old_dir, &new_dir, "nonexistent.json"));
assert!(!new_dir.join("nonexistent.json").exists());
}
#[test]
fn migrate_all_known_files() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
let mut copied = 0u32;
for rel in LEGACY_FILES {
if migrate_file(&old_dir, &new_dir, rel) {
copied += 1;
}
}
assert_eq!(copied, 4);
assert_eq!(
std::fs::read_to_string(new_dir.join("identity.key")).unwrap(),
"nsec1-fake-key-data"
);
assert_eq!(
std::fs::read_to_string(new_dir.join("agents/managed-agents.json")).unwrap(),
r#"[{"id":"a1"}]"#
);
assert_eq!(
std::fs::read_to_string(new_dir.join("agents/personas.json")).unwrap(),
"[]"
);
assert_eq!(
std::fs::read_to_string(new_dir.join("agents/teams.json")).unwrap(),
"[]"
);
// Old directory must still exist.
assert!(old_dir.exists());
}
#[test]
fn migrate_is_idempotent() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
// First pass: copies everything.
let first_pass: u32 = LEGACY_FILES
.iter()
.map(|rel| u32::from(migrate_file(&old_dir, &new_dir, rel)))
.sum();
assert_eq!(first_pass, 4);
// Second pass: skips everything (all files already exist).
let second_pass: u32 = LEGACY_FILES
.iter()
.map(|rel| u32::from(migrate_file(&old_dir, &new_dir, rel)))
.sum();
assert_eq!(second_pass, 0);
}
#[test]
fn migrate_partial_only_copies_missing() {
let (_parent, old_dir, new_dir) = setup_legacy_layout();
// Pre-create identity.key in new dir — only the other 3 should copy.
std::fs::create_dir_all(&new_dir).unwrap();
std::fs::write(new_dir.join("identity.key"), "nsec1-already-here").unwrap();
let copied: u32 = LEGACY_FILES
.iter()
.map(|rel| u32::from(migrate_file(&old_dir, &new_dir, rel)))
.sum();
assert_eq!(copied, 3);
// identity.key should be untouched.
assert_eq!(
std::fs::read_to_string(new_dir.join("identity.key")).unwrap(),
"nsec1-already-here"
);
}
}

View File

@@ -171,7 +171,7 @@ pub fn build_nip98_auth_header_for_keys(
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.sign_with_keys(&keys)
.sign_with_keys(keys)
.map_err(|error| format!("sign failed: {error}"))?;
Ok(format!(

View File

@@ -27,6 +27,31 @@ pub fn slugify(name: &str, fallback: &str, max_len: usize) -> String {
raw.trim_end_matches('-').to_string()
}
pub fn percent_encode(input: &str) -> String {
let mut encoded = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
_ => {
let high = char::from_digit((byte >> 4) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
let low = char::from_digit((byte & 0x0f) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
encoded.push('%');
encoded.push(high);
encoded.push(low);
}
}
}
encoded
}
#[cfg(test)]
mod tests {
use super::slugify;
@@ -61,28 +86,3 @@ mod tests {
assert_eq!(slugify("abcde fghij", "x", 10), "abcde");
}
}
pub fn percent_encode(input: &str) -> String {
let mut encoded = String::with_capacity(input.len());
for byte in input.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
encoded.push(byte as char);
}
_ => {
let high = char::from_digit((byte >> 4) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
let low = char::from_digit((byte & 0x0f) as u32, 16)
.expect("nibble 0-15 is always a valid hex digit")
.to_ascii_uppercase();
encoded.push('%');
encoded.push(high);
encoded.push(low);
}
}
}
encoded
}

View File

@@ -0,0 +1,4 @@
{
"identifier": "xyz.block.sprout.app.dev",
"productName": "Sprout Dev"
}

View File

@@ -139,17 +139,17 @@ proxy:
proxy-release:
cargo run -p sprout-proxy --release
# Run the desktop Tauri app in dev mode
# Run the desktop Tauri app in dev mode (uses dev identifier for side-by-side with production)
dev *ARGS:
cd {{desktop_dir}} && pnpm tauri dev {{ARGS}}
cd {{desktop_dir}} && pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}}
# Run the desktop frontend dev server
desktop-dev:
cd {{desktop_dir}} && pnpm dev
# Run the desktop Tauri app
# Run the desktop Tauri app (uses dev identifier for side-by-side with production)
desktop-app *ARGS:
cd {{desktop_dir}} && pnpm tauri dev {{ARGS}}
cd {{desktop_dir}} && pnpm tauri dev --config src-tauri/tauri.dev.conf.json {{ARGS}}
# ─── Desktop Release ──────────────────────────────────────────────────────────