Skip to content

Commit 7d07791

Browse files
committed
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
1 parent 97773f1 commit 7d07791

File tree

8 files changed

+381
-13
lines changed

8 files changed

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

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod experiment;
1111
pub mod feed;
1212
mod issue;
1313
mod mcp;
14+
mod migrate;
1415
mod settings;
1516
mod user;
1617

@@ -127,6 +128,9 @@ pub enum RootSubcommand {
127128
/// Model Context Protocol (MCP)
128129
#[command(subcommand)]
129130
Mcp(McpSubcommand),
131+
/// Migrate data from amazon-q to kiro-cli (hidden)
132+
#[command(hide = true)]
133+
Migrate(migrate::MigrateArgs),
130134
}
131135

132136
impl RootSubcommand {
@@ -181,6 +185,7 @@ impl RootSubcommand {
181185
Self::Version { changelog } => Cli::print_version(changelog),
182186
Self::Chat(args) => args.execute(os).await,
183187
Self::Mcp(args) => args.execute(os, &mut std::io::stderr()).await,
188+
Self::Migrate(args) => args.execute(os).await,
184189
}
185190
}
186191
}
@@ -205,6 +210,7 @@ impl Display for RootSubcommand {
205210
Self::Issue(_) => "issue",
206211
Self::Version { .. } => "version",
207212
Self::Mcp(_) => "mcp",
213+
Self::Migrate(_) => "migrate",
208214
};
209215

210216
write!(f, "{name}")
@@ -260,6 +266,16 @@ impl Cli {
260266
debug!(command =? std::env::args().collect::<Vec<_>>(), "Command being ran");
261267

262268
let mut os = Os::new().await?;
269+
270+
// Run migration silently on startup (skips if already completed or locked)
271+
let _ = migrate::MigrateArgs {
272+
force: false,
273+
dry_run: false,
274+
yes: true,
275+
}
276+
.execute(&mut os)
277+
.await;
278+
263279
let result = subcommand.execute(&mut os).await;
264280

265281
let telemetry_result = os.telemetry.finish().await;

crates/chat-cli/src/database/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ impl Database {
196196
}
197197
.migrate();
198198
},
199-
false => GlobalPaths::database_path_static()?,
199+
false => GlobalPaths::database_path()?,
200200
};
201201

202202
// make the parent dir if it doesnt exist

crates/chat-cli/src/database/settings.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ impl Settings {
197197
return Ok(Self::default());
198198
}
199199

200-
let path = GlobalPaths::settings_path_static()?;
200+
let path = GlobalPaths::settings_path()?;
201201

202202
// If the folder doesn't exist, create it.
203203
if let Some(parent) = path.parent() {
@@ -261,7 +261,7 @@ impl Settings {
261261
return Ok(());
262262
}
263263

264-
let path = GlobalPaths::settings_path_static()?;
264+
let path = GlobalPaths::settings_path()?;
265265

266266
// If the folder doesn't exist, create it.
267267
if let Some(parent) = path.parent() {

0 commit comments

Comments
 (0)