use super::{Error, Result, Settled, access_token, identify, oauth_of, purge};
use crate::api::Owner;
use crate::context::Context;
use crate::state::{Account, Park, State};
use crate::{claude, home, park, state, store};
use serde_json::{Value, json};
use std::fs::{File, OpenOptions, TryLockError};
use std::os::unix::fs::OpenOptionsExt;
use std::path::PathBuf;
use std::process::Command;
pub enum Enrolled {
Current { email: String },
SignedIn { email: String },
Renewed { email: String },
}
pub struct SignIn {
dir: PathBuf,
document: Value,
ctx: Context,
_one_at_a_time: File,
}
impl Drop for SignIn {
fn drop(&mut self) {
let _ = store::discard_signin(&self.ctx, &self.dir);
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn reserve_signin(ctx: &Context) -> Result<SignIn> {
let home = home::ensure(ctx).map_err(|source| Error::HomeUnwritable {
path: home::dir(ctx),
source,
})?;
let lock_path = home.join("signin.lock");
let one_at_a_time = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.mode(0o600)
.open(&lock_path)
.map_err(|source| Error::HomeUnwritable {
path: lock_path.clone(),
source,
})?;
match one_at_a_time.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => return Err(Error::SignInInProgress),
Err(TryLockError::Error(source)) => {
return Err(Error::HomeUnwritable {
path: lock_path,
source,
});
}
}
let dir = home.join("signin");
let _ = store::discard_signin(ctx, &dir);
let _ = std::fs::remove_dir_all(&dir);
home::create_private(&dir).map_err(|source| Error::HomeUnwritable {
path: dir.clone(),
source,
})?;
Ok(SignIn {
dir,
document: Value::Null,
ctx: ctx.clone(),
_one_at_a_time: one_at_a_time,
})
}
pub fn sign_in(ctx: &Context) -> Result<SignIn> {
let mut pending = reserve_signin(ctx)?;
let finished = login(ctx, &pending.dir)
.stdout(std::io::stderr())
.status()
.map_err(started)?
.success();
if !finished {
return Err(Error::SignInIncomplete);
}
pending.document = signed_in_document(ctx, &pending.dir)?;
Ok(pending)
}
fn login(ctx: &Context, dir: &std::path::Path) -> Command {
let mut command = Command::new(&ctx.claude_program);
command
.args(["auth", "login"])
.env("CLAUDE_CONFIG_DIR", dir)
.env_remove("CLAUDE_SECURESTORAGE_CONFIG_DIR");
command
}
fn started(e: std::io::Error) -> Error {
match e.kind() {
std::io::ErrorKind::NotFound => Error::ClaudeNotFound,
_ => Error::SignInIncomplete,
}
}
fn signed_in_document(ctx: &Context, dir: &std::path::Path) -> Result<Value> {
let raw = store::read_signin(ctx, dir)?.ok_or(Error::SignInIncomplete)?;
serde_json::from_str(&raw).map_err(|e| Error::LiveCredentialShapeUnexpected {
detail: e.to_string(),
})
}
pub struct WatchedSignIn {
child: std::process::Child,
said: std::sync::mpsc::Receiver<String>,
pending: SignIn,
}
impl WatchedSignIn {
pub fn next_line(&self) -> Option<String> {
self.said.recv().ok()
}
pub fn paste(&mut self, line: &str) -> Result<()> {
use std::io::Write;
let stdin = self.child.stdin.as_mut().ok_or(Error::SignInIncomplete)?;
writeln!(stdin, "{line}").map_err(|_| Error::SignInIncomplete)?;
stdin.flush().map_err(|_| Error::SignInIncomplete)
}
pub fn finish(mut self) -> Result<SignIn> {
let finished = self
.child
.wait()
.map_err(|_| Error::SignInIncomplete)?
.success();
if !finished {
return Err(Error::SignInIncomplete);
}
let mut pending = self.pending;
pending.document = signed_in_document(&pending.ctx.clone(), &pending.dir.clone())?;
Ok(pending)
}
pub fn cancel(mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
pub fn sign_in_watched(ctx: &Context) -> Result<WatchedSignIn> {
let pending = reserve_signin(ctx)?;
let mut child = login(ctx, &pending.dir)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(started)?;
let (say, said) = std::sync::mpsc::channel();
for stream in [
child.stdout.take().map(Readable::Out),
child.stderr.take().map(Readable::Err),
]
.into_iter()
.flatten()
{
let say = say.clone();
std::thread::spawn(move || {
use std::io::Read;
let mut reader: Box<dyn Read + Send> = match stream {
Readable::Out(o) => Box::new(o),
Readable::Err(e) => Box::new(e),
};
let mut buffer = [0_u8; 1024];
while let Ok(read) = reader.read(&mut buffer) {
if read == 0 {
break;
}
let text = String::from_utf8_lossy(&buffer[..read]).into_owned();
if say.send(text).is_err() {
break;
}
}
});
}
Ok(WatchedSignIn {
child,
said,
pending,
})
}
enum Readable {
Out(std::process::ChildStdout),
Err(std::process::ChildStderr),
}
pub fn enroll(settled: Settled, label: &str, signed_in: Option<SignIn>) -> Result<Enrolled> {
let Settled {
_exclusive,
mut state,
ctx,
} = settled;
match signed_in {
Some(login) => park_signed_in(&ctx, label, &mut state, &login),
None => record_current(&ctx, label, &mut state),
}
}
fn claim(state: &State, label: &str, owner: &Owner) -> Result<()> {
if let Some(taken) = state.get(label)
&& taken.account_uuid != owner.account_uuid
{
return Err(Error::LabelTaken {
label: label.to_string(),
email: taken.email.clone(),
});
}
if let Some(existing) = state.by_uuid(&owner.account_uuid)
&& existing.label != label
{
return Err(Error::AlreadyEnrolled {
email: owner.email.clone(),
label: existing.label.clone(),
});
}
Ok(())
}
fn record_current(ctx: &Context, label: &str, state: &mut State) -> Result<Enrolled> {
let live = store::read(ctx, &claude::live_service(ctx))?.ok_or(Error::LiveCredentialAbsent)?;
let owner = identify(ctx, &access_token(&live)?)?;
claim(state, label, &owner)?;
let parked = state.get(label).and_then(|a| a.parked.clone());
state.upsert(account(label, &owner, parked));
state.active = Some(label.to_string());
state::save(ctx, state)?;
Ok(Enrolled::Current { email: owner.email })
}
fn park_signed_in(
ctx: &Context,
label: &str,
state: &mut State,
login: &SignIn,
) -> Result<Enrolled> {
let owner = identify(ctx, &access_token(&login.document)?)?;
claim(state, label, &owner)?;
let service = park::reserve(ctx, &owner.account_uuid)?;
let fresh = park::store_at(ctx, &service, &oauth_of(&login.document)?)?;
let existing = state.get(label);
let previous = existing.and_then(|a| a.parked.clone());
let renewed = existing.is_some();
state.upsert(account(label, &owner, previous));
state.park(label, fresh);
state::save(ctx, state).inspect_err(|_| {
let _ = store::vault_delete(ctx, &service);
})?;
purge(ctx, state);
Ok(if renewed {
Enrolled::Renewed { email: owner.email }
} else {
Enrolled::SignedIn { email: owner.email }
})
}
fn account(label: &str, owner: &Owner, parked: Option<Park>) -> Account {
Account {
label: label.to_string(),
account_uuid: owner.account_uuid.clone(),
email: owner.email.clone(),
organization_uuid: owner.organization_uuid.clone(),
oauth_account: json!({
"accountUuid": owner.account_uuid,
"emailAddress": owner.email,
"organizationUuid": owner.organization_uuid,
}),
parked,
}
}