From 1700a18f835886345ed0fb012318dca75e6a5146 Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Tue, 8 Sep 2026 10:18:24 +0200 Subject: [PATCH 1/2] feat: add launcher ls command --- src/cli.rs | 1 + src/cli/cmd.rs | 8 +++++++ src/cli/cmd/job/stop.rs | 2 +- src/cli/cmd/launcher.rs | 32 +++++++++++++++++++++++++ src/cli/cmd/launcher/list.rs | 36 ++++++++++++++++++++++++++++ src/cli/complete.rs | 1 + src/cli/opts.rs | 2 ++ src/cli/sink.rs | 1 + src/httpclient.rs | 4 ++-- src/httpclient/data.rs | 46 ++++++++++++++++++++++++++++++++++++ 10 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/cli/cmd/launcher.rs create mode 100644 src/cli/cmd/launcher/list.rs diff --git a/src/cli.rs b/src/cli.rs index 4b08e27..4ca3808 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -29,6 +29,7 @@ pub async fn execute_cmd(opts: MainOpts) -> Result<(), CmdError> { SubCommand::Dataset(input) => input.exec(ctx).await?, SubCommand::Job(input) => input.exec(ctx).await?, + SubCommand::Launcher(input) => input.exec(ctx).await?, SubCommand::Logout(input) => input.exec(&ctx).await?, }; Ok(()) diff --git a/src/cli/cmd.rs b/src/cli/cmd.rs index 8a4468e..c4ecf09 100644 --- a/src/cli/cmd.rs +++ b/src/cli/cmd.rs @@ -1,5 +1,6 @@ pub mod dataset; pub mod job; +pub mod launcher; pub mod login; pub mod logout; pub mod project; @@ -85,6 +86,8 @@ pub enum CmdError { #[snafu(display("Job - {}", source))] Job { source: job::Error }, + #[snafu(display("Launcher - {}", source))] + Launcher { source: launcher::Error }, #[snafu(display("Logout - {}", source))] Logout { source: logout::Error }, @@ -95,6 +98,11 @@ impl From for CmdError { CmdError::Job { source } } } +impl From for CmdError { + fn from(source: launcher::Error) -> Self { + CmdError::Launcher { source } + } +} impl From for CmdError { fn from(source: version::Error) -> Self { diff --git a/src/cli/cmd/job/stop.rs b/src/cli/cmd/job/stop.rs index 89a5214..8402297 100644 --- a/src/cli/cmd/job/stop.rs +++ b/src/cli/cmd/job/stop.rs @@ -13,7 +13,7 @@ use snafu::{ResultExt, Snafu}; /// Stop a running non-interactive session. #[derive(Parser, Debug)] pub struct Input { - /// The launcher to use for launching the job. + /// The id of the job to stop #[arg(value_hint=ValueHint::Other, add = ArgValueCompleter::new(complete_job_name))] pub job_id: String, } diff --git a/src/cli/cmd/launcher.rs b/src/cli/cmd/launcher.rs new file mode 100644 index 0000000..1779967 --- /dev/null +++ b/src/cli/cmd/launcher.rs @@ -0,0 +1,32 @@ +pub mod list; + +use super::Context; +use clap::Parser; +use snafu::{ResultExt, Snafu}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error listing launchers: {}", source))] + List { source: list::Error }, +} + +/// Sub command for managing projects +#[derive(Parser, Debug)] +pub struct Input { + #[command(subcommand)] + pub subcmd: LauncherCommand, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + match &self.subcmd { + LauncherCommand::List(input) => input.exec(ctx).await.context(ListSnafu), + } + } +} + +#[derive(Parser, Debug)] +pub enum LauncherCommand { + #[command()] + List(list::Input), +} diff --git a/src/cli/cmd/launcher/list.rs b/src/cli/cmd/launcher/list.rs new file mode 100644 index 0000000..1f108f2 --- /dev/null +++ b/src/cli/cmd/launcher/list.rs @@ -0,0 +1,36 @@ +use super::Context; +use crate::{ + cli::sink::Error as SinkError, + httpclient::{self, data::SessionMode}, +}; + +use clap::Parser; + +use snafu::{ResultExt, Snafu}; + +/// Listing launchers. +/// +/// List currently running launchers. +#[derive(Parser, Debug)] +pub struct Input {} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let mut result = ctx.client.list_launchers().await.context(HttpClientSnafu)?; + + result.retain(|e| e.launcher_type == SessionMode::NonInteractive); + if let Ok(Some(project)) = ctx.resolve_project_context().await { + result.retain(|v| v.project_id == project.id); + } + ctx.write_result(&result).await.context(WriteResultSnafu) + } +} diff --git a/src/cli/complete.rs b/src/cli/complete.rs index 08e32a6..b659c2a 100644 --- a/src/cli/complete.rs +++ b/src/cli/complete.rs @@ -141,6 +141,7 @@ pub fn complete_job_launcher_id(current: &ffi::OsStr) -> Vec None, }; for launcher in launchers + .0 .iter() .filter(|e| e.launcher_type == SessionMode::NonInteractive) .filter(|e| match &project_id { diff --git a/src/cli/opts.rs b/src/cli/opts.rs index 106e1bd..86b7eee 100644 --- a/src/cli/opts.rs +++ b/src/cli/opts.rs @@ -164,6 +164,8 @@ pub enum SubCommand { #[command()] Job(job::Input), + #[command()] + Launcher(launcher::Input), #[command()] Logout(logout::Input), diff --git a/src/cli/sink.rs b/src/cli/sink.rs index fcb94e1..e908180 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -63,5 +63,6 @@ impl Sink for UserCode {} impl Sink for Response {} impl Sink for SessionStartResponse {} impl Sink for SessionList {} +impl Sink for LauncherList {} impl Sink for SessionLogs {} impl Sink for VersionInfo {} diff --git a/src/httpclient.rs b/src/httpclient.rs index 7529c9d..5813499 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -455,10 +455,10 @@ impl Client { Ok(r) } - pub async fn list_launchers(&self) -> Result, Error> { + pub async fn list_launchers(&self) -> Result { let path = "/api/data/session_launchers"; let result = self.json_get::>(path).await?; - Ok(result) + Ok(LauncherList(result)) } pub async fn get_launcher(&self, id: &str) -> Result, Error> { diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 4ead0df..24f8f11 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -81,6 +81,52 @@ impl fmt::Display for SessionStartRequest { ) } } +#[derive(Debug, Serialize, Deserialize)] +pub struct LauncherList(pub Vec); +impl LauncherList { + pub fn filter(self, f: F) -> LauncherList + where + F: Fn(&SessionLauncher) -> bool, + { + let l: Vec = self.0.into_iter().filter(|v| f(v)).collect(); + LauncherList(l) + } + + pub fn retain(&mut self, f: F) + where + F: Fn(&SessionLauncher) -> bool, + { + self.0.retain(|v| f(v)); + } +} +fn create_launcher_table<'a, I>(data: I) -> Table +where + I: IntoIterator, +{ + let mut builder = Builder::default(); + for r in data { + let data = vec![&r.name, r.id.as_str(), &r.project_id]; + builder.push_record(data); + } + builder.insert_record(0, vec!["Launcher", "Id", "Project Id"]); + + let mut table = builder.build(); + let settings = Settings::default().with(Style::sharp()); + + table.with(settings); + table +} + +impl fmt::Display for LauncherList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + write!(f, "No launchers found.") + } else { + let table = create_launcher_table(&self.0); + write!(f, "{}", table) + } + } +} #[derive(Debug, Serialize, Deserialize)] pub struct SessionList(pub Vec); From 59213255b9a2bf67166a8fe375aad5daedf2458a Mon Sep 17 00:00:00 2001 From: Ralf Grubenmann Date: Thu, 10 Sep 2026 13:14:30 +0200 Subject: [PATCH 2/2] add project list command --- src/cli/cmd/launcher.rs | 2 +- src/cli/cmd/project.rs | 6 +++++ src/cli/cmd/project/list.rs | 40 +++++++++++++++++++++++++++++++ src/cli/sink.rs | 1 + src/httpclient.rs | 10 ++++++++ src/httpclient/data.rs | 48 +++++++++++++++++++++++++++++++++++++ 6 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 src/cli/cmd/project/list.rs diff --git a/src/cli/cmd/launcher.rs b/src/cli/cmd/launcher.rs index 1779967..41e9572 100644 --- a/src/cli/cmd/launcher.rs +++ b/src/cli/cmd/launcher.rs @@ -10,7 +10,7 @@ pub enum Error { List { source: list::Error }, } -/// Sub command for managing projects +/// Sub command for managing launchers #[derive(Parser, Debug)] pub struct Input { #[command(subcommand)] diff --git a/src/cli/cmd/project.rs b/src/cli/cmd/project.rs index 74a82e9..5980361 100644 --- a/src/cli/cmd/project.rs +++ b/src/cli/cmd/project.rs @@ -1,4 +1,5 @@ pub mod clone; +pub mod list; use super::Context; use clap::Parser; @@ -8,6 +9,8 @@ use snafu::{ResultExt, Snafu}; pub enum Error { #[snafu(display("Error cloning project: {}", source))] Clone { source: clone::Error }, + #[snafu(display("Error listing projects: {}", source))] + List { source: list::Error }, } /// Sub command for managing projects @@ -21,6 +24,7 @@ impl Input { pub async fn exec(&self, ctx: Context) -> Result<(), Error> { match &self.subcmd { ProjectCommand::Clone(input) => input.exec(ctx).await.context(CloneSnafu), + ProjectCommand::List(input) => input.exec(ctx).await.context(ListSnafu), } } } @@ -29,4 +33,6 @@ impl Input { pub enum ProjectCommand { #[command()] Clone(clone::Input), + #[command()] + List(list::Input), } diff --git a/src/cli/cmd/project/list.rs b/src/cli/cmd/project/list.rs new file mode 100644 index 0000000..f52d183 --- /dev/null +++ b/src/cli/cmd/project/list.rs @@ -0,0 +1,40 @@ +use super::Context; +use crate::{ + cli::sink::Error as SinkError, + httpclient::{self}, +}; + +use clap::Parser; + +use snafu::{ResultExt, Snafu}; + +/// Listing projects. +/// +/// List all projects owned by a user +#[derive(Parser, Debug)] +pub struct Input { + /// Get all projects, not just ones you're a member of + #[arg(long, short, default_value_t = false)] + pub all: bool, +} + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Error writing data: {}", source))] + WriteResult { source: SinkError }, + + #[snafu(display("Http error: {}", source))] + HttpClient { source: httpclient::Error }, +} + +impl Input { + pub async fn exec(&self, ctx: Context) -> Result<(), Error> { + let result = ctx + .client + .list_projects(!self.all) + .await + .context(HttpClientSnafu)?; + + ctx.write_result(&result).await.context(WriteResultSnafu) + } +} diff --git a/src/cli/sink.rs b/src/cli/sink.rs index e908180..3966f7a 100644 --- a/src/cli/sink.rs +++ b/src/cli/sink.rs @@ -56,6 +56,7 @@ impl From for Error { } impl Sink for ProjectDetails {} +impl Sink for ProjectList {} impl Sink for SimpleMessage {} impl Sink for BuildInfo {} impl Sink for PathEntry {} diff --git a/src/httpclient.rs b/src/httpclient.rs index 5813499..5941686 100644 --- a/src/httpclient.rs +++ b/src/httpclient.rs @@ -362,6 +362,16 @@ impl Client { }) } } + pub async fn list_projects(&self, direct_member: bool) -> Result { + let mut url = self.make_url("/api/data/projects")?; + url.query_pairs_mut() + .append_pair("direct_member", &direct_member.to_string()); + let req = self.set_bearer_token(self.client.get(url.clone())).await?; + + self.run_request::>(req, url) + .await + .map(ProjectList) + } pub async fn get_namespace( &self, diff --git a/src/httpclient/data.rs b/src/httpclient/data.rs index 24f8f11..d689b31 100644 --- a/src/httpclient/data.rs +++ b/src/httpclient/data.rs @@ -336,6 +336,54 @@ impl fmt::Display for ProjectDetails { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct ProjectList(pub Vec); +impl ProjectList { + pub fn filter(self, f: F) -> ProjectList + where + F: Fn(&ProjectDetails) -> bool, + { + let l: Vec = self.0.into_iter().filter(|v| f(v)).collect(); + ProjectList(l) + } + + pub fn retain(&mut self, f: F) + where + F: Fn(&ProjectDetails) -> bool, + { + self.0.retain(|v| f(v)); + } +} + +fn create_project_table<'a, I>(data: I) -> Table +where + I: IntoIterator, +{ + let mut builder = Builder::default(); + for r in data { + let data = vec![&r.name, r.id.as_str(), &r.namespace, &r.slug]; + builder.push_record(data); + } + builder.insert_record(0, vec!["Project", "Id", "Namespace", "Slug"]); + + let mut table = builder.build(); + let settings = Settings::default().with(Style::sharp()); + + table.with(settings); + table +} + +impl fmt::Display for ProjectList { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0.is_empty() { + write!(f, "No projects found.") + } else { + let table = create_project_table(&self.0); + write!(f, "{}", table) + } + } +} + #[derive(Debug, Serialize, Deserialize)] pub struct RenkuError { pub code: i32,