feat: Add working workspace #8
@@ -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).
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+269
-20
@@ -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(¤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<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(())
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user