From efacfa56197124ee05b5c40c9f1cd5c5f1289f5b Mon Sep 17 00:00:00 2001 From: Przemyslaw Olszewski Date: Sun, 30 Aug 2026 21:58:22 +0200 Subject: [PATCH] feat: Add working workspace commands --- diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -1,43 +1,54 @@ -# SynCode CLI - -`syn` is the SynCode command-line client and LocalAgent daemon. It authenticates -through an external identity provider, maintains fenced per-host agent leases, -and issues short-lived repository credentials for Git operations. - -## Commands - -```text -syn login --host https://syncode.sh -syn logout --host https://syncode.sh -syn agent create owner/codex --definition codex --restriction read,write -syn agent ssh-key add owner/codex ~/.ssh/id_ed25519.pub -syn agent ssh-key revoke -syn agent revoke owner/codex -syn pr create owner/repo --head feature --title "Add feature" -syn pr list owner/repo -syn pr view owner/repo 12 -syn pr merge owner/repo 12 --method squash -syn issue list owner/repo -syn issue comment owner/repo 34 "Working on this" -syn repo clone owner/repo -syn status -``` - -Repository commands run as the logged-in user by default. Pass `--as-agent` -to use the active LocalAgent and its restricted capabilities. - -`syn login` installs a host-specific Git credential helper. Repository tokens -remain in the daemon and expire after five minutes; they are never written to -the Git credential store. - -## Development - -```sh -cargo fmt --check -cargo clippy --all-targets -- -D warnings -cargo test -``` - -## License - -MIT. See [LICENSE](LICENSE). +# SynCode CLI + +`syn` is the SynCode command-line client and LocalAgent daemon. It authenticates +through an external identity provider, maintains fenced per-host agent leases, +and issues short-lived repository credentials for Git operations. + +## Commands + +```text +syn login --host https://syncode.sh +syn logout --host https://syncode.sh +syn agent create owner/codex --definition codex --restriction read,write +syn agent ssh-key add owner/codex ~/.ssh/id_ed25519.pub +syn agent ssh-key revoke +syn agent revoke owner/codex +syn pr create owner/repo --head feature --title "Add feature" +syn pr list owner/repo +syn pr view owner/repo 12 +syn pr merge owner/repo 12 --method squash +syn repo create repository-name +syn repo clone owner/repo +syn workspace init +syn workspace checkout +syn workspace environment set production --source-branch main --infra-branch main +syn project create COL "Collaboration plane" +syn issue create "Deliver working workspace" +syn docs put README.md ./README.md +syn version create 0.6.0 --slug v0-6-0 +syn status +``` + +`syn workspace init` writes `.syn/workspace.json`. Project, issue, document and +version commands discover that file from the workspace root and from nested +source repositories. Explicit `--workspace-id` and `--project-id` values remain +available when operating outside a checkout. + +Repository commands run as the logged-in user by default. Pass `--as-agent` +to use the active LocalAgent and its restricted capabilities. + +`syn login` installs a host-specific Git credential helper. Repository tokens +remain in the daemon and expire after five minutes; they are never written to +the Git credential store. + +## Development + +```sh +cargo fmt --check +cargo clippy --all-targets -- -D warnings +cargo test +``` + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/build.rs b/build.rs --- a/build.rs +++ b/build.rs @@ -1,14 +1,18 @@ -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(()) -} +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/collab.proto", + ], + &["proto"], + )?; + println!("cargo:rerun-if-changed=proto"); + Ok(()) +} diff --git a/src/issue.rs b/src/issue.rs --- a/src/issue.rs +++ b/src/issue.rs @@ -1,85 +1,50 @@ -use crate::api::{ForgeOperations, Repository}; -use crate::error::{Error, Result}; -use crate::wire::{ - CreateIssueCommentOperation, ForgeItemState, ForgeListState, ListIssuesOperation, -}; - -pub async fn comment( - host: &str, - repository: &str, - number: u64, - body: &str, - as_agent: bool, -) -> Result<()> { - if body.trim().is_empty() { - return Err(Error::Configuration( - "comment body cannot be empty".to_owned(), - )); - } - let mut operations = ForgeOperations::connect( - host, - Repository::parse(repository)?, - &["issues:write"], - as_agent, - ) - .await?; - let comment = operations - .client - .create_issue_comment(CreateIssueCommentOperation { - api_token: operations.token, - repository: Some(operations.repository), - number, - body: body.to_owned(), - }) - .await? - .into_inner(); - println!("Comment {}", comment.id); - println!("{}", comment.web_url); - Ok(()) -} - -pub async fn list(host: &str, repository: &str, state: &str, as_agent: bool) -> Result<()> { - let state = match state { - "open" => ForgeListState::Open, - "closed" => ForgeListState::Closed, - "all" => ForgeListState::All, - _ => { - return Err(Error::Configuration(format!( - "unsupported issue state {state}" - ))); - } - }; - let mut operations = ForgeOperations::connect( - host, - Repository::parse(repository)?, - &["issues:read"], - as_agent, - ) - .await?; - let issues = operations - .client - .list_issues(ListIssuesOperation { - api_token: operations.token, - repository: Some(operations.repository), - state: state.into(), - }) - .await? - .into_inner() - .issues; - for issue in issues { - let state = match ForgeItemState::try_from(issue.state) { - Ok(ForgeItemState::Open) => "open", - Ok(ForgeItemState::Closed) => "closed", - _ => { - return Err(Error::Command( - "identity returned an invalid issue state".to_owned(), - )); - } - }; - println!( - "#{}\t{}\t{}\t{}", - issue.number, state, issue.title, issue.web_url - ); - } - Ok(()) -} +use crate::IssuePriorityArgument; +use crate::collab_wire::{CreateIssueRequest, IssuePriority, ListIssuesRequest}; +use crate::error::Result; + +pub async fn create( + host: &str, + project_id: &str, + title: &str, + description: &str, + priority: IssuePriorityArgument, +) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let issue = client + .grpc + .create_issue(CreateIssueRequest { + session_token: client.session_token, + project_id: project_id.to_owned(), + title: title.to_owned(), + description: description.to_owned(), + priority: match priority { + IssuePriorityArgument::None => IssuePriority::None.into(), + IssuePriorityArgument::Low => IssuePriority::Low.into(), + IssuePriorityArgument::Normal => IssuePriority::Normal.into(), + IssuePriorityArgument::High => IssuePriority::High.into(), + IssuePriorityArgument::Urgent => IssuePriority::Urgent.into(), + }, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + println!("{}\t#{}\t{}", issue.id, issue.number, issue.title); + Ok(()) +} + +pub async fn list(host: &str, project_id: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_issues(ListIssuesRequest { + session_token: client.session_token, + project_id: project_id.to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for issue in response.issues { + println!("{}\t#{}\t{}", issue.id, issue.number, issue.title); + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -1,815 +1,1198 @@ -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(()) - } -} +mod agent; +mod api; +mod client; +mod collaboration; +mod config; +mod credential; +mod daemon; +mod document; +mod error; +mod ipc; +mod issue; +mod login; +mod project; +mod pull_request; +mod repository; +mod run; +mod settings; +mod token; +mod version; +mod workspace; + +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")); +} + +pub mod collab_wire { + #![allow(clippy::doc_markdown, clippy::large_enum_variant, clippy::use_self)] + + include!(concat!(env!("OUT_DIR"), "/syncode.collab.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, + }, + Workspace { + #[command(subcommand)] + command: WorkspaceCommand, + }, + Project { + #[command(subcommand)] + command: ProjectCommand, + }, + Docs { + #[command(subcommand)] + command: DocumentCommand, + }, + Version { + #[command(subcommand)] + command: VersionCommand, + }, + 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 { + Create { + title: String, + #[arg(long)] + project_id: Option, + #[arg(long, default_value = "")] + description: String, + #[arg(long, value_enum, default_value = "normal")] + priority: IssuePriorityArgument, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + #[arg(long)] + project_id: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum WorkspaceCommand { + Create { + name: String, + #[arg(long)] + slug: Option, + #[arg(long, value_enum, default_value = "user")] + owner_kind: OwnerKindArgument, + #[arg(long)] + owner_id: Option, + #[arg(long, default_value = "development")] + environment: String, + #[arg(long, default_value = "main")] + branch: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Init { + #[arg(long)] + name: Option, + #[arg(long)] + key: Option, + #[arg(long, default_value = "development")] + environment: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + AddRepository { + workspace_id: String, + repository: String, + #[arg(long, value_enum, default_value = "source")] + role: RepositoryRoleArgument, + #[arg(long, default_value = "main")] + branch: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Checkout { + workspace_id: String, + directory: Option, + #[arg(long)] + environment: Option, + #[arg(long)] + partial: bool, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Environment { + #[command(subcommand)] + command: EnvironmentCommand, + }, +} + +#[derive(Subcommand)] +enum EnvironmentCommand { + Set { + workspace_id: String, + name: String, + #[arg(long, default_value = "main")] + source_branch: String, + #[arg(long)] + infra_branch: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + workspace_id: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum ProjectCommand { + Create { + key: String, + name: String, + #[arg(long)] + workspace_id: Option, + #[arg(long)] + slug: Option, + #[arg(long, default_value = "")] + description: String, + #[arg(long, value_enum, default_value = "continuous")] + kind: ProjectKindArgument, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + #[arg(long)] + workspace_id: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum DocumentCommand { + Put { + path: String, + file: PathBuf, + #[arg(long)] + workspace_id: Option, + #[arg(long)] + project_id: Option, + #[arg(long)] + base_revision: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + #[arg(long)] + workspace_id: Option, + #[arg(long)] + project_id: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Subcommand)] +enum VersionCommand { + Create { + name: String, + #[arg(long)] + project_id: Option, + #[arg(long)] + slug: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + List { + #[arg(long)] + project_id: Option, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum OwnerKindArgument { + User, + Organization, +} + +#[derive(Clone, Copy, ValueEnum)] +enum RepositoryRoleArgument { + Source, + Infra, +} + +#[derive(Clone, Copy, ValueEnum)] +enum ProjectKindArgument { + Continuous, + Finite, +} + +#[derive(Clone, Copy, ValueEnum)] +enum IssuePriorityArgument { + None, + Low, + Normal, + High, + Urgent, +} + +#[derive(Subcommand)] +enum RepositoryCommand { + Create { + name: String, + #[arg(long, value_enum, default_value = "user")] + owner_kind: OwnerKindArgument, + #[arg(long)] + owner_id: Option, + #[arg(long, value_enum, default_value = "private")] + visibility: RepositoryVisibilityArgument, + #[arg(long, default_value = "main")] + branch: String, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, + Clone { + repository: String, + directory: Option, + #[arg(long)] + as_agent: bool, + #[arg(long, default_value = DEFAULT_HOST)] + host: String, + }, +} + +#[derive(Clone, Copy, ValueEnum)] +enum RepositoryVisibilityArgument { + Public, + Private, + Limited, +} + +#[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::Create { + project_id, + title, + description, + priority, + host, + } => { + let project_id = workspace::project_context(project_id.as_deref())?; + issue::create(&host, &project_id, &title, &description, priority).await + } + IssueCommand::List { project_id, host } => { + let project_id = workspace::project_context(project_id.as_deref())?; + issue::list(&host, &project_id).await + } + }, + Command::Workspace { command } => match command { + WorkspaceCommand::Create { + name, + slug, + owner_kind, + owner_id, + environment, + branch, + host, + } => { + workspace::create( + &host, + &name, + slug.as_deref(), + owner_kind, + owner_id.as_deref(), + &environment, + &branch, + ) + .await + } + WorkspaceCommand::Init { + name, + key, + environment, + host, + } => workspace::init(&host, name.as_deref(), key.as_deref(), &environment).await, + WorkspaceCommand::List { host } => workspace::list(&host).await, + WorkspaceCommand::AddRepository { + workspace_id, + repository, + role, + branch, + host, + } => workspace::add_repository(&host, &workspace_id, &repository, role, &branch).await, + WorkspaceCommand::Checkout { + workspace_id, + directory, + environment, + partial, + as_agent, + host, + } => { + workspace::checkout( + &host, + &workspace_id, + directory.as_deref(), + environment.as_deref(), + partial, + as_agent, + ) + .await + } + WorkspaceCommand::Environment { command } => match command { + EnvironmentCommand::Set { + workspace_id, + name, + source_branch, + infra_branch, + host, + } => { + workspace::configure_environment( + &host, + &workspace_id, + &name, + &source_branch, + infra_branch.as_deref(), + ) + .await + } + EnvironmentCommand::List { workspace_id, host } => { + workspace::list_environments(&host, &workspace_id).await + } + }, + }, + Command::Project { command } => match command { + ProjectCommand::Create { + workspace_id, + key, + name, + slug, + description, + kind, + host, + } => { + let workspace_id = workspace::workspace_context(workspace_id.as_deref())?; + project::create( + &host, + &workspace_id, + &key, + &name, + slug.as_deref(), + &description, + kind, + ) + .await + } + ProjectCommand::List { workspace_id, host } => { + let workspace_id = workspace::workspace_context(workspace_id.as_deref())?; + project::list(&host, &workspace_id).await + } + }, + Command::Docs { command } => match command { + DocumentCommand::Put { + workspace_id, + path, + file, + project_id, + base_revision, + host, + } => { + let workspace_id = workspace::workspace_context(workspace_id.as_deref())?; + document::put( + &host, + &workspace_id, + project_id.as_deref(), + &path, + &file, + base_revision.as_deref(), + ) + .await + } + DocumentCommand::List { + workspace_id, + project_id, + host, + } => { + let workspace_id = workspace::workspace_context(workspace_id.as_deref())?; + document::list(&host, &workspace_id, project_id.as_deref()).await + } + }, + Command::Version { command } => match command { + VersionCommand::Create { + project_id, + name, + slug, + host, + } => { + let project_id = workspace::project_context(project_id.as_deref())?; + version::create(&host, &project_id, &name, slug.as_deref()).await + } + VersionCommand::List { project_id, host } => { + let project_id = workspace::project_context(project_id.as_deref())?; + version::list(&host, &project_id).await + } + }, + Command::Repo { command } => match command { + RepositoryCommand::Create { + name, + owner_kind, + owner_id, + visibility, + branch, + host, + } => { + repository::create( + &host, + &name, + owner_kind, + owner_id.as_deref(), + visibility, + &branch, + ) + .await + } + 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/src/repository.rs b/src/repository.rs --- a/src/repository.rs +++ b/src/repository.rs @@ -1,79 +1,328 @@ -use std::path::{Path, PathBuf}; - -use tokio::process::Command; - -use crate::api::Repository; -use crate::error::{Error, Result}; -use crate::ipc::{self, Request}; - -const CREDENTIAL_HELPER: &str = - "!f() { printf 'username=syn\\npassword=%s\\n\\n' \"$SYNCODE_GIT_TOKEN\"; }; f"; - -pub async fn clone( - host: &str, - repository: &str, - directory: Option<&Path>, - as_agent: bool, -) -> Result<()> { - let repository = Repository::parse(repository)?; - 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 response = ipc::send(&Request::RepositoryCredential { - host: host_key, - owner: repository.owner.clone(), - repository: repository.name.clone(), - as_agent, - }) - .await?; - let token = response - .token - .ok_or_else(|| Error::Daemon("daemon returned no repository token".to_owned()))?; - let target = directory - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(&repository.name)); - let mut remote = url::Url::parse(&configured.forge_url)?; - let mut segments = remote.path_segments_mut().map_err(|()| { - Error::Configuration("forge URL cannot contain repository paths".to_owned()) - })?; - segments.clear(); - segments.push(&repository.owner); - segments.push(&format!("{}.git", repository.name)); - drop(segments); - let mut command = Command::new("git"); - command - .env("SYNCODE_GIT_TOKEN", token) - .env("GIT_TERMINAL_PROMPT", "0") - .args([ - "-c", - "credential.helper=", - "-c", - &format!("credential.helper={CREDENTIAL_HELPER}"), - "-c", - "credential.useHttpPath=true", - "clone", - remote.as_str(), - ]); - if let Some(directory) = directory { - command.arg(directory); - } - let status = command.status().await?; - if !status.success() { - return Err(Error::Command(format!("git clone exited with {status}"))); - } - if as_agent { - let status = Command::new("git") - .arg("-C") - .arg(target) - .args(["config", "syncode.asAgent", "true"]) - .status() - .await?; - if !status.success() { - return Err(Error::Command(format!("git config exited with {status}"))); - } - } - Ok(()) -} +use std::path::{Path, PathBuf}; + +use tokio::process::Command; + +use crate::api::Repository; +use crate::error::{Error, Result}; +use crate::{OwnerKindArgument, RepositoryVisibilityArgument}; + +pub async fn create( + host: &str, + name: &str, + owner_kind: OwnerKindArgument, + owner_id: Option<&str>, + visibility: RepositoryVisibilityArgument, + branch: &str, +) -> Result<()> { + 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 owner_id = match (owner_kind, owner_id) { + (_, Some(id)) => id.to_owned(), + (OwnerKindArgument::User, None) => current_user_id(configured).await?, + (OwnerKindArgument::Organization, None) => { + return Err(Error::Configuration( + "--owner-id is required for an organization repository".to_owned(), + )); + } + }; + let response = reqwest::Client::new() + .post(repository_graphql_url(&configured.forge_url)?) + .header( + reqwest::header::COOKIE, + format!("syncode_identity_session={}", configured.session_token), + ) + .json(&serde_json::json!({ + "query": "mutation CreateRepository($input: CreateRepositoryInput!) { createRepository(input: $input) { id } }", + "variables": { "input": { + "objectFormat": "SHA1", + "defaultBranch": branch, + "access": { + "ownerKind": match owner_kind { OwnerKindArgument::User => "USER", OwnerKindArgument::Organization => "ORGANIZATION" }, + "ownerId": owner_id, + "name": name, + "visibility": match visibility { RepositoryVisibilityArgument::Public => "PUBLIC", RepositoryVisibilityArgument::Private => "PRIVATE", RepositoryVisibilityArgument::Limited => "LIMITED" } + } + }} + })) + .send() + .await? + .error_for_status()? + .json::() + .await?; + let id = graphql_value(&response, &["data", "createRepository", "id"])?; + println!("{id}\t{name}"); + Ok(()) +} + +async fn current_user_id(configured: &crate::config::Host) -> Result { + let response = reqwest::Client::new() + .post(format!("{}/graphql", configured.identity_url)) + .header( + reqwest::header::COOKIE, + format!("syncode_identity_session={}", configured.session_token), + ) + .json(&serde_json::json!({"query": "query { me { id } }"})) + .send() + .await? + .error_for_status()? + .json::() + .await?; + Ok(graphql_value(&response, &["data", "me", "id"])?.to_owned()) +} + +fn repository_graphql_url(forge_url: &str) -> Result { + let mut url = url::Url::parse(forge_url)?; + let hostname = url + .host_str() + .ok_or_else(|| Error::Configuration("forge URL has no hostname".to_owned()))?; + let repository_hostname = format!("repo.{hostname}"); + url.set_host(Some(&repository_hostname)) + .map_err(|_| Error::Configuration("repository hostname is invalid".to_owned()))?; + url.set_path("/graphql"); + Ok(url.into()) +} + +fn graphql_value<'a>(response: &'a serde_json::Value, path: &[&str]) -> Result<&'a str> { + if let Some(message) = response + .get("errors") + .and_then(serde_json::Value::as_array) + .and_then(|errors| errors.first()) + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + { + return Err(Error::Command(format!( + "repository rejected the request: {message}" + ))); + } + let mut value = response; + for key in path { + value = value.get(key).ok_or_else(|| { + Error::Command("repository returned an incomplete response".to_owned()) + })?; + } + value + .as_str() + .ok_or_else(|| Error::Command("repository returned an invalid identifier".to_owned())) +} +use crate::ipc::{self, Request}; + +const CREDENTIAL_HELPER: &str = + "!f() { printf 'username=syn\\npassword=%s\\n\\n' \"$SYNCODE_GIT_TOKEN\"; }; f"; + +pub async fn clone( + host: &str, + repository: &str, + directory: Option<&Path>, + as_agent: bool, +) -> Result<()> { + clone_repository(host, repository, directory, None, as_agent).await +} + +pub async fn clone_branch( + host: &str, + repository: &str, + directory: &Path, + branch: &str, + as_agent: bool, +) -> Result<()> { + clone_repository(host, repository, Some(directory), Some(branch), as_agent).await +} + +pub async fn reconcile_branch( + host: &str, + repository: &str, + directory: &Path, + branch: &str, + as_agent: bool, +) -> Result<()> { + let repository = Repository::parse(repository)?; + let token = credential(host, &repository, as_agent).await?; + let current = Command::new("git") + .arg("-C") + .arg(directory) + .args(["branch", "--show-current"]) + .output() + .await?; + if !current.status.success() { + return Err(Error::Command(format!( + "{} is not a Git checkout", + directory.display() + ))); + } + let current = String::from_utf8_lossy(¤t.stdout).trim().to_owned(); + let worktree = Command::new("git") + .arg("-C") + .arg(directory) + .args(["status", "--porcelain"]) + .output() + .await?; + if !worktree.status.success() { + return Err(Error::Command(format!( + "git status exited with {}", + worktree.status + ))); + } + if !worktree.stdout.is_empty() { + return Err(Error::Command(format!( + "{} is dirty and cannot reconcile {current} with {branch}", + directory.display() + ))); + } + let fetch = authenticated_git(directory, &token) + .args(["fetch", "origin", branch]) + .status() + .await?; + if !fetch.success() { + return Err(Error::Command(format!("git fetch exited with {fetch}"))); + } + let local = Command::new("git") + .arg("-C") + .arg(directory) + .args([ + "show-ref", + "--verify", + "--quiet", + &format!("refs/heads/{branch}"), + ]) + .status() + .await?; + let mut switch = Command::new("git"); + switch.arg("-C").arg(directory).arg("switch"); + if local.success() { + switch.arg(branch); + } else { + switch.args(["-c", branch, "--track", &format!("origin/{branch}")]); + } + let status = switch.status().await?; + if !status.success() { + return Err(Error::Command(format!("git switch exited with {status}"))); + } + let fast_forward = Command::new("git") + .arg("-C") + .arg(directory) + .args(["merge", "--ff-only", &format!("origin/{branch}")]) + .status() + .await?; + if !fast_forward.success() { + return Err(Error::Command(format!( + "git fast-forward exited with {fast_forward}" + ))); + } + if as_agent { + set_agent(directory).await?; + } + Ok(()) +} + +async fn clone_repository( + host: &str, + repository: &str, + directory: Option<&Path>, + branch: Option<&str>, + as_agent: bool, +) -> Result<()> { + let repository = Repository::parse(repository)?; + 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 token = credential_for_host(host_key, &repository, as_agent).await?; + let target = directory + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(&repository.name)); + let mut remote = url::Url::parse(&configured.forge_url)?; + let mut segments = remote.path_segments_mut().map_err(|()| { + Error::Configuration("forge URL cannot contain repository paths".to_owned()) + })?; + segments.clear(); + segments.push(&repository.owner); + segments.push(&format!("{}.git", repository.name)); + drop(segments); + let mut command = Command::new("git"); + command + .env("SYNCODE_GIT_TOKEN", token) + .env("GIT_TERMINAL_PROMPT", "0") + .args([ + "-c", + "credential.helper=", + "-c", + &format!("credential.helper={CREDENTIAL_HELPER}"), + "-c", + "credential.useHttpPath=true", + "clone", + ]); + if let Some(branch) = branch { + command.args(["--branch", branch]); + } + command.arg(remote.as_str()); + if let Some(directory) = directory { + command.arg(directory); + } + let status = command.status().await?; + if !status.success() { + return Err(Error::Command(format!("git clone exited with {status}"))); + } + if as_agent { + set_agent(&target).await?; + } + Ok(()) +} + +async fn credential(host: &str, repository: &Repository, as_agent: bool) -> Result { + let (host_key, _) = crate::config::normalize_host(host)?; + credential_for_host(host_key, repository, as_agent).await +} + +async fn credential_for_host( + host_key: String, + repository: &Repository, + as_agent: bool, +) -> Result { + let response = ipc::send(&Request::RepositoryCredential { + host: host_key, + owner: repository.owner.clone(), + repository: repository.name.clone(), + as_agent, + }) + .await?; + response + .token + .ok_or_else(|| Error::Daemon("daemon returned no repository token".to_owned())) +} + +fn authenticated_git(directory: &Path, token: &str) -> Command { + let mut command = Command::new("git"); + command + .env("SYNCODE_GIT_TOKEN", token) + .env("GIT_TERMINAL_PROMPT", "0") + .arg("-C") + .arg(directory) + .args([ + "-c", + "credential.helper=", + "-c", + &format!("credential.helper={CREDENTIAL_HELPER}"), + "-c", + "credential.useHttpPath=true", + ]); + command +} + +async fn set_agent(directory: &Path) -> Result<()> { + let status = Command::new("git") + .arg("-C") + .arg(directory) + .args(["config", "syncode.asAgent", "true"]) + .status() + .await?; + if !status.success() { + return Err(Error::Command(format!("git config exited with {status}"))); + } + Ok(()) +} diff --git a/proto/collab.proto b/proto/collab.proto --- /dev/null +++ b/proto/collab.proto @@ -1,0 +1,57 @@ +syntax = "proto3"; + +package syncode.collab.v1; + +service Collab { + rpc CreateWorkspace(CreateWorkspaceRequest) returns (Workspace); + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + rpc GetWorkspace(GetWorkspaceRequest) returns (Workspace); + rpc AttachRepository(AttachRepositoryRequest) returns (OperationResponse); + rpc GetCheckout(GetCheckoutRequest) returns (Checkout); + rpc ConfigureEnvironment(ConfigureEnvironmentRequest) returns (Environment); + rpc ListEnvironments(ListEnvironmentsRequest) returns (ListEnvironmentsResponse); + rpc CreateProject(CreateProjectRequest) returns (Project); + rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse); + rpc PutDocument(PutDocumentRequest) returns (Document); + rpc ListDocuments(ListDocumentsRequest) returns (ListDocumentsResponse); + rpc CreateIssue(CreateIssueRequest) returns (Issue); + rpc ListIssues(ListIssuesRequest) returns (ListIssuesResponse); + rpc CreateVersion(CreateVersionRequest) returns (Version); + rpc ListVersions(ListVersionsRequest) returns (ListVersionsResponse); +} + +enum OwnerKind { OWNER_KIND_UNSPECIFIED = 0; OWNER_KIND_USER = 1; OWNER_KIND_ORGANIZATION = 2; } +enum RepositoryRole { REPOSITORY_ROLE_UNSPECIFIED = 0; REPOSITORY_ROLE_SOURCE = 1; REPOSITORY_ROLE_INFRA = 2; } +enum ProjectKind { PROJECT_KIND_UNSPECIFIED = 0; PROJECT_KIND_CONTINUOUS = 1; PROJECT_KIND_FINITE = 2; } +enum IssuePriority { ISSUE_PRIORITY_UNSPECIFIED = 0; ISSUE_PRIORITY_NONE = 1; ISSUE_PRIORITY_LOW = 2; ISSUE_PRIORITY_NORMAL = 3; ISSUE_PRIORITY_HIGH = 4; ISSUE_PRIORITY_URGENT = 5; } + +message Workspace { string id = 1; OwnerKind owner_kind = 2; string owner_id = 3; string name = 4; string slug = 5; string default_environment = 6; int64 version = 7; } +message CreateWorkspaceRequest { string session_token = 1; OwnerKind owner_kind = 2; string owner_id = 3; string name = 4; string slug = 5; string default_environment = 6; string default_source_branch = 7; } +message ListWorkspacesRequest { string session_token = 1; } +message ListWorkspacesResponse { repeated Workspace workspaces = 1; } +message GetWorkspaceRequest { string session_token = 1; string workspace_id = 2; } +message AttachRepositoryRequest { string session_token = 1; string workspace_id = 2; string owner = 3; string name = 4; RepositoryRole role = 5; string branch = 6; } +message OperationResponse {} +message GetCheckoutRequest { string session_token = 1; string workspace_id = 2; string environment = 3; bool partial = 4; } +message CheckoutRepository { string repository_id = 1; string owner = 2; string name = 3; RepositoryRole role = 4; string branch = 5; } +message Checkout { Workspace workspace = 1; string environment = 2; repeated CheckoutRepository repositories = 3; uint32 omitted_repositories = 4; } +message Environment { string workspace_id = 1; string name = 2; string default_source_branch = 3; string infra_branch = 4; } +message ConfigureEnvironmentRequest { string session_token = 1; string workspace_id = 2; string name = 3; string default_source_branch = 4; string infra_branch = 5; } +message ListEnvironmentsRequest { string session_token = 1; string workspace_id = 2; } +message ListEnvironmentsResponse { repeated Environment environments = 1; } +message Project { string id = 1; string workspace_id = 2; string key = 3; string name = 4; string slug = 5; string description = 6; ProjectKind kind = 7; string lifecycle = 8; string default_status_id = 9; string completion_status_id = 10; int64 version = 11; } +message CreateProjectRequest { string session_token = 1; string workspace_id = 2; string key = 3; string name = 4; string slug = 5; string description = 6; ProjectKind kind = 7; } +message ListProjectsRequest { string session_token = 1; string workspace_id = 2; } +message ListProjectsResponse { repeated Project projects = 1; } +message Document { string id = 1; string workspace_id = 2; string project_id = 3; string path = 4; string revision_id = 5; string content = 6; } +message PutDocumentRequest { string session_token = 1; string workspace_id = 2; string project_id = 3; string path = 4; string content = 5; string base_revision_id = 6; } +message ListDocumentsRequest { string session_token = 1; string workspace_id = 2; string project_id = 3; } +message ListDocumentsResponse { repeated Document documents = 1; } +message Issue { string id = 1; string project_id = 2; int64 number = 3; string title = 4; string description = 5; string status_id = 6; string type_id = 7; IssuePriority priority = 8; int64 version = 9; } +message CreateIssueRequest { string session_token = 1; string project_id = 2; string title = 3; string description = 4; IssuePriority priority = 5; } +message ListIssuesRequest { string session_token = 1; string project_id = 2; } +message ListIssuesResponse { repeated Issue issues = 1; } +message Version { string id = 1; string project_id = 2; string name = 3; string slug = 4; string state = 5; } +message CreateVersionRequest { string session_token = 1; string project_id = 2; string name = 3; string slug = 4; } +message ListVersionsRequest { string session_token = 1; string project_id = 2; } +message ListVersionsResponse { repeated Version versions = 1; } diff --git a/src/collaboration.rs b/src/collaboration.rs --- /dev/null +++ b/src/collaboration.rs @@ -1,0 +1,45 @@ +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; + +use crate::collab_wire::collab_client::CollabClient; +use crate::error::{Error, Result}; + +pub struct Client { + pub grpc: CollabClient, + pub session_token: String, +} + +pub async fn connect(host: &str) -> Result { + 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(Client { + grpc: CollabClient::new(channel), + session_token: configured.session_token.clone(), + }) +} + +pub fn error(error: tonic::Status) -> Error { + Error::Command(format!("collaboration rejected the request: {error}")) +} + +pub fn slug(value: &str) -> String { + let mut slug = String::new(); + let mut separator = false; + for character in value.chars().flat_map(char::to_lowercase) { + if character.is_ascii_alphanumeric() { + slug.push(character); + separator = false; + } else if !separator && !slug.is_empty() { + slug.push('-'); + separator = true; + } + } + slug.trim_end_matches('-').to_owned() +} diff --git a/src/document.rs b/src/document.rs --- /dev/null +++ b/src/document.rs @@ -1,0 +1,55 @@ +use std::path::Path; + +use crate::collab_wire::{ListDocumentsRequest, PutDocumentRequest}; +use crate::error::Result; + +pub async fn put( + host: &str, + workspace_id: &str, + project_id: Option<&str>, + path: &str, + file: &Path, + base_revision: Option<&str>, +) -> Result<()> { + let content = std::fs::read_to_string(file)?; + let mut client = crate::collaboration::connect(host).await?; + let document = client + .grpc + .put_document(PutDocumentRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + project_id: project_id.unwrap_or_default().to_owned(), + path: path.to_owned(), + content, + base_revision_id: base_revision.unwrap_or_default().to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + println!( + "{}\t{}\t{}", + document.id, document.revision_id, document.path + ); + Ok(()) +} + +pub async fn list(host: &str, workspace_id: &str, project_id: Option<&str>) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_documents(ListDocumentsRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + project_id: project_id.unwrap_or_default().to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for document in response.documents { + println!( + "{}\t{}\t{}", + document.id, document.revision_id, document.path + ); + } + Ok(()) +} diff --git a/src/project.rs b/src/project.rs --- /dev/null +++ b/src/project.rs @@ -1,0 +1,75 @@ +use crate::ProjectKindArgument; +use crate::collab_wire::{CreateProjectRequest, ListProjectsRequest, Project, ProjectKind}; +use crate::error::Result; + +pub async fn create( + host: &str, + workspace_id: &str, + key: &str, + name: &str, + slug: Option<&str>, + description: &str, + kind: ProjectKindArgument, +) -> Result<()> { + let project = create_remote( + host, + workspace_id, + key, + name, + slug.unwrap_or(&crate::collaboration::slug(name)), + description, + kind, + ) + .await?; + println!("{}\t{}\t{}", project.id, project.key, project.name); + Ok(()) +} + +pub(crate) async fn create_remote( + host: &str, + workspace_id: &str, + key: &str, + name: &str, + slug: &str, + description: &str, + kind: ProjectKindArgument, +) -> Result { + let mut client = crate::collaboration::connect(host).await?; + client + .grpc + .create_project(CreateProjectRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + key: key.to_owned(), + name: name.to_owned(), + slug: slug.to_owned(), + description: description.to_owned(), + kind: match kind { + ProjectKindArgument::Continuous => ProjectKind::Continuous.into(), + ProjectKindArgument::Finite => ProjectKind::Finite.into(), + }, + }) + .await + .map_err(crate::collaboration::error) + .map(tonic::Response::into_inner) +} + +pub async fn list(host: &str, workspace_id: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_projects(ListProjectsRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for project in response.projects { + println!( + "{}\t{}\t{}\t{}", + project.id, project.key, project.lifecycle, project.name + ); + } + Ok(()) +} diff --git a/src/version.rs b/src/version.rs --- /dev/null +++ b/src/version.rs @@ -1,0 +1,38 @@ +use crate::collab_wire::{CreateVersionRequest, ListVersionsRequest}; +use crate::error::Result; + +pub async fn create(host: &str, project_id: &str, name: &str, slug: Option<&str>) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let version = client + .grpc + .create_version(CreateVersionRequest { + session_token: client.session_token, + project_id: project_id.to_owned(), + name: name.to_owned(), + slug: slug + .map(str::to_owned) + .unwrap_or_else(|| crate::collaboration::slug(name)), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + println!("{}\t{}\t{}", version.id, version.state, version.name); + Ok(()) +} + +pub async fn list(host: &str, project_id: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_versions(ListVersionsRequest { + session_token: client.session_token, + project_id: project_id.to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for version in response.versions { + println!("{}\t{}\t{}", version.id, version.state, version.name); + } + Ok(()) +} diff --git a/src/workspace.rs b/src/workspace.rs --- /dev/null +++ b/src/workspace.rs @@ -1,0 +1,449 @@ +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::api::Repository; +use crate::collab_wire::{ + AttachRepositoryRequest, ConfigureEnvironmentRequest, CreateWorkspaceRequest, + GetCheckoutRequest, ListEnvironmentsRequest, ListWorkspacesRequest, OwnerKind, RepositoryRole, + Workspace, +}; +use crate::error::{Error, Result}; +use crate::{OwnerKindArgument, RepositoryRoleArgument}; + +pub async fn create( + host: &str, + name: &str, + slug: Option<&str>, + owner_kind: OwnerKindArgument, + owner_id: Option<&str>, + environment: &str, + branch: &str, +) -> Result<()> { + let workspace = create_remote( + host, + name, + slug.unwrap_or(&crate::collaboration::slug(name)), + owner_kind, + owner_id.unwrap_or_default(), + environment, + branch, + ) + .await?; + println!("{}\t{}\t{}", workspace.id, workspace.slug, workspace.name); + Ok(()) +} + +pub async fn list(host: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_workspaces(ListWorkspacesRequest { + session_token: client.session_token, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for workspace in response.workspaces { + println!("{}\t{}\t{}", workspace.id, workspace.slug, workspace.name); + } + Ok(()) +} + +pub async fn configure_environment( + host: &str, + workspace_id: &str, + name: &str, + source_branch: &str, + infra_branch: Option<&str>, +) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let environment = client + .grpc + .configure_environment(ConfigureEnvironmentRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + name: name.to_owned(), + default_source_branch: source_branch.to_owned(), + infra_branch: infra_branch.unwrap_or_default().to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + println!( + "{}\t{}\t{}", + environment.name, environment.default_source_branch, environment.infra_branch + ); + Ok(()) +} + +pub async fn list_environments(host: &str, workspace_id: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_environments(ListEnvironmentsRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for environment in response.environments { + println!( + "{}\t{}\t{}", + environment.name, environment.default_source_branch, environment.infra_branch + ); + } + Ok(()) +} + +pub async fn add_repository( + host: &str, + workspace_id: &str, + repository: &str, + role: RepositoryRoleArgument, + branch: &str, +) -> Result<()> { + let repository = Repository::parse(repository)?; + let mut client = crate::collaboration::connect(host).await?; + client + .grpc + .attach_repository(AttachRepositoryRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + owner: repository.owner, + name: repository.name, + role: match role { + RepositoryRoleArgument::Source => RepositoryRole::Source.into(), + RepositoryRoleArgument::Infra => RepositoryRole::Infra.into(), + }, + branch: branch.to_owned(), + }) + .await + .map_err(crate::collaboration::error)?; + Ok(()) +} + +pub async fn checkout( + host: &str, + workspace_id: &str, + directory: Option<&Path>, + environment: Option<&str>, + partial: bool, + as_agent: bool, +) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let checkout = client + .grpc + .get_checkout(GetCheckoutRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + environment: environment.unwrap_or_default().to_owned(), + partial, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + let workspace = checkout + .workspace + .ok_or_else(|| Error::Command("collaboration returned no workspace".to_owned()))?; + let root = directory + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(&workspace.slug)); + std::fs::create_dir_all(&root)?; + let mut repositories = Vec::new(); + for repository in checkout.repositories { + let role = RepositoryRole::try_from(repository.role) + .map_err(|_| Error::Command("collaboration returned an invalid role".to_owned()))?; + let target = match role { + RepositoryRole::Source => root.join("sources").join(&repository.name), + RepositoryRole::Infra => root.join("infra"), + RepositoryRole::Unspecified => { + return Err(Error::Command( + "collaboration returned an unspecified role".to_owned(), + )); + } + }; + if target.exists() { + crate::repository::reconcile_branch( + host, + &format!("{}/{}", repository.owner, repository.name), + &target, + &repository.branch, + as_agent, + ) + .await?; + } else { + if let Some(parent) = target.parent() { + std::fs::create_dir_all(parent)?; + } + crate::repository::clone_branch( + host, + &format!("{}/{}", repository.owner, repository.name), + &target, + &repository.branch, + as_agent, + ) + .await?; + } + repositories.push(LocalRepository { + id: repository.repository_id, + path: target + .strip_prefix(&root) + .map_err(|_| Error::Command("workspace path escaped its root".to_owned()))? + .to_string_lossy() + .into_owned(), + branch: repository.branch, + }); + } + write_state( + &root, + &WorkspaceState { + workspace_id: workspace.id, + default_project_id: None, + environment: checkout.environment, + partial: checkout.omitted_repositories > 0, + omitted_repositories: checkout.omitted_repositories, + repositories, + }, + )?; + println!("{}", root.display()); + if checkout.omitted_repositories > 0 { + println!("omitted\t{}", checkout.omitted_repositories); + } + Ok(()) +} + +pub async fn init( + host: &str, + name: Option<&str>, + key: Option<&str>, + environment: &str, +) -> Result<()> { + let root = git_output(&["rev-parse", "--show-toplevel"])?; + let root = PathBuf::from(root); + let remote = git_output(&["-C", path(&root)?, "remote", "get-url", "origin"])?; + let repository = repository_from_remote(&remote)?; + let branch = git_output(&["-C", path(&root)?, "branch", "--show-current"])?; + if branch.is_empty() { + return Err(Error::Configuration( + "detached HEAD cannot initialize a workspace".to_owned(), + )); + } + let name = name.unwrap_or(&repository.name); + let slug = crate::collaboration::slug(name); + let workspace = create_remote( + host, + name, + &slug, + OwnerKindArgument::User, + "", + environment, + &branch, + ) + .await?; + add_repository( + host, + &workspace.id, + &repository.to_string(), + RepositoryRoleArgument::Source, + &branch, + ) + .await?; + let repository_id = attached_repository_id( + host, + &workspace.id, + environment, + &repository.owner, + &repository.name, + ) + .await?; + let project_key = key.map(str::to_owned).unwrap_or_else(|| default_key(name)); + let project = crate::project::create_remote( + host, + &workspace.id, + &project_key, + name, + &slug, + "", + crate::ProjectKindArgument::Continuous, + ) + .await?; + write_state( + &root, + &WorkspaceState { + workspace_id: workspace.id.clone(), + default_project_id: Some(project.id), + environment: environment.to_owned(), + partial: false, + omitted_repositories: 0, + repositories: vec![LocalRepository { + id: repository_id, + path: ".".to_owned(), + branch, + }], + }, + )?; + println!("{}\t{}", workspace.id, root.display()); + Ok(()) +} + +async fn attached_repository_id( + host: &str, + workspace_id: &str, + environment: &str, + owner: &str, + name: &str, +) -> Result { + let mut client = crate::collaboration::connect(host).await?; + let checkout = client + .grpc + .get_checkout(GetCheckoutRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + environment: environment.to_owned(), + partial: false, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + checkout + .repositories + .into_iter() + .find(|repository| repository.owner == owner && repository.name == name) + .map(|repository| repository.repository_id) + .ok_or_else(|| { + Error::Command("attached repository is missing from the checkout plan".to_owned()) + }) +} + +async fn create_remote( + host: &str, + name: &str, + slug: &str, + owner_kind: OwnerKindArgument, + owner_id: &str, + environment: &str, + branch: &str, +) -> Result { + let mut client = crate::collaboration::connect(host).await?; + client + .grpc + .create_workspace(CreateWorkspaceRequest { + session_token: client.session_token, + owner_kind: match owner_kind { + OwnerKindArgument::User => OwnerKind::User.into(), + OwnerKindArgument::Organization => OwnerKind::Organization.into(), + }, + owner_id: owner_id.to_owned(), + name: name.to_owned(), + slug: slug.to_owned(), + default_environment: environment.to_owned(), + default_source_branch: branch.to_owned(), + }) + .await + .map_err(crate::collaboration::error) + .map(tonic::Response::into_inner) +} + +#[derive(Deserialize, Serialize)] +struct WorkspaceState { + workspace_id: String, + #[serde(default)] + default_project_id: Option, + environment: String, + partial: bool, + omitted_repositories: u32, + repositories: Vec, +} + +pub fn workspace_context(explicit: Option<&str>) -> Result { + explicit + .map(str::to_owned) + .map_or_else(|| read_context().map(|state| state.workspace_id), Ok) +} + +pub fn project_context(explicit: Option<&str>) -> Result { + if let Some(project_id) = explicit { + return Ok(project_id.to_owned()); + } + read_context()? + .default_project_id + .ok_or_else(|| Error::Configuration("workspace has no default project".to_owned())) +} + +fn read_context() -> Result { + let mut directory = std::env::current_dir()?; + loop { + let path = directory.join(".syn/workspace.json"); + match std::fs::read(&path) { + Ok(bytes) => return Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + if !directory.pop() { + return Err(Error::Configuration( + "no .syn/workspace.json found in this directory tree".to_owned(), + )); + } + } +} + +#[derive(Deserialize, Serialize)] +struct LocalRepository { + id: String, + path: String, + branch: String, +} + +fn write_state(root: &Path, state: &WorkspaceState) -> Result<()> { + let directory = root.join(".syn"); + std::fs::create_dir_all(&directory)?; + std::fs::write( + directory.join("workspace.json"), + serde_json::to_vec_pretty(state)?, + )?; + Ok(()) +} + +fn git_output(arguments: &[&str]) -> Result { + let output = std::process::Command::new("git").args(arguments).output()?; + if !output.status.success() { + return Err(Error::Command(format!("git exited with {}", output.status))); + } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_owned()) + .map_err(|_| Error::Command("git returned non-UTF-8 output".to_owned())) +} + +fn path(value: &Path) -> Result<&str> { + value + .to_str() + .ok_or_else(|| Error::Configuration("workspace path is not UTF-8".to_owned())) +} + +fn repository_from_remote(remote: &str) -> Result { + let path = if let Ok(url) = url::Url::parse(remote) { + url.path().trim_start_matches('/').to_owned() + } else if let Some((_, path)) = remote.split_once(':') { + path.to_owned() + } else { + return Err(Error::Configuration( + "origin is not a supported Git URL".to_owned(), + )); + }; + Repository::parse(path.trim_end_matches(".git")) +} + +fn default_key(name: &str) -> String { + let mut key: String = name + .chars() + .filter(char::is_ascii_alphanumeric) + .take(3) + .flat_map(char::to_uppercase) + .collect(); + while key.len() < 2 { + key.push('X'); + } + key +} -- SynCode