1use super::{Error, Result, Settled, access_token, identify, oauth_of, purge};
10use crate::api::Owner;
11use crate::context::Context;
12use crate::state::{Account, Park, State};
13use crate::{claude, home, park, state, store};
14use serde_json::{Value, json};
15use std::fs::{File, OpenOptions, TryLockError};
16use std::os::unix::fs::OpenOptionsExt;
17use std::path::PathBuf;
18use std::process::Command;
19
20pub enum Enrolled {
21 Current { email: String },
23 SignedIn { email: String },
25 Renewed { email: String },
27}
28
29pub struct SignIn {
32 dir: PathBuf,
33 document: Value,
34 ctx: Context,
35 _one_at_a_time: File,
36}
37
38impl Drop for SignIn {
39 fn drop(&mut self) {
40 let _ = store::discard_signin(&self.ctx, &self.dir);
41 let _ = std::fs::remove_dir_all(&self.dir);
42 }
43}
44
45fn reserve_signin(ctx: &Context) -> Result<SignIn> {
51 let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
52 path: home::dir(ctx),
53 source,
54 })?;
55 let lock_path = home.join("signin.lock");
56 let one_at_a_time = OpenOptions::new()
57 .create(true)
58 .truncate(false)
59 .write(true)
60 .mode(0o600)
61 .open(&lock_path)
62 .map_err(|source| Error::HomeUnwritable {
63 path: lock_path.clone(),
64 source,
65 })?;
66 match one_at_a_time.try_lock() {
67 Ok(()) => {}
68 Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
69 Err(TryLockError::Error(source)) => {
70 return Err(Error::HomeUnwritable {
71 path: lock_path,
72 source,
73 });
74 }
75 }
76
77 let dir = home.join("signin");
78 let _ = store::discard_signin(ctx, &dir);
83 let _ = std::fs::remove_dir_all(&dir);
84 home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
85 path: dir.clone(),
86 source,
87 })?;
88 Ok(SignIn {
89 dir,
90 document: Value::Null,
91 ctx: ctx.clone(),
92 _one_at_a_time: one_at_a_time,
93 })
94}
95
96pub fn sign_in(ctx: &Context) -> Result<SignIn> {
97 let mut pending = reserve_signin(ctx)?;
98 let finished = login(ctx, &pending.dir)
101 .stdout(std::io::stderr())
102 .status()
103 .map_err(started)?
104 .success();
105 if !finished {
106 return Err(Error::SignInIncomplete);
107 }
108 pending.document = signed_in_document(ctx, &pending.dir)?;
109 Ok(pending)
110}
111
112fn login(ctx: &Context, dir: &std::path::Path) -> Command {
117 let mut command = Command::new(&ctx.claude_program);
118 command
119 .args(["auth", "login"])
120 .env("CLAUDE_CONFIG_DIR", dir)
121 .env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR");
122 command
123}
124
125fn started(e: std::io::Error) -> Error {
126 match e.kind() {
127 std::io::ErrorKind::NotFound => Error::ClaudeNotFound,
128 _ => Error::SignInIncomplete,
129 }
130}
131
132fn signed_in_document(ctx: &Context, dir: &std::path::Path) -> Result<Value> {
133 let raw = store::read_signin(ctx, dir)?.ok_or(Error::SignInIncomplete)?;
134 serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
135 detail: e.to_string(),
136 })
137}
138
139pub struct WatchedSignIn {
142 child: std::process::Child,
143 said: std::sync::mpsc::Receiver<String>,
144 pending: SignIn,
145}
146
147impl WatchedSignIn {
148 pub fn next_line(&self) -> Option<String> {
151 self.said.recv().ok()
152 }
153
154 pub fn paste(&mut self, line: &str) -> Result<()> {
157 use std::io::Write;
158 let stdin = self.child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
159 writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
160 stdin.flush().map_err(|_| Error::SignInIncomplete)
161 }
162
163 pub fn finish(mut self) -> Result<SignIn> {
165 let finished = self
166 .child
167 .wait()
168 .map_err(|_| Error::SignInIncomplete)?
169 .success();
170 if !finished {
171 return Err(Error::SignInIncomplete);
172 }
173 let mut pending = self.pending;
174 pending.document = signed_in_document(&pending.ctx.clone(), &pending.dir.clone())?;
175 Ok(pending)
176 }
177
178 pub fn cancel(mut self) {
180 let _ = self.child.kill();
181 let _ = self.child.wait();
182 }
183}
184
185pub fn sign_in_watched(ctx: &Context) -> Result<WatchedSignIn> {
187 let pending = reserve_signin(ctx)?;
188 let mut child = login(ctx, &pending.dir)
189 .stdin(std::process::Stdio::piped())
190 .stdout(std::process::Stdio::piped())
191 .stderr(std::process::Stdio::piped())
192 .spawn()
193 .map_err(started)?;
194 let (say, said) = std::sync::mpsc::channel();
195 for stream in [
198 child.stdout.take().map(Readable::Out),
199 child.stderr.take().map(Readable::Err),
200 ]
201 .into_iter()
202 .flatten()
203 {
204 let say = say.clone();
205 std::thread::spawn(move || {
206 use std::io::Read;
207 let mut reader: Box<dyn Read + Send> = match stream {
208 Readable::Out(o) => Box::new(o),
209 Readable::Err(e) => Box::new(e),
210 };
211 let mut buffer = [0_u8; 1024];
212 while let Ok(read) = reader.read(&mut buffer) {
213 if read == 0 {
214 break;
215 }
216 let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
217 if say.send(text).is_err() {
218 break;
219 }
220 }
221 });
222 }
223 Ok(WatchedSignIn {
224 child,
225 said,
226 pending,
227 })
228}
229
230enum Readable {
231 Out(std::process::ChildStdout),
232 Err(std::process::ChildStderr),
233}
234
235pub fn enroll(settled: Settled, label: &str, signed_in: Option<SignIn>) -> Result<Enrolled> {
237 let Settled {
238 _exclusive,
239 mut state,
240 ctx,
241 } = settled;
242 match signed_in {
243 Some(login) => park_signed_in(&ctx, label, &mut state, &login),
244 None => record_current(&ctx, label, &mut state),
245 }
246}
247
248fn claim(state: &State, label: &str, owner: &Owner) -> Result<()> {
250 if let Some(taken) = state.get(label)
251 && taken.account_uuid != owner.account_uuid
252 {
253 return Err(Error::LabelTaken {
254 label: label.to_string(),
255 email: taken.email.clone(),
256 });
257 }
258 if let Some(existing) = state.by_uuid(&owner.account_uuid)
259 && existing.label != label
260 {
261 return Err(Error::AlreadyEnrolled {
262 email: owner.email.clone(),
263 label: existing.label.clone(),
264 });
265 }
266 Ok(())
267}
268
269fn record_current(ctx: &Context, label: &str, state: &mut State) -> Result<Enrolled> {
270 let live = store::read(ctx, &claude::live_service(ctx))?.ok_or(Error::LiveCredentialAbsent)?;
271 let owner = identify(ctx, &access_token(&live)?)?;
272 claim(state, label, &owner)?;
273 let parked = state.get(label).and_then(|a| a.parked.clone());
274 state.upsert(account(label, &owner, parked));
275 state.active = Some(label.to_string());
276 state::save(ctx, state)?;
277 Ok(Enrolled::Current { email: owner.email })
278}
279
280fn park_signed_in(
281 ctx: &Context,
282 label: &str,
283 state: &mut State,
284 login: &SignIn,
285) -> Result<Enrolled> {
286 let owner = identify(ctx, &access_token(&login.document)?)?;
287 claim(state, label, &owner)?;
288 let service = park::reserve(ctx, &owner.account_uuid)?;
289 let fresh = park::store_at(ctx, &service, &oauth_of(&login.document)?)?;
290 let existing = state.get(label);
291 let previous = existing.and_then(|a| a.parked.clone());
292 let renewed = existing.is_some();
293 state.upsert(account(label, &owner, previous));
294 state.park(label, fresh);
295 state::save(ctx, state).inspect_err(|_| {
297 let _ = store::vault_delete(ctx, &service);
298 })?;
299 purge(ctx, state);
300 Ok(if renewed {
301 Enrolled::Renewed { email: owner.email }
302 } else {
303 Enrolled::SignedIn { email: owner.email }
304 })
305}
306
307fn account(label: &str, owner: &Owner, parked: Option<Park>) -> Account {
310 Account {
311 label: label.to_string(),
312 account_uuid: owner.account_uuid.clone(),
313 email: owner.email.clone(),
314 organization_uuid: owner.organization_uuid.clone(),
315 oauth_account: json!({
316 "accountUuid": owner.account_uuid,
317 "emailAddress": owner.email,
318 "organizationUuid": owner.organization_uuid,
319 }),
320 parked,
321 }
322}