1use crate::base::paths::HOME_DIR;
19use crate::profile::HarnessKind;
20use crate::profile::identity::STORE_DIR;
21
22pub const MERGE_DIR: &str = ".qcode-merge";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct Shared {
29 pub path: &'static str,
31 pub keys: &'static [&'static str],
33}
34
35const CLAUDE_CODE: Shared =
37 Shared { path: ".claude.json", keys: &["oauthAccount", "hasCompletedOnboarding", "lastOnboardingVersion"] };
38
39#[must_use]
41pub fn shared(harness: HarnessKind) -> Option<Shared> {
42 match harness {
43 HarnessKind::ClaudeCode => Some(CLAUDE_CODE),
44 _ => None,
45 }
46}
47
48const TAKE: &str = "const fs = require('fs'), path = require('path');
52const [from, to, ...keys] = process.argv.slice(1);
53let all; try { all = JSON.parse(fs.readFileSync(from, 'utf8')); } catch (e) { process.exit(0); }
54const taken = {};
55for (const key of keys) if (all && all[key] !== undefined) taken[key] = all[key];
56if (Object.keys(taken).length === 0) process.exit(0);
57fs.mkdirSync(path.dirname(to), { recursive: true });
58fs.writeFileSync(to, JSON.stringify(taken), { mode: 0o600 });";
59
60const MERGE: &str = "const fs = require('fs'), path = require('path');
65const [store, home, folder] = process.argv.slice(1);
66const root = path.join(store, folder);
67const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true })
68 .flatMap((entry) => entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)]);
69let files = []; try { files = walk(root); } catch (e) {}
70for (const file of files) {
71 const target = path.join(home, path.relative(root, file));
72 let into = {};
73 if (fs.existsSync(target)) { try { into = JSON.parse(fs.readFileSync(target, 'utf8')); } catch (e) { continue; } }
74 const given = JSON.parse(fs.readFileSync(file, 'utf8'));
75 fs.mkdirSync(path.dirname(target), { recursive: true });
76 fs.writeFileSync(target, JSON.stringify({ ...into, ...given }, null, 2), { mode: 0o600 });
77}
78fs.rmSync(path.join(home, folder), { recursive: true, force: true });";
79
80#[must_use]
84pub fn take(harness: HarnessKind, capture: &str) -> Option<Vec<String>> {
85 let shared = shared(harness)?;
86 let mut words = vec![
87 "node".to_owned(),
88 "-e".to_owned(),
89 TAKE.to_owned(),
90 format!("{HOME_DIR}/{}", shared.path),
91 format!("{capture}/{MERGE_DIR}/{}", shared.path),
92 ];
93 words.extend(shared.keys.iter().map(|key| (*key).to_owned()));
94 Some(words)
95}
96
97const STRIP: &str = "const fs = require('fs');
100const [file, ...keys] = process.argv.slice(1);
101let all; try { all = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { process.exit(0); }
102if (!all || typeof all !== 'object') process.exit(0);
103for (const key of keys) delete all[key];
104fs.writeFileSync(file, JSON.stringify(all, null, 2));";
105
106#[must_use]
111pub fn strip(harness: HarnessKind) -> Option<Vec<String>> {
112 let shared = shared(harness)?;
113 let mut words = vec!["node".to_owned(), "-e".to_owned(), STRIP.to_owned(), format!("{HOME_DIR}/{}", shared.path)];
114 words.extend(shared.keys.iter().map(|key| (*key).to_owned()));
115 Some(words)
116}
117
118#[must_use]
121pub fn merge() -> Vec<String> {
122 ["node", "-e", MERGE, STORE_DIR, HOME_DIR, MERGE_DIR].map(str::to_owned).to_vec()
123}
124
125#[cfg(all(test, unix))]
126mod tests {
127 use std::path::Path;
128 use std::process::Command;
129
130 use super::*;
131
132 fn node(words: &[String], root: &Path) -> bool {
135 let moved: Vec<String> = words
136 .iter()
137 .map(|word| if word.starts_with('/') { format!("{}{word}", root.display()) } else { word.clone() })
138 .collect();
139 Command::new(&moved[0]).args(&moved[1..]).status().is_ok_and(|status| status.success())
140 }
141
142 fn read(path: &Path) -> serde_json::Value {
143 serde_json::from_str(&std::fs::read_to_string(path).expect("the file is there")).expect("JSON")
144 }
145
146 #[test]
147 fn only_claude_code_shares_its_login_with_its_settings() {
148 assert_eq!(shared(HarnessKind::ClaudeCode).map(|shared| shared.path), Some(".claude.json"));
149 for harness in HarnessKind::ALL.into_iter().filter(|harness| *harness != HarnessKind::ClaudeCode) {
150 assert_eq!(shared(harness), None, "{harness:?}");
151 assert_eq!(take(harness, "/capture"), None, "{harness:?}");
152 }
153 }
154
155 #[test]
156 fn a_login_s_keys_are_taken_out_and_merged_into_a_home_keeping_what_the_home_had() {
157 let root = crate::testing::scratch("account-merge");
158 let _ = std::fs::remove_dir_all(&root);
159 let home = root.join(HOME_DIR.trim_start_matches('/'));
160 let capture = "/capture";
161 std::fs::create_dir_all(&home).expect("a home");
162 std::fs::write(
165 home.join(".claude.json"),
166 r#"{"hasCompletedOnboarding":true,"oauthAccount":{"emailAddress":"take@qcode.test"},"projects":{"/tmp":{}},"numStartups":3}"#,
167 )
168 .expect("the login container's file");
169 let words = take(HarnessKind::ClaudeCode, capture).expect("Claude Code has keys to take");
170 assert!(node(&words, &root), "the keys are taken");
171 let taken = read(&root.join("capture").join(MERGE_DIR).join(".claude.json"));
172 assert_eq!(taken["oauthAccount"]["emailAddress"], "take@qcode.test");
173 assert_eq!(taken["hasCompletedOnboarding"], true);
174 assert!(taken.get("projects").is_none() && taken.get("numStartups").is_none(), "{taken}");
175
176 let store = root.join(STORE_DIR.trim_start_matches('/'));
179 std::fs::create_dir_all(store.join(MERGE_DIR)).expect("a store");
180 std::fs::rename(
181 root.join("capture").join(MERGE_DIR).join(".claude.json"),
182 store.join(MERGE_DIR).join(".claude.json"),
183 )
184 .expect("the capture is stored");
185 std::fs::write(home.join(".claude.json"), r#"{"projects":{"/work":{"hasTrustDialogAccepted":true}}}"#)
186 .expect("the template's file");
187 std::fs::create_dir_all(home.join(MERGE_DIR)).expect("the copy's folder");
188 std::fs::copy(store.join(MERGE_DIR).join(".claude.json"), home.join(MERGE_DIR).join(".claude.json"))
189 .expect("the copy of the store");
190 assert!(node(&merge(), &root), "the keys are merged");
191 let merged = read(&home.join(".claude.json"));
192 assert_eq!(merged["oauthAccount"]["emailAddress"], "take@qcode.test");
193 assert_eq!(merged["hasCompletedOnboarding"], true);
194 assert_eq!(merged["projects"]["/work"]["hasTrustDialogAccepted"], true, "the template's keys stay");
195 assert!(!home.join(MERGE_DIR).exists(), "nothing of the copy is left in the home");
196 let _ = std::fs::remove_dir_all(&root);
197 }
198
199 #[test]
200 fn a_home_with_no_file_gets_one_and_a_home_file_that_is_not_json_is_left_alone() {
201 let root = crate::testing::scratch("account-merge-edges");
202 let _ = std::fs::remove_dir_all(&root);
203 let home = root.join(HOME_DIR.trim_start_matches('/'));
204 let store = root.join(STORE_DIR.trim_start_matches('/'));
205 std::fs::create_dir_all(store.join(MERGE_DIR)).expect("a store");
206 std::fs::create_dir_all(&home).expect("a home");
207 std::fs::write(store.join(MERGE_DIR).join(".claude.json"), r#"{"hasCompletedOnboarding":true}"#).expect("keys");
208 assert!(node(&merge(), &root));
209 assert_eq!(read(&home.join(".claude.json"))["hasCompletedOnboarding"], true);
210
211 std::fs::write(home.join(".claude.json"), "not json").expect("a broken file");
212 assert!(node(&merge(), &root));
213 assert_eq!(std::fs::read_to_string(home.join(".claude.json")).expect("there"), "not json");
214
215 std::fs::remove_file(home.join(".claude.json")).expect("removed");
217 let words = take(HarnessKind::ClaudeCode, "/capture").expect("keys");
218 assert!(node(&words, &root));
219 assert!(!root.join("capture").exists(), "nothing was taken");
220 let _ = std::fs::remove_dir_all(&root);
221 }
222 #[test]
223 fn a_login_made_in_a_profiles_shell_is_taken_out_of_the_shared_file_and_the_rest_stays() {
224 let root = crate::testing::scratch("account-strip");
225 let _ = std::fs::remove_dir_all(&root);
226 let home = root.join(HOME_DIR.trim_start_matches('/'));
227 std::fs::create_dir_all(&home).expect("a home");
228 std::fs::write(
229 home.join(".claude.json"),
230 r#"{"hasCompletedOnboarding":true,"lastOnboardingVersion":"2.1.282","oauthAccount":{"emailAddress":"strip@qcode.test"},"theme":"dark"}"#,
231 )
232 .expect("the shell's file");
233 let words = strip(HarnessKind::ClaudeCode).expect("Claude Code has keys to strip");
234 assert!(node(&words, &root), "the keys are stripped");
235 let left = read(&home.join(".claude.json"));
236 assert_eq!(left, serde_json::json!({"theme": "dark"}));
237 std::fs::write(home.join(".claude.json"), "not json").expect("a broken file");
238 assert!(node(&words, &root));
239 assert_eq!(std::fs::read_to_string(home.join(".claude.json")).expect("there"), "not json");
240 assert_eq!(strip(HarnessKind::OpenCode), None);
241 let _ = std::fs::remove_dir_all(&root);
242 }
243}