feat: Add working workspace #8

Manually merged
day01 merged 1 commits from feat/0.6-working-workspace into develop 2026-08-30 19:59:19 +00:00
11 changed files with 1443 additions and 112 deletions
+13 -2
View File
@@ -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 <key-id>
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 <key-id>
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 <workspace-id>
syn workspace environment set <workspace-id> 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).
+5 -1
View File
@@ -1,14 +1,18 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
+38 -73
View File
@@ -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(())
}
+399 -16
View File
File diff suppressed because it is too large Load Diff
+269 -20
View File
@@ -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::<serde_json::Value>()
.await?;
let id = graphql_value(&response, &["data", "createRepository", "id"])?;
println!("{id}\t{name}");
Ok(())
}

async fn current_user_id(configured: &crate::config::Host) -> Result<String> {
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::<serde_json::Value>()
.await?;
Ok(graphql_value(&response, &["data", "me", "id"])?.to_owned())
}

fn repository_graphql_url(forge_url: &str) -> Result<String> {
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(&current.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<String> {
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<String> {
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(())
}
+57
View File
@@ -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; }
+45
View File
@@ -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<Channel>,
pub session_token: String,
}

pub async fn connect(host: &str) -> Result<Client> {
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()
}
+55
View File
@@ -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(())
}
+75
View File
@@ -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<Project> {
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(())
}
+38
View File
@@ -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(())
}
+449
View File
@@ -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<String> {
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<Workspace> {
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<String>,
environment: String,
partial: bool,
omitted_repositories: u32,
repositories: Vec<LocalRepository>,
}

pub fn workspace_context(explicit: Option<&str>) -> Result<String> {
explicit
.map(str::to_owned)
.map_or_else(|| read_context().map(|state| state.workspace_id), Ok)
}

pub fn project_context(explicit: Option<&str>) -> Result<String> {
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<WorkspaceState> {
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<String> {
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<Repository> {
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
}