From 109c3743d293e5d3fe5cecccda58e07c0aa41a25 Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Sun, 30 Aug 2026 08:54:49 +0200 Subject: [PATCH] feat: add run inspection commands --- diff --git a/build.rs b/build.rs --- a/build.rs +++ b/build.rs @@ -1,10 +1,14 @@ -fn main() -> Result<(), Box> { - let protoc = protoc_bin_vendored::protoc_bin_path()?; - let mut config = tonic_prost_build::Config::new(); - config.protoc_executable(protoc); - tonic_prost_build::configure() - .build_server(false) - .compile_with_config(config, &["proto/identity.proto"], &["proto"])?; - println!("cargo:rerun-if-changed=proto/identity.proto"); - Ok(()) -} +fn main() -> Result<(), Box> { + let protoc = protoc_bin_vendored::protoc_bin_path()?; + let mut config = tonic_prost_build::Config::new(); + config.protoc_executable(protoc); + tonic_prost_build::configure() + .build_server(false) + .compile_with_config( + config, + &["proto/identity.proto", "proto/actions.proto"], + &["proto"], + )?; + println!("cargo:rerun-if-changed=proto"); + Ok(()) +} diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,776 +1,815 @@ -mod agent; -mod api; -mod client; -mod config; -mod credential; -mod daemon; -mod error; -mod ipc; -mod issue; -mod login; -mod pull_request; -mod repository; -mod settings; -mod token; - -use std::path::PathBuf; - -use clap::{Parser, Subcommand, ValueEnum}; - -use crate::error::{Error, Result}; -use crate::wire::LocalAgentLogoutRequest; - -pub mod wire { - #![allow(clippy::doc_markdown, clippy::large_enum_variant, clippy::use_self)] - - include!(concat!(env!("OUT_DIR"), "/syncode.identity.v1.rs")); -} - -const DEFAULT_HOST: &str = "https://syncode.sh"; - -#[derive(Parser)] -#[command(name = "syn", version, about)] -struct Arguments { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand)] -enum Command { - Login { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - #[arg(long, default_value = "github")] - provider: String, - }, - Logout { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Agent { - #[command(subcommand)] - command: AgentCommand, - }, - Pr { - #[command(subcommand)] - command: PullRequestCommand, - }, - Issue { - #[command(subcommand)] - command: IssueCommand, - }, - Repo { - #[command(subcommand)] - command: RepositoryCommand, - }, - Token { - #[command(subcommand)] - command: TokenCommand, - }, - Org { - #[command(subcommand)] - command: OrganizationCommand, - }, - Team { - #[command(subcommand)] - command: TeamCommand, - }, - SshKey { - #[command(subcommand)] - command: UserSshKeyCommand, - }, - SigningKey { - #[command(subcommand)] - command: SigningKeyCommand, - }, - Account { - #[command(subcommand)] - command: AccountCommand, - }, - Email { - #[command(subcommand)] - command: EmailCommand, - }, - Identity { - #[command(subcommand)] - command: IdentityCommand, - }, - Credential { - operation: String, - }, - Status, - #[command(hide = true)] - Daemon, -} - -#[derive(Subcommand)] -enum PullRequestCommand { - Create { - repository: String, - #[arg(long)] - head: String, - #[arg(long, default_value = "main")] - base: String, - #[arg(long)] - title: String, - #[arg(long, default_value = "")] - body: String, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - List { - repository: String, - #[arg(long, default_value = "open")] - state: String, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - View { - repository: String, - number: u64, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Merge { - repository: String, - number: u64, - #[arg(long, default_value = "merge")] - method: String, - #[arg(long)] - delete_branch: bool, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum IssueCommand { - Comment { - repository: String, - number: u64, - body: String, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - List { - repository: String, - #[arg(long, default_value = "open")] - state: String, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum RepositoryCommand { - Clone { - repository: String, - directory: Option, - #[arg(long)] - as_agent: bool, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum TokenCommand { - List { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Create { - #[arg(long, default_value = "syn")] - name: String, - #[arg(long)] - capability: String, - #[arg(long, default_value = "30d")] - expires: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Revoke { - token_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum OrganizationCommand { - List { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Create { - slug: String, - #[arg(long)] - display_name: Option, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Update { - slug: String, - #[arg(long)] - display_name: Option, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Delete { - slug: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum TeamCommand { - List { - organization: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Create { - organization: String, - name: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Rename { - team_id: String, - name: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Delete { - team_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - AddMember { - team_id: String, - username: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - RemoveMember { - team_id: String, - user_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Grant { - team_id: String, - repository: String, - #[arg(long, value_enum)] - preset: GrantPresetArgument, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - RevokeGrant { - grant_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Clone, Copy, ValueEnum)] -enum GrantPresetArgument { - Read, - Write, - Admin, -} - -#[derive(Subcommand)] -enum UserSshKeyCommand { - List { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Add { - title: String, - public_key: PathBuf, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Revoke { - key_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum SigningKeyCommand { - List { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Add { - title: String, - #[arg(long = "type", value_parser = ["gpg", "ssh"])] - key_type: String, - public_key: PathBuf, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Revoke { - key_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum AccountCommand { - Show { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Update { - #[arg(long)] - display_name: Option, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Delete { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum EmailCommand { - List { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Add { - address: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Primary { - address: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Remove { - address: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum IdentityCommand { - List { - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Unlink { - identity_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum AgentCommand { - Create { - agent: String, - #[arg(long)] - definition: String, - #[arg(long)] - restriction: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Revoke { - agent: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - SshKey { - #[command(subcommand)] - command: SshKeyCommand, - }, - Platform { - #[command(subcommand)] - command: PlatformAgentCommand, - }, -} - -#[derive(Subcommand)] -enum PlatformAgentCommand { - List { - organization: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Create { - organization: String, - name: String, - #[arg(long)] - definition: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Grant { - agent_id: String, - #[arg(long)] - repository: Option, - #[arg(long, value_enum)] - preset: GrantPresetArgument, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Revoke { - agent_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[derive(Subcommand)] -enum SshKeyCommand { - Add { - agent: String, - public_key: PathBuf, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, - Revoke { - key_id: String, - #[arg(long, default_value = DEFAULT_HOST)] - host: String, - }, -} - -#[tokio::main] -async fn main() { - if let Err(error) = execute(Arguments::parse()).await { - eprintln!("syn: {error}"); - std::process::exit(1); - } -} - -async fn execute(arguments: Arguments) -> Result<()> { - match arguments.command { - Command::Login { host, provider } => login::login(&host, &provider).await, - Command::Logout { host } => logout(&host).await, - Command::Agent { command } => match command { - AgentCommand::Create { - agent, - definition, - restriction, - host, - } => agent::create(&host, &agent, &definition, &restriction).await, - AgentCommand::Revoke { agent, host } => agent::revoke(&host, &agent).await, - AgentCommand::SshKey { command } => match command { - SshKeyCommand::Add { - agent, - public_key, - host, - } => agent::add_ssh_key(&host, &agent, &public_key).await, - SshKeyCommand::Revoke { key_id, host } => { - agent::revoke_ssh_key(&host, &key_id).await - } - }, - AgentCommand::Platform { command } => match command { - PlatformAgentCommand::List { organization, host } => { - settings::list_platform_agents(&host, &organization).await - } - PlatformAgentCommand::Create { - organization, - name, - definition, - host, - } => { - settings::create_platform_agent(&host, &organization, &name, &definition).await - } - PlatformAgentCommand::Grant { - agent_id, - repository, - preset, - host, - } => { - settings::grant_platform_agent_access( - &host, - &agent_id, - repository.as_deref(), - preset, - ) - .await - } - PlatformAgentCommand::Revoke { agent_id, host } => { - settings::revoke_platform_agent(&host, &agent_id).await - } - }, - }, - Command::Pr { command } => match command { - PullRequestCommand::Create { - repository, - head, - base, - title, - body, - as_agent, - host, - } => { - pull_request::create(&host, &repository, &head, &base, &title, &body, as_agent) - .await - } - PullRequestCommand::List { - repository, - state, - as_agent, - host, - } => pull_request::list(&host, &repository, &state, as_agent).await, - PullRequestCommand::View { - repository, - number, - as_agent, - host, - } => pull_request::view(&host, &repository, number, as_agent).await, - PullRequestCommand::Merge { - repository, - number, - method, - delete_branch, - as_agent, - host, - } => { - pull_request::merge(&host, &repository, number, &method, delete_branch, as_agent) - .await - } - }, - Command::Issue { command } => match command { - IssueCommand::Comment { - repository, - number, - body, - as_agent, - host, - } => issue::comment(&host, &repository, number, &body, as_agent).await, - IssueCommand::List { - repository, - state, - as_agent, - host, - } => issue::list(&host, &repository, &state, as_agent).await, - }, - Command::Repo { command } => match command { - RepositoryCommand::Clone { - repository, - directory, - as_agent, - host, - } => repository::clone(&host, &repository, directory.as_deref(), as_agent).await, - }, - Command::Token { command } => match command { - TokenCommand::List { host } => settings::list_tokens(&host).await, - TokenCommand::Create { - name, - capability, - expires, - host, - } => token::create(&host, name, &capability, &expires).await, - TokenCommand::Revoke { token_id, host } => { - settings::revoke_token(&host, &token_id).await - } - }, - Command::Org { command } => match command { - OrganizationCommand::List { host } => settings::list_organizations(&host).await, - OrganizationCommand::Create { - slug, - display_name, - host, - } => settings::create_organization(&host, &slug, display_name.as_deref()).await, - OrganizationCommand::Update { - slug, - display_name, - host, - } => settings::update_organization(&host, &slug, display_name.as_deref()).await, - OrganizationCommand::Delete { slug, host } => { - settings::delete_organization(&host, &slug).await - } - }, - Command::Team { command } => match command { - TeamCommand::List { organization, host } => { - settings::list_teams(&host, &organization).await - } - TeamCommand::Create { - organization, - name, - host, - } => settings::create_team(&host, &organization, &name).await, - TeamCommand::Rename { - team_id, - name, - host, - } => settings::rename_team(&host, &team_id, &name).await, - TeamCommand::Delete { team_id, host } => settings::delete_team(&host, &team_id).await, - TeamCommand::AddMember { - team_id, - username, - host, - } => settings::add_team_member(&host, &team_id, &username).await, - TeamCommand::RemoveMember { - team_id, - user_id, - host, - } => settings::remove_team_member(&host, &team_id, &user_id).await, - TeamCommand::Grant { - team_id, - repository, - preset, - host, - } => settings::grant_team_repository_access(&host, &team_id, &repository, preset).await, - TeamCommand::RevokeGrant { grant_id, host } => { - settings::revoke_grant(&host, &grant_id).await - } - }, - Command::SshKey { command } => match command { - UserSshKeyCommand::List { host } => settings::list_ssh_keys(&host).await, - UserSshKeyCommand::Add { - title, - public_key, - host, - } => settings::add_ssh_key(&host, &title, &public_key).await, - UserSshKeyCommand::Revoke { key_id, host } => { - settings::revoke_ssh_key(&host, &key_id).await - } - }, - Command::SigningKey { command } => match command { - SigningKeyCommand::List { host } => settings::list_signing_keys(&host).await, - SigningKeyCommand::Add { - title, - key_type, - public_key, - host, - } => settings::add_signing_key(&host, &title, &key_type, &public_key).await, - SigningKeyCommand::Revoke { key_id, host } => { - settings::revoke_signing_key(&host, &key_id).await - } - }, - Command::Account { command } => match command { - AccountCommand::Show { host } => settings::show_account(&host).await, - AccountCommand::Update { display_name, host } => { - settings::update_profile(&host, display_name.as_deref()).await - } - AccountCommand::Delete { host } => settings::request_account_deletion(&host).await, - }, - Command::Email { command } => match command { - EmailCommand::List { host } => settings::list_emails(&host).await, - EmailCommand::Add { address, host } => settings::add_email(&host, &address).await, - EmailCommand::Primary { address, host } => { - settings::set_primary_email(&host, &address).await - } - EmailCommand::Remove { address, host } => settings::remove_email(&host, &address).await, - }, - Command::Identity { command } => match command { - IdentityCommand::List { host } => settings::list_identities(&host).await, - IdentityCommand::Unlink { identity_id, host } => { - settings::unlink_identity(&host, &identity_id).await - } - }, - Command::Credential { operation } => credential::run(&operation).await, - Command::Status => { - let response = ipc::send(&ipc::Request::Status).await?; - println!( - "{}", - response - .message - .unwrap_or_else(|| "daemon is active".to_owned()) - ); - Ok(()) - } - Command::Daemon => daemon::run().await, - } -} - -async fn logout(host: &str) -> Result<()> { - let (host_key, _) = config::normalize_host(host)?; - let mut config = config::load()?; - let configured = config - .hosts - .get(&host_key) - .cloned() - .ok_or_else(|| Error::Configuration(format!("not logged in to {host_key}")))?; - let mut identity = client::connect(&configured.identity_url).await?; - identity - .logout(LocalAgentLogoutRequest { - session_token: configured.session_token, - }) - .await?; - config.hosts.remove(&host_key); - config::save(&config)?; - println!("Logged out from {host_key}"); - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::error::Error as StdError; - - use super::*; - - #[test] - fn parses_typed_team_and_platform_agent_grants() -> std::result::Result<(), Box> { - let team = Arguments::try_parse_from([ - "syn", - "team", - "grant", - "team-id", - "repository", - "--preset", - "write", - ])?; - assert!(matches!( - team.command, - Command::Team { - command: TeamCommand::Grant { - preset: GrantPresetArgument::Write, - .. - } - } - )); - - let agent = Arguments::try_parse_from([ - "syn", - "agent", - "platform", - "grant", - "agent-id", - "--repository", - "repository", - "--preset", - "read", - ])?; - assert!(matches!( - agent.command, - Command::Agent { - command: AgentCommand::Platform { - command: PlatformAgentCommand::Grant { - preset: GrantPresetArgument::Read, - .. - } - } - } - )); - Ok(()) - } -} +mod agent; +mod api; +mod client; +mod config; +mod credential; +mod daemon; +mod error; +mod ipc; +mod issue; +mod login; +mod pull_request; +mod repository; +mod run; +mod settings; +mod token; + +use std::path::PathBuf; + +use clap::{Parser, Subcommand, ValueEnum}; + +use crate::error::{Error, Result}; +use crate::wire::LocalAgentLogoutRequest; + +pub mod wire { + #![allow(clippy::doc_markdown, clippy::large_enum_variant, clippy::use_self)] + + include!(concat!(env!("OUT_DIR"), "/syncode.identity.v1.rs")); +} + +pub mod actions_wire { + #![allow(clippy::doc_markdown, clippy::large_enum_variant, clippy::use_self)] + + include!(concat!(env!("OUT_DIR"), "/syncode.control.v1.rs")); +} + +const DEFAULT_HOST: &str = "https://syncode.sh"; + +#[derive(Parser)] +#[command(name = "syn", version, about)] +struct Arguments { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + Login { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + #[arg(long, default_value = "github")] + provider: String, + }, + Logout { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Agent { + #[command(subcommand)] + command: AgentCommand, + }, + Pr { + #[command(subcommand)] + command: PullRequestCommand, + }, + Issue { + #[command(subcommand)] + command: IssueCommand, + }, + Repo { + #[command(subcommand)] + command: RepositoryCommand, + }, + Run { + #[command(subcommand)] + command: RunCommand, + }, + Token { + #[command(subcommand)] + command: TokenCommand, + }, + Org { + #[command(subcommand)] + command: OrganizationCommand, + }, + Team { + #[command(subcommand)] + command: TeamCommand, + }, + SshKey { + #[command(subcommand)] + command: UserSshKeyCommand, + }, + SigningKey { + #[command(subcommand)] + command: SigningKeyCommand, + }, + Account { + #[command(subcommand)] + command: AccountCommand, + }, + Email { + #[command(subcommand)] + command: EmailCommand, + }, + Identity { + #[command(subcommand)] + command: IdentityCommand, + }, + Credential { + operation: String, + }, + Status, + #[command(hide = true)] + Daemon, +} + +#[derive(Subcommand)] +enum PullRequestCommand { + Create { + repository: String, + #[arg(long)] + head: String, + #[arg(long, default_value = "main")] + base: String, + #[arg(long)] + title: String, + #[arg(long, default_value = "")] + body: String, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + repository: String, + #[arg(long, default_value = "open")] + state: String, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + View { + repository: String, + number: u64, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Merge { + repository: String, + number: u64, + #[arg(long, default_value = "merge")] + method: String, + #[arg(long)] + delete_branch: bool, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum IssueCommand { + Comment { + repository: String, + number: u64, + body: String, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + repository: String, + #[arg(long, default_value = "open")] + state: String, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum RepositoryCommand { + Clone { + repository: String, + directory: Option, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum RunCommand { + List { + repository: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + View { + run_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Logs { + run_id: String, + #[arg(long)] + job: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum TokenCommand { + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Create { + #[arg(long, default_value = "syn")] + name: String, + #[arg(long)] + capability: String, + #[arg(long, default_value = "30d")] + expires: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Revoke { + token_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum OrganizationCommand { + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Create { + slug: String, + #[arg(long)] + display_name: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Update { + slug: String, + #[arg(long)] + display_name: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Delete { + slug: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum TeamCommand { + List { + organization: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Create { + organization: String, + name: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Rename { + team_id: String, + name: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Delete { + team_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + AddMember { + team_id: String, + username: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + RemoveMember { + team_id: String, + user_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Grant { + team_id: String, + repository: String, + #[arg(long, value_enum)] + preset: GrantPresetArgument, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + RevokeGrant { + grant_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum GrantPresetArgument { + Read, + Write, + Admin, +} + +#[derive(Subcommand)] +enum UserSshKeyCommand { + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Add { + title: String, + public_key: PathBuf, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Revoke { + key_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum SigningKeyCommand { + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Add { + title: String, + #[arg(long = "type", value_parser = ["gpg", "ssh"])] + key_type: String, + public_key: PathBuf, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Revoke { + key_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum AccountCommand { + Show { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Update { + #[arg(long)] + display_name: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Delete { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum EmailCommand { + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Add { + address: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Primary { + address: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Remove { + address: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum IdentityCommand { + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Unlink { + identity_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum AgentCommand { + Create { + agent: String, + #[arg(long)] + definition: String, + #[arg(long)] + restriction: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Revoke { + agent: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + SshKey { + #[command(subcommand)] + command: SshKeyCommand, + }, + Platform { + #[command(subcommand)] + command: PlatformAgentCommand, + }, +} + +#[derive(Subcommand)] +enum PlatformAgentCommand { + List { + organization: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Create { + organization: String, + name: String, + #[arg(long)] + definition: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Grant { + agent_id: String, + #[arg(long)] + repository: Option, + #[arg(long, value_enum)] + preset: GrantPresetArgument, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Revoke { + agent_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum SshKeyCommand { + Add { + agent: String, + public_key: PathBuf, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Revoke { + key_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[tokio::main] +async fn main() { + if let Err(error) = execute(Arguments::parse()).await { + eprintln!("syn: {error}"); + std::process::exit(1); + } +} + +async fn execute(arguments: Arguments) -> Result<()> { + match arguments.command { + Command::Login { host, provider } => login::login(&host, &provider).await, + Command::Logout { host } => logout(&host).await, + Command::Agent { command } => match command { + AgentCommand::Create { + agent, + definition, + restriction, + host, + } => agent::create(&host, &agent, &definition, &restriction).await, + AgentCommand::Revoke { agent, host } => agent::revoke(&host, &agent).await, + AgentCommand::SshKey { command } => match command { + SshKeyCommand::Add { + agent, + public_key, + host, + } => agent::add_ssh_key(&host, &agent, &public_key).await, + SshKeyCommand::Revoke { key_id, host } => { + agent::revoke_ssh_key(&host, &key_id).await + } + }, + AgentCommand::Platform { command } => match command { + PlatformAgentCommand::List { organization, host } => { + settings::list_platform_agents(&host, &organization).await + } + PlatformAgentCommand::Create { + organization, + name, + definition, + host, + } => { + settings::create_platform_agent(&host, &organization, &name, &definition).await + } + PlatformAgentCommand::Grant { + agent_id, + repository, + preset, + host, + } => { + settings::grant_platform_agent_access( + &host, + &agent_id, + repository.as_deref(), + preset, + ) + .await + } + PlatformAgentCommand::Revoke { agent_id, host } => { + settings::revoke_platform_agent(&host, &agent_id).await + } + }, + }, + Command::Pr { command } => match command { + PullRequestCommand::Create { + repository, + head, + base, + title, + body, + as_agent, + host, + } => { + pull_request::create(&host, &repository, &head, &base, &title, &body, as_agent) + .await + } + PullRequestCommand::List { + repository, + state, + as_agent, + host, + } => pull_request::list(&host, &repository, &state, as_agent).await, + PullRequestCommand::View { + repository, + number, + as_agent, + host, + } => pull_request::view(&host, &repository, number, as_agent).await, + PullRequestCommand::Merge { + repository, + number, + method, + delete_branch, + as_agent, + host, + } => { + pull_request::merge(&host, &repository, number, &method, delete_branch, as_agent) + .await + } + }, + Command::Issue { command } => match command { + IssueCommand::Comment { + repository, + number, + body, + as_agent, + host, + } => issue::comment(&host, &repository, number, &body, as_agent).await, + IssueCommand::List { + repository, + state, + as_agent, + host, + } => issue::list(&host, &repository, &state, as_agent).await, + }, + Command::Repo { command } => match command { + RepositoryCommand::Clone { + repository, + directory, + as_agent, + host, + } => repository::clone(&host, &repository, directory.as_deref(), as_agent).await, + }, + Command::Run { command } => match command { + RunCommand::List { repository, host } => run::list(&host, &repository).await, + RunCommand::View { run_id, host } => run::view(&host, &run_id).await, + RunCommand::Logs { run_id, job, host } => { + run::logs(&host, &run_id, job.as_deref()).await + } + }, + Command::Token { command } => match command { + TokenCommand::List { host } => settings::list_tokens(&host).await, + TokenCommand::Create { + name, + capability, + expires, + host, + } => token::create(&host, name, &capability, &expires).await, + TokenCommand::Revoke { token_id, host } => { + settings::revoke_token(&host, &token_id).await + } + }, + Command::Org { command } => match command { + OrganizationCommand::List { host } => settings::list_organizations(&host).await, + OrganizationCommand::Create { + slug, + display_name, + host, + } => settings::create_organization(&host, &slug, display_name.as_deref()).await, + OrganizationCommand::Update { + slug, + display_name, + host, + } => settings::update_organization(&host, &slug, display_name.as_deref()).await, + OrganizationCommand::Delete { slug, host } => { + settings::delete_organization(&host, &slug).await + } + }, + Command::Team { command } => match command { + TeamCommand::List { organization, host } => { + settings::list_teams(&host, &organization).await + } + TeamCommand::Create { + organization, + name, + host, + } => settings::create_team(&host, &organization, &name).await, + TeamCommand::Rename { + team_id, + name, + host, + } => settings::rename_team(&host, &team_id, &name).await, + TeamCommand::Delete { team_id, host } => settings::delete_team(&host, &team_id).await, + TeamCommand::AddMember { + team_id, + username, + host, + } => settings::add_team_member(&host, &team_id, &username).await, + TeamCommand::RemoveMember { + team_id, + user_id, + host, + } => settings::remove_team_member(&host, &team_id, &user_id).await, + TeamCommand::Grant { + team_id, + repository, + preset, + host, + } => settings::grant_team_repository_access(&host, &team_id, &repository, preset).await, + TeamCommand::RevokeGrant { grant_id, host } => { + settings::revoke_grant(&host, &grant_id).await + } + }, + Command::SshKey { command } => match command { + UserSshKeyCommand::List { host } => settings::list_ssh_keys(&host).await, + UserSshKeyCommand::Add { + title, + public_key, + host, + } => settings::add_ssh_key(&host, &title, &public_key).await, + UserSshKeyCommand::Revoke { key_id, host } => { + settings::revoke_ssh_key(&host, &key_id).await + } + }, + Command::SigningKey { command } => match command { + SigningKeyCommand::List { host } => settings::list_signing_keys(&host).await, + SigningKeyCommand::Add { + title, + key_type, + public_key, + host, + } => settings::add_signing_key(&host, &title, &key_type, &public_key).await, + SigningKeyCommand::Revoke { key_id, host } => { + settings::revoke_signing_key(&host, &key_id).await + } + }, + Command::Account { command } => match command { + AccountCommand::Show { host } => settings::show_account(&host).await, + AccountCommand::Update { display_name, host } => { + settings::update_profile(&host, display_name.as_deref()).await + } + AccountCommand::Delete { host } => settings::request_account_deletion(&host).await, + }, + Command::Email { command } => match command { + EmailCommand::List { host } => settings::list_emails(&host).await, + EmailCommand::Add { address, host } => settings::add_email(&host, &address).await, + EmailCommand::Primary { address, host } => { + settings::set_primary_email(&host, &address).await + } + EmailCommand::Remove { address, host } => settings::remove_email(&host, &address).await, + }, + Command::Identity { command } => match command { + IdentityCommand::List { host } => settings::list_identities(&host).await, + IdentityCommand::Unlink { identity_id, host } => { + settings::unlink_identity(&host, &identity_id).await + } + }, + Command::Credential { operation } => credential::run(&operation).await, + Command::Status => { + let response = ipc::send(&ipc::Request::Status).await?; + println!( + "{}", + response + .message + .unwrap_or_else(|| "daemon is active".to_owned()) + ); + Ok(()) + } + Command::Daemon => daemon::run().await, + } +} + +async fn logout(host: &str) -> Result<()> { + let (host_key, _) = config::normalize_host(host)?; + let mut config = config::load()?; + let configured = config + .hosts + .get(&host_key) + .cloned() + .ok_or_else(|| Error::Configuration(format!("not logged in to {host_key}")))?; + let mut identity = client::connect(&configured.identity_url).await?; + identity + .logout(LocalAgentLogoutRequest { + session_token: configured.session_token, + }) + .await?; + config.hosts.remove(&host_key); + config::save(&config)?; + println!("Logged out from {host_key}"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::error::Error as StdError; + + use super::*; + + #[test] + fn parses_typed_team_and_platform_agent_grants() -> std::result::Result<(), Box> { + let team = Arguments::try_parse_from([ + "syn", + "team", + "grant", + "team-id", + "repository", + "--preset", + "write", + ])?; + assert!(matches!( + team.command, + Command::Team { + command: TeamCommand::Grant { + preset: GrantPresetArgument::Write, + .. + } + } + )); + + let agent = Arguments::try_parse_from([ + "syn", + "agent", + "platform", + "grant", + "agent-id", + "--repository", + "repository", + "--preset", + "read", + ])?; + assert!(matches!( + agent.command, + Command::Agent { + command: AgentCommand::Platform { + command: PlatformAgentCommand::Grant { + preset: GrantPresetArgument::Read, + .. + } + } + } + )); + Ok(()) + } +} diff --git a/proto/actions.proto b/proto/actions.proto --- /dev/null +++ b/proto/actions.proto @@ -1,0 +1,55 @@ +syntax = "proto3"; + +package syncode.control.v1; + +service ActionsRead { + rpc ListRuns(ListRunsRequest) returns (ListRunsResponse); + rpc GetRun(GetRunRequest) returns (ActionRun); + rpc GetJobLogs(GetJobLogsRequest) returns (GetJobLogsResponse); +} + +message ListRunsRequest { + string session_token = 1; + string owner = 2; + string repository = 3; +} + +message GetRunRequest { + string session_token = 1; + string run_id = 2; +} + +message GetJobLogsRequest { + string session_token = 1; + string run_id = 2; + string job_id = 3; +} + +message ListRunsResponse { + repeated ActionRun runs = 1; +} + +message ActionRun { + string id = 1; + uint64 number = 2; + string repository_id = 3; + string commit = 4; + string reference = 5; + string event = 6; + string workflow = 7; + string state = 8; + string conclusion = 9; + repeated ActionJob jobs = 10; +} + +message ActionJob { + string id = 1; + string key = 2; + string state = 3; + string conclusion = 4; + string node_id = 5; +} + +message GetJobLogsResponse { + repeated string lines = 1; +} diff --git a/src/run.rs b/src/run.rs --- /dev/null +++ b/src/run.rs @@ -1,0 +1,153 @@ +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; + +use crate::actions_wire::actions_read_client::ActionsReadClient; +use crate::actions_wire::{ + ActionJob, ActionRun, GetJobLogsRequest, GetRunRequest, ListRunsRequest, +}; +use crate::api::Repository; +use crate::error::{Error, Result}; + +async fn client(host: &str) -> Result<(ActionsReadClient, String)> { + let (host_key, _) = crate::config::normalize_host(host)?; + let config = crate::config::load()?; + let configured = config + .hosts + .get(&host_key) + .ok_or_else(|| Error::Configuration(format!("not logged in to {host_key}")))?; + let channel = Endpoint::from_shared(configured.forge_url.clone())? + .tls_config(ClientTlsConfig::new().with_webpki_roots())? + .connect() + .await?; + Ok(( + ActionsReadClient::new(channel), + configured.session_token.clone(), + )) +} + +pub async fn list(host: &str, repository: &str) -> Result<()> { + let repository = Repository::parse(repository)?; + let (mut client, session_token) = client(host).await?; + let response = client + .list_runs(ListRunsRequest { + session_token, + owner: repository.owner, + repository: repository.name, + }) + .await + .map_err(control_error)? + .into_inner(); + for run in response.runs { + println!( + "{}\t{}\t{}\t{}\t{}", + run.number, + run.id, + status(&run.state, &run.conclusion), + run.workflow, + short_commit(&run.commit) + ); + } + Ok(()) +} + +pub async fn view(host: &str, run_id: &str) -> Result<()> { + let (mut client, session_token) = client(host).await?; + let run = get_run(&mut client, session_token, run_id).await?; + println!("run\t{}", run.id); + println!("number\t{}", run.number); + println!("status\t{}", status(&run.state, &run.conclusion)); + println!("workflow\t{}", run.workflow); + println!("commit\t{}", run.commit); + println!("reference\t{}", run.reference); + println!("event\t{}", run.event); + for job in run.jobs { + print_job(&job); + } + Ok(()) +} + +pub async fn logs(host: &str, run_id: &str, job_id: Option<&str>) -> Result<()> { + let (mut client, session_token) = client(host).await?; + let job_id = match job_id { + Some(job_id) => job_id.to_owned(), + None => { + let run = get_run(&mut client, session_token.clone(), run_id).await?; + match run.jobs.as_slice() { + [job] => job.id.clone(), + [] => { + return Err(Error::Configuration("run has no jobs".to_owned())); + } + _ => { + return Err(Error::Configuration( + "run has multiple jobs; select one with --job ".to_owned(), + )); + } + } + } + }; + let response = client + .get_job_logs(GetJobLogsRequest { + session_token, + run_id: run_id.to_owned(), + job_id, + }) + .await + .map_err(control_error)? + .into_inner(); + for line in response.lines { + println!("{line}"); + } + Ok(()) +} + +async fn get_run( + client: &mut ActionsReadClient, + session_token: String, + run_id: &str, +) -> Result { + client + .get_run(GetRunRequest { + session_token, + run_id: run_id.to_owned(), + }) + .await + .map_err(control_error) + .map(tonic::Response::into_inner) +} + +fn print_job(job: &ActionJob) { + println!( + "job\t{}\t{}\t{}", + job.id, + job.key, + status(&job.state, &job.conclusion) + ); +} + +fn status(state: &str, conclusion: &str) -> String { + if conclusion.is_empty() { + state.to_owned() + } else { + format!("{state}/{conclusion}") + } +} + +fn short_commit(commit: &str) -> &str { + commit.get(..commit.len().min(12)).unwrap_or(commit) +} + +fn control_error(error: tonic::Status) -> Error { + Error::Command(format!("control rejected the request: {error}")) +} + +#[cfg(test)] +mod tests { + use super::{short_commit, status}; + + #[test] + fn formats_status_and_commit() { + assert_eq!("running", status("running", "")); + assert_eq!("finished/success", status("finished", "success")); + assert_eq!("0123456789ab", short_commit("0123456789abcdef")); + assert_eq!("abc", short_commit("abc")); + } +} -- SynCode