Skip to content

Commit b86899c

Browse files
authored
feat: Add automatic migration from amazon-q to kiro-cli (#3359)
* feat: Add automatic migration from amazon-q to kiro-cli - Migrate database from {data_local_dir}/amazon-q to {data_local_dir}/kiro-cli - Migrate settings from {data_local_dir}/amazon-q to ~/.aws/kiro-cli - Transform api.q.service → api.kiro.service in settings - Add migration.kiro.completed flag to track completion - Implement file locking with rustix to prevent race conditions - Run migration automatically on CLI startup (silent, no user interaction) - Add hidden 'migrate' subcommand for manual migration - Update paths.rs to use new locations with fallback to old * Address comments
1 parent 80987c0 commit b86899c

File tree

8 files changed

+390
-12
lines changed

8 files changed

+390
-12
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ regex = "1.7.0"
8787
reqwest = { version = "0.12.14", default-features = false, features = ["http2", "charset", "rustls-tls", "rustls-tls-native-roots", "gzip", "json", "socks", "cookies", "stream"] }
8888
ring = "0.17.14"
8989
rusqlite = { version = "0.32.1", features = ["bundled", "serde_json"] }
90+
rustix = { version = "1.1.2", features = ["fs"] }
9091
rustls = "0.23.23"
9192
rustls-native-certs = "0.8.1"
9293
rustls-pemfile = "2.1.0"

crates/chat-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ regex.workspace = true
8080
reqwest.workspace = true
8181
ring.workspace = true
8282
rusqlite.workspace = true
83+
rustix.workspace = true
8384
rustls.workspace = true
8485
rustls-native-certs.workspace = true
8586
rustls-pemfile.workspace = true

crates/chat-cli/src/cli/migrate.rs

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
use std::path::{
2+
Path,
3+
PathBuf,
4+
};
5+
use std::process::ExitCode;
6+
7+
use clap::Parser;
8+
use eyre::{
9+
Context,
10+
Result,
11+
};
12+
use rustix::fs::{
13+
FlockOperation,
14+
flock,
15+
};
16+
use serde_json::{
17+
Map,
18+
Value,
19+
};
20+
use tokio::fs;
21+
22+
use crate::os::Os;
23+
use crate::util::paths::GlobalPaths;
24+
25+
#[derive(Debug, Parser, PartialEq)]
26+
pub struct MigrateArgs {
27+
/// Force migration even if already completed
28+
#[arg(long)]
29+
pub force: bool,
30+
31+
/// Dry run - show what would be migrated
32+
#[arg(long)]
33+
pub dry_run: bool,
34+
35+
/// Skip confirmation prompts
36+
#[arg(long, short = 'y')]
37+
pub yes: bool,
38+
}
39+
40+
impl MigrateArgs {
41+
pub async fn execute(self, os: &mut Os) -> Result<ExitCode> {
42+
// Try to acquire migration lock
43+
let _lock = match acquire_migration_lock()? {
44+
Some(lock) => lock,
45+
None => {
46+
println!("Migration already in progress by another process");
47+
return Ok(ExitCode::SUCCESS);
48+
},
49+
};
50+
51+
let status = detect_migration(os).await?;
52+
53+
if !self.force && matches!(status, MigrationStatus::Completed) {
54+
println!("✓ Migration already completed");
55+
return Ok(ExitCode::SUCCESS);
56+
}
57+
58+
let MigrationStatus::Needed {
59+
old_db,
60+
old_settings,
61+
new_db,
62+
new_settings,
63+
} = status
64+
else {
65+
println!("✓ No migration needed (fresh install)");
66+
return Ok(ExitCode::SUCCESS);
67+
};
68+
69+
if !self.yes && !self.dry_run {
70+
println!("This will migrate your database and settings from amazon-q to kiro-cli.");
71+
println!("\nMigration details:");
72+
println!(" • Database: {{data_local_dir}}/amazon-q → {{data_local_dir}}/kiro-cli");
73+
println!(" • Settings: {{data_local_dir}}/amazon-q → ~/.aws/kiro-cli");
74+
println!("\nContinue? (y/N)");
75+
76+
let mut input = String::new();
77+
std::io::stdin().read_line(&mut input)?;
78+
if !input.trim().eq_ignore_ascii_case("y") {
79+
println!("Migration cancelled");
80+
return Ok(ExitCode::SUCCESS);
81+
}
82+
}
83+
84+
// Migrate database
85+
let db_result = migrate_database(&old_db, &new_db, self.dry_run).await?;
86+
println!("✓ Database: {}", db_result.message);
87+
88+
// Reload database connection after copying the file
89+
if !self.dry_run && db_result.bytes_copied > 0 {
90+
os.database = crate::database::Database::new().await?;
91+
}
92+
93+
// Migrate settings
94+
let settings_result = migrate_settings(&old_settings, &new_settings, self.dry_run).await?;
95+
println!("✓ Settings: {}", settings_result.message);
96+
if !settings_result.transformations.is_empty() {
97+
println!(" Transformations applied:");
98+
for t in &settings_result.transformations {
99+
println!(" - {t}");
100+
}
101+
}
102+
103+
if !self.dry_run {
104+
os.database.set_kiro_migration_completed()?;
105+
println!("\n✓ Migration completed successfully!");
106+
} else {
107+
println!("\n(Dry run - no changes made)");
108+
}
109+
110+
Ok(ExitCode::SUCCESS)
111+
}
112+
}
113+
114+
// Migration detection
115+
#[derive(Debug)]
116+
enum MigrationStatus {
117+
NotNeeded,
118+
Needed {
119+
old_db: PathBuf,
120+
old_settings: PathBuf,
121+
new_db: PathBuf,
122+
new_settings: PathBuf,
123+
},
124+
Completed,
125+
}
126+
127+
async fn detect_migration(os: &mut Os) -> Result<MigrationStatus> {
128+
let old_db = GlobalPaths::old_database_path()?;
129+
let old_settings = GlobalPaths::old_settings_path()?;
130+
let new_db = GlobalPaths::database_path()?;
131+
let new_settings = GlobalPaths::settings_path()?;
132+
133+
let old_exists = old_db.exists() || old_settings.exists();
134+
let migration_completed = os.database.is_kiro_migration_completed().unwrap_or(false);
135+
136+
if migration_completed {
137+
Ok(MigrationStatus::Completed)
138+
} else if old_exists {
139+
Ok(MigrationStatus::Needed {
140+
old_db,
141+
old_settings,
142+
new_db,
143+
new_settings,
144+
})
145+
} else {
146+
Ok(MigrationStatus::NotNeeded)
147+
}
148+
}
149+
150+
// Database migration
151+
#[derive(Debug)]
152+
struct DbMigrationResult {
153+
message: String,
154+
#[allow(dead_code)]
155+
bytes_copied: u64,
156+
}
157+
158+
async fn migrate_database(old_path: &Path, new_path: &Path, dry_run: bool) -> Result<DbMigrationResult> {
159+
if !old_path.exists() {
160+
return Ok(DbMigrationResult {
161+
message: "No database to migrate".to_string(),
162+
bytes_copied: 0,
163+
});
164+
}
165+
166+
let metadata = fs::metadata(old_path).await.context("Cannot read source database")?;
167+
if !metadata.is_file() {
168+
eyre::bail!("Database is not a file");
169+
}
170+
if metadata.len() == 0 {
171+
eyre::bail!("Database is empty");
172+
}
173+
174+
if dry_run {
175+
return Ok(DbMigrationResult {
176+
message: format!(
177+
"Would copy database:\n From: {}\n To: {}",
178+
old_path.display(),
179+
new_path.display()
180+
),
181+
bytes_copied: 0,
182+
});
183+
}
184+
185+
if let Some(parent) = new_path.parent() {
186+
fs::create_dir_all(parent)
187+
.await
188+
.context(format!("Failed to create target directory: {}", parent.display()))?;
189+
}
190+
191+
let bytes = fs::copy(old_path, new_path).await.context("Failed to copy database")?;
192+
193+
let metadata = fs::metadata(new_path).await.context("Cannot read migrated database")?;
194+
if metadata.len() == 0 {
195+
eyre::bail!("Migrated database is empty");
196+
}
197+
198+
Ok(DbMigrationResult {
199+
message: format!("Migrated database ({bytes} bytes)"),
200+
bytes_copied: bytes,
201+
})
202+
}
203+
204+
// Settings migration
205+
#[derive(Debug)]
206+
struct SettingsMigrationResult {
207+
message: String,
208+
#[allow(dead_code)]
209+
settings_count: usize,
210+
transformations: Vec<String>,
211+
}
212+
213+
async fn migrate_settings(old_path: &Path, new_path: &Path, dry_run: bool) -> Result<SettingsMigrationResult> {
214+
let settings = if old_path.exists() {
215+
let content = fs::read_to_string(old_path)
216+
.await
217+
.context("Failed to read settings file")?;
218+
let value: Value = serde_json::from_str(&content).context("Failed to parse settings JSON")?;
219+
match value {
220+
Value::Object(map) => map,
221+
_ => Map::new(),
222+
}
223+
} else {
224+
Map::new()
225+
};
226+
227+
let mut transformed = settings;
228+
let mut transformations = Vec::new();
229+
230+
// Transform api.q.service → api.kiro.service
231+
if let Some(q_service) = transformed.remove("api.q.service") {
232+
transformed.insert("api.kiro.service".to_string(), q_service);
233+
transformations.push("api.q.service → api.kiro.service".to_string());
234+
}
235+
236+
// Add migration completed flag
237+
transformed.insert("migration.kiro.completed".to_string(), Value::Bool(true));
238+
transformations.push("Added migration.kiro.completed flag".to_string());
239+
240+
if dry_run {
241+
return Ok(SettingsMigrationResult {
242+
message: format!(
243+
"Would migrate settings:\n From: {}\n To: {}\n Transformations: {}",
244+
old_path.display(),
245+
new_path.display(),
246+
transformations.join(", ")
247+
),
248+
settings_count: transformed.len(),
249+
transformations,
250+
});
251+
}
252+
253+
if let Some(parent) = new_path.parent() {
254+
fs::create_dir_all(parent)
255+
.await
256+
.context(format!("Failed to create target directory: {}", parent.display()))?;
257+
}
258+
259+
let json = serde_json::to_string_pretty(&transformed).context("Failed to serialize settings")?;
260+
fs::write(new_path, json)
261+
.await
262+
.context("Failed to write settings file")?;
263+
264+
Ok(SettingsMigrationResult {
265+
message: "Settings migrated successfully".to_string(),
266+
settings_count: transformed.len(),
267+
transformations,
268+
})
269+
}
270+
271+
// File locking
272+
struct MigrationLock {
273+
_file: std::fs::File,
274+
path: PathBuf,
275+
}
276+
277+
impl Drop for MigrationLock {
278+
fn drop(&mut self) {
279+
let _ = std::fs::remove_file(&self.path);
280+
}
281+
}
282+
283+
fn acquire_migration_lock() -> Result<Option<MigrationLock>> {
284+
let lock_path = GlobalPaths::migration_lock_path()?;
285+
286+
if let Some(parent) = lock_path.parent() {
287+
std::fs::create_dir_all(parent)?;
288+
}
289+
290+
let file = std::fs::OpenOptions::new()
291+
.create(true)
292+
.write(true)
293+
.truncate(false)
294+
.open(&lock_path)?;
295+
296+
match flock(&file, FlockOperation::NonBlockingLockExclusive) {
297+
Ok(()) => Ok(Some(MigrationLock {
298+
_file: file,
299+
path: lock_path,
300+
})),
301+
Err(_) => {
302+
// Check if lock is stale (older than 1 minute)
303+
if let Ok(metadata) = std::fs::metadata(&lock_path) {
304+
if let Ok(modified) = metadata.modified() {
305+
if let Ok(elapsed) = modified.elapsed() {
306+
if elapsed.as_secs() > 60 {
307+
// Stale lock - remove and retry
308+
std::fs::remove_file(&lock_path)?;
309+
let file = std::fs::OpenOptions::new()
310+
.create(true)
311+
.write(true)
312+
.truncate(false)
313+
.open(&lock_path)?;
314+
return match flock(&file, FlockOperation::NonBlockingLockExclusive) {
315+
Ok(()) => Ok(Some(MigrationLock {
316+
_file: file,
317+
path: lock_path,
318+
})),
319+
Err(_) => Ok(None),
320+
};
321+
}
322+
}
323+
}
324+
}
325+
Ok(None)
326+
},
327+
}
328+
}

0 commit comments

Comments
 (0)