use anyhow::{anyhow, Result};
use futures_util::StreamExt;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use crate::cli::{
Cli, OutputFormat, SpindleCommand, SpindleConfigArgs, SpindleListArgs,
SpindleLogsArgs, SpindleRunArgs, SpindleSecretAddArgs,
SpindleSecretCommand, SpindleSecretListArgs, SpindleSecretRemoveArgs,
};
pub async fn run(cli: &Cli, cmd: SpindleCommand) -> Result<()> {
match cmd {
SpindleCommand::List(args) => list(cli, args).await,
SpindleCommand::Config(args) => config(args).await,
SpindleCommand::Run(args) => run_pipeline(args).await,
SpindleCommand::Logs(args) => logs(args).await,
SpindleCommand::Secret(cmd) => secret(cli, cmd).await,
}
}
async fn list(cli: &Cli, args: SpindleListArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let (owner, name) = parse_repo_ref(
args.repo.as_deref().unwrap_or(&session.handle),
&session.handle,
);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
let pipelines =
crate::ops::repo::list_pipelines(&pds, &info.did, &auth).await?;
if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
return crate::util::print_serialized(cli.format, &pipelines);
}
if pipelines.is_empty() {
println!("No pipelines found for {}/{}", owner, name);
} else {
crate::util::print_table(
["RKEY", "KIND", "REPOSITORY", "WORKFLOWS"],
pipelines.into_iter().map(|pipeline| {
let workflows = pipeline
.pipeline
.workflows
.iter()
.map(|workflow| workflow.name.as_str())
.collect::<Vec<_>>()
.join(", ");
[
pipeline.rkey,
pipeline.pipeline.trigger_metadata.kind,
pipeline.pipeline.trigger_metadata.repo.repo,
workflows,
]
}),
);
}
Ok(())
}
async fn config(args: SpindleConfigArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
if args.enable && args.disable {
return Err(anyhow!("Cannot use --enable and --disable together"));
}
if !args.enable && !args.disable && args.url.is_none() {
return Err(anyhow!("Must provide --enable, --disable, or --url"));
}
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let (owner, name) = parse_repo_ref(
args.repo.as_deref().unwrap_or(&session.handle),
&session.handle,
);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
let new_spindle = if args.disable {
None
} else if let Some(url) = args.url.as_deref() {
Some(url)
} else if args.enable {
Some("https://spindle.tangled.sh")
} else {
return Err(anyhow!("Invalid flags combination"));
};
crate::ops::repo::update_repo_spindle(
&pds,
&info.did,
&info.rkey,
new_spindle,
&auth,
)
.await?;
if args.disable {
println!("Disabled spindle for {}/{}", owner, name);
} else {
println!(
"Enabled spindle for {}/{} ({})",
owner,
name,
new_spindle.unwrap_or_default()
);
}
Ok(())
}
async fn run_pipeline(args: SpindleRunArgs) -> Result<()> {
println!(
"Spindle run (stub) repo={:?} branch={:?} wait={}",
args.repo, args.branch, args.wait
);
Ok(())
}
async fn logs(args: SpindleLogsArgs) -> Result<()> {
let parts: Vec<&str> = args.job_id.split(':').collect();
let (knot, rkey, name) = if parts.len() == 3 {
(
parts[0].to_string(),
parts[1].to_string(),
parts[2].to_string(),
)
} else if parts.len() == 1 {
let session = crate::util::load_session_with_refresh().await?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let info = crate::ops::repo::get_repo_info(
&pds,
&session.handle,
&session.handle,
&auth,
)
.await?;
(info.knot, info.rkey, parts[0].to_string())
} else {
return Err(anyhow!(
"Invalid job_id format. Expected 'knot:rkey:name' or 'name'"
));
};
let spindle_base = std::env::var("TANGLED_SPINDLE_BASE")
.unwrap_or_else(|_| "wss://spindle.tangled.sh".to_string());
let ws_url =
format!("{}/spindle/logs/{}/{}/{}", spindle_base, knot, rkey, name);
let (ws_stream, _) = crate::progress::with_spinner(
format!("Connecting to logs stream for {knot}:{rkey}:{name}..."),
connect_async(&ws_url),
)
.await
.map_err(|e| anyhow!("Failed to connect to log stream: {}", e))?;
let (mut _write, mut read) = ws_stream.split();
let mut line_count = 0;
let max_lines = args.lines.unwrap_or(usize::MAX);
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => {
println!("{}", text);
line_count += 1;
if line_count >= max_lines {
break;
}
}
Ok(Message::Close(_)) => {
break;
}
Err(e) => {
return Err(anyhow!("WebSocket error: {}", e));
}
_ => {}
}
}
Ok(())
}
async fn secret(cli: &Cli, cmd: SpindleSecretCommand) -> Result<()> {
match cmd {
SpindleSecretCommand::List(args) => secret_list(cli, args).await,
SpindleSecretCommand::Add(args) => secret_add(args).await,
SpindleSecretCommand::Remove(args) => secret_remove(args).await,
}
}
async fn secret_list(cli: &Cli, args: SpindleSecretListArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
let spindle_base = info
.spindle
.clone()
.or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
let secrets = crate::ops::secrets::list_repo_secrets(
&spindle_base,
&pds,
&auth,
&repo_at,
)
.await?;
if matches!(cli.format, OutputFormat::Json | OutputFormat::Yaml) {
return crate::util::print_serialized(cli.format, &secrets);
}
if secrets.is_empty() {
println!("No secrets configured for {}", args.repo);
} else {
crate::util::print_table(
["KEY", "CREATED AT", "CREATED BY"],
secrets.into_iter().map(|secret| {
[secret.key, secret.created_at, secret.created_by]
}),
);
}
Ok(())
}
async fn secret_add(args: SpindleSecretAddArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
let spindle_base = info
.spindle
.clone()
.or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
let value = if args.value == "-" {
use std::io::Read;
let mut buffer = String::new();
std::io::stdin().read_to_string(&mut buffer)?;
buffer
} else if let Some(path) = args.value.strip_prefix('@') {
let expanded_path = if path.starts_with("~/") {
if let Ok(home) = std::env::var("HOME") {
path.replacen("~/", &format!("{}/", home), 1)
} else {
path.to_string()
}
} else {
path.to_string()
};
std::fs::read_to_string(&expanded_path).map_err(|e| {
anyhow!("Failed to read file '{}': {}", expanded_path, e)
})?
} else {
args.value
};
crate::ops::secrets::add_repo_secret(
&spindle_base,
&pds,
&auth,
&repo_at,
&args.key,
&value,
)
.await?;
println!("Added secret '{}' to {}", args.key, args.repo);
Ok(())
}
async fn secret_remove(args: SpindleSecretRemoveArgs) -> Result<()> {
let session = crate::util::load_session_with_refresh().await?;
let auth = crate::ops::auth::PdsAuth::from_session(&session)?;
let pds = session
.pds
.clone()
.or_else(|| std::env::var("TANGLED_PDS_BASE").ok())
.unwrap_or_else(|| "https://bsky.social".into());
let (owner, name) = parse_repo_ref(&args.repo, &session.handle);
let info =
crate::ops::repo::get_repo_info(&pds, owner, name, &auth).await?;
let repo_at = format!("at://{}/sh.tangled.repo/{}", info.did, info.rkey);
let spindle_base = info
.spindle
.clone()
.or_else(|| std::env::var("TANGLED_SPINDLE_BASE").ok())
.unwrap_or_else(|| "https://spindle.tangled.sh".to_string());
crate::ops::secrets::remove_repo_secret(
&spindle_base,
&pds,
&auth,
&repo_at,
&args.key,
)
.await?;
println!("Removed secret '{}' from {}", args.key, args.repo);
Ok(())
}
fn parse_repo_ref<'a>(
spec: &'a str,
default_owner: &'a str,
) -> (&'a str, &'a str) {
if let Some((owner, name)) = spec.split_once('/') {
(owner, name)
} else {
(default_owner, spec)
}
}