1199 lines
47 KiB
Rust
1199 lines
47 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use comfy_table::Cell;
|
|
use cynic::QueryBuilder;
|
|
use galaxy_cli::agent::OutputFormat;
|
|
use galaxy_cli::environment::{EnvironmentCommand, ImageCommand};
|
|
use galaxy_cli::scope::ObjectScope;
|
|
use galaxy_cli::GlobalOptions;
|
|
use galaxy_graphql::queries::get_oauth_connect_tx_status::OauthConnectTxStatus;
|
|
use galaxy_graphql::queries::list_warp_dev_images::{
|
|
ListWarpDevImages, ListWarpDevImagesResult, ListWarpDevImagesVariables,
|
|
};
|
|
use galaxy_graphql::queries::user_repo_auth_status::UserRepoAuthStatusEnum;
|
|
use galaxyui::r#async::FutureExt;
|
|
use galaxyui::{AppContext, ModelContext, SingletonEntity};
|
|
use inquire::error::InquireError;
|
|
use inquire::{Confirm, Select};
|
|
use serde::Serialize;
|
|
|
|
use crate::ai::agent_sdk::driver::WARP_DRIVE_SYNC_TIMEOUT;
|
|
use crate::ai::agent_sdk::oauth_flow::poll_oauth_until_terminal;
|
|
use crate::ai::agent_sdk::output::{self, TableFormat};
|
|
use crate::ai::cloud_environments::{
|
|
AmbientAgentEnvironment, BaseImage, CloudAmbientAgentEnvironment,
|
|
CloudAmbientAgentEnvironmentModel, GithubRepo,
|
|
};
|
|
use crate::auth::UserUid;
|
|
use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
|
|
use crate::cloud_object::{CloudObject, CloudObjectLookup as _};
|
|
use crate::server::cloud_objects::update_manager::{
|
|
ObjectOperation, OperationSuccessType, UpdateManager, UpdateManagerEvent,
|
|
};
|
|
use crate::server::ids::{ClientId, ServerId, SyncId};
|
|
use crate::server::server_api::ServerApiProvider;
|
|
use crate::util::time_format::format_approx_duration_from_now_utc;
|
|
use crate::workspaces::user_profiles::UserProfiles;
|
|
use crate::CloudObjectTypeAndId;
|
|
|
|
const WARP_DEV_ENVIRONMENTS_REPO: &str = "https://github.com/warpdotdev/warp-dev-environments";
|
|
|
|
/// Parse repo strings in the format "owner/repo" into GithubRepo objects.
|
|
fn parse_repos(repo_strings: Vec<String>) -> anyhow::Result<Vec<GithubRepo>> {
|
|
repo_strings
|
|
.into_iter()
|
|
.map(|r| {
|
|
let parts: Vec<&str> = r.split('/').collect();
|
|
if parts.len() != 2 {
|
|
return Err(anyhow::anyhow!(
|
|
"Invalid repo format: '{}'. Expected format: 'owner/repo'",
|
|
r
|
|
));
|
|
}
|
|
Ok(GithubRepo::new(parts[0].to_string(), parts[1].to_string()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Handle environment-related CLI commands.
|
|
pub fn run(
|
|
ctx: &mut AppContext,
|
|
global_options: GlobalOptions,
|
|
command: EnvironmentCommand,
|
|
) -> anyhow::Result<()> {
|
|
let runner = ctx.add_singleton_model(|_ctx| EnvironmentCommandRunner);
|
|
match command {
|
|
EnvironmentCommand::List => {
|
|
runner.update(ctx, |runner, ctx| runner.list(global_options, ctx));
|
|
Ok(())
|
|
}
|
|
EnvironmentCommand::Create {
|
|
name,
|
|
description,
|
|
docker_image,
|
|
repo,
|
|
setup_command,
|
|
scope,
|
|
} => {
|
|
let repos = parse_repos(repo)?;
|
|
|
|
runner.update(ctx, |runner, ctx| {
|
|
runner.create(
|
|
name,
|
|
description,
|
|
docker_image,
|
|
repos,
|
|
setup_command,
|
|
scope,
|
|
ctx,
|
|
)
|
|
});
|
|
Ok(())
|
|
}
|
|
EnvironmentCommand::Delete { id, force } => {
|
|
runner.update(ctx, |runner, ctx| runner.delete(id, force, ctx));
|
|
Ok(())
|
|
}
|
|
EnvironmentCommand::Update {
|
|
id,
|
|
name,
|
|
description,
|
|
remove_description,
|
|
docker_image,
|
|
repo,
|
|
setup_command,
|
|
remove_repo,
|
|
remove_setup_command,
|
|
force,
|
|
} => {
|
|
let repos = parse_repos(repo)?;
|
|
let remove_repos = parse_repos(remove_repo)?;
|
|
|
|
runner.update(ctx, |runner, ctx| {
|
|
runner.update_environment(
|
|
id,
|
|
name,
|
|
description,
|
|
remove_description,
|
|
docker_image,
|
|
repos,
|
|
setup_command,
|
|
remove_repos,
|
|
remove_setup_command,
|
|
force,
|
|
ctx,
|
|
)
|
|
});
|
|
Ok(())
|
|
}
|
|
EnvironmentCommand::Get { id } => {
|
|
runner.update(ctx, |runner, ctx| runner.get(id, ctx));
|
|
Ok(())
|
|
}
|
|
EnvironmentCommand::Image(image_cmd) => match image_cmd {
|
|
ImageCommand::List => {
|
|
runner.update(ctx, |runner, ctx| runner.list_images(global_options, ctx));
|
|
Ok(())
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Singleton model for running async work as part of environment CLI commands.
|
|
struct EnvironmentCommandRunner;
|
|
|
|
impl EnvironmentCommandRunner {
|
|
fn list_images(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
|
|
let server_api = ServerApiProvider::as_ref(ctx).get();
|
|
|
|
let operation = ListWarpDevImages::build(ListWarpDevImagesVariables {});
|
|
let fetch_images = async move { server_api.send_graphql_request(operation, None).await };
|
|
|
|
ctx.spawn(fetch_images, move |_, result, ctx| match result {
|
|
Ok(response) => match response.list_warp_dev_images {
|
|
ListWarpDevImagesResult::ListWarpDevImagesOutput(output) => {
|
|
let image_infos: Vec<_> = output
|
|
.images
|
|
.into_iter()
|
|
.map(|img| ImageInfo {
|
|
image: img.image,
|
|
repository: img.repository,
|
|
tag: img.tag,
|
|
})
|
|
.collect();
|
|
|
|
if matches!(
|
|
global_options.output_format,
|
|
OutputFormat::Text | OutputFormat::Pretty
|
|
) {
|
|
println!(
|
|
"All Warp dev images contain Python and Node. For more information, see: {}\n",
|
|
WARP_DEV_ENVIRONMENTS_REPO
|
|
);
|
|
}
|
|
output::print_list(image_infos, global_options.output_format);
|
|
ctx.terminate_app(galaxyui::platform::TerminationMode::ForceTerminate, None);
|
|
}
|
|
ListWarpDevImagesResult::UserFacingError(_) | ListWarpDevImagesResult::Unknown => {
|
|
super::report_fatal_error(anyhow::anyhow!("Failed to fetch images"), ctx);
|
|
}
|
|
},
|
|
Err(err) => {
|
|
super::report_fatal_error(anyhow::anyhow!("Failed to fetch images: {}", err), ctx);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn list(&self, global_options: GlobalOptions, ctx: &mut ModelContext<Self>) {
|
|
let initial_sync = UpdateManager::as_ref(ctx)
|
|
.initial_load_complete()
|
|
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT);
|
|
|
|
ctx.spawn(initial_sync, move |_, result, ctx| {
|
|
if result.is_err() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
let environments = CloudAmbientAgentEnvironment::get_all(ctx);
|
|
|
|
let environment_infos: Vec<_> = environments
|
|
.iter()
|
|
.map(|environment| {
|
|
let name = environment.model().string_model.name.clone();
|
|
let description = environment.model().string_model.description.clone();
|
|
let base_image = environment.model().string_model.base_image.clone();
|
|
let github_repos = environment.model().string_model.github_repos.clone();
|
|
let setup_commands = environment.model().string_model.setup_commands.clone();
|
|
|
|
let creator_email = environment
|
|
.metadata()
|
|
.creator_uid
|
|
.as_ref()
|
|
.and_then(|uid| {
|
|
UserProfiles::as_ref(ctx)
|
|
.profile_for_uid(UserUid::new(uid))
|
|
.map(|profile| profile.email.clone())
|
|
})
|
|
.unwrap_or_else(|| "Unknown".to_string());
|
|
|
|
let last_edited_utc = environment.metadata().revision.as_ref().map(|r| r.utc());
|
|
|
|
let last_edited = last_edited_utc
|
|
.map(format_approx_duration_from_now_utc)
|
|
.unwrap_or_else(|| "Unknown".to_string());
|
|
|
|
let scope_display =
|
|
super::common::format_owner(&environment.permissions().owner);
|
|
|
|
let id = match environment.sync_id() {
|
|
SyncId::ServerId(server_id) => server_id.to_string(),
|
|
SyncId::ClientId(_) => "Unsynced".to_string(),
|
|
};
|
|
|
|
EnvironmentInfo {
|
|
id,
|
|
name,
|
|
description,
|
|
base_image,
|
|
github_repos,
|
|
setup_commands,
|
|
creator_email,
|
|
last_edited,
|
|
last_edited_utc,
|
|
scope: scope_display.to_string(),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
output::print_list(environment_infos, global_options.output_format);
|
|
|
|
ctx.terminate_app(galaxyui::platform::TerminationMode::ForceTerminate, None);
|
|
});
|
|
}
|
|
|
|
fn get(&mut self, id: String, ctx: &mut ModelContext<Self>) {
|
|
let initial_sync = UpdateManager::as_ref(ctx)
|
|
.initial_load_complete()
|
|
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT);
|
|
|
|
ctx.spawn(initial_sync, move |_, result, ctx| {
|
|
if result.is_err() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get the ServerId and check if the environment exists
|
|
let server_id = match ServerId::try_from(id.as_str()) {
|
|
Ok(sid) => sid,
|
|
Err(_) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!("Environment {} not found", id))),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let sync_id = SyncId::ServerId(server_id);
|
|
let environment = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx);
|
|
|
|
if let Some(environment) = environment {
|
|
Self::print_environment_details(&environment.model().string_model);
|
|
ctx.terminate_app(galaxyui::platform::TerminationMode::ForceTerminate, None);
|
|
} else {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!("Environment {} not found", id))),
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn print_environment_details(env: &AmbientAgentEnvironment) {
|
|
println!("Name: {}", env.name);
|
|
if let Some(desc) = &env.description {
|
|
println!("Description: {desc}");
|
|
}
|
|
match &env.base_image {
|
|
BaseImage::DockerImage(img) => {
|
|
println!("Docker image: {img}");
|
|
}
|
|
}
|
|
if env.github_repos.is_empty() {
|
|
println!("Repositories: None");
|
|
} else {
|
|
println!("Repositories:");
|
|
for repo in &env.github_repos {
|
|
println!(" - {}/{}", repo.owner, repo.repo);
|
|
}
|
|
}
|
|
if env.setup_commands.is_empty() {
|
|
println!("Setup commands: None");
|
|
} else {
|
|
println!("Setup commands:");
|
|
for (i, cmd) in env.setup_commands.iter().enumerate() {
|
|
println!(" {}. {}", i + 1, cmd);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle inquire errors, returning true if the error was handled (and caller should return early).
|
|
fn handle_inquire_error(err: InquireError, ctx: &mut ModelContext<Self>) -> bool {
|
|
match err {
|
|
InquireError::OperationCanceled | InquireError::OperationInterrupted => {
|
|
eprintln!("Environment creation canceled.");
|
|
ctx.terminate_app(galaxyui::platform::TerminationMode::ForceTerminate, None);
|
|
true
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
/// Fetch images from server and prompt user to select one. Calls continuation with selected image.
|
|
fn prompt_for_docker_image<F>(continuation: F, ctx: &mut ModelContext<Self>)
|
|
where
|
|
F: FnOnce(String, &mut ModelContext<Self>) + Send + 'static,
|
|
{
|
|
const CUSTOM_IMAGE_OPTION: &str = "Custom Docker image";
|
|
|
|
let server_api = ServerApiProvider::as_ref(ctx).get();
|
|
let operation = ListWarpDevImages::build(ListWarpDevImagesVariables {});
|
|
let fetch_images = async move { server_api.send_graphql_request(operation, None).await };
|
|
|
|
ctx.spawn(fetch_images, move |_, result, ctx| match result {
|
|
Ok(response) => match response.list_warp_dev_images {
|
|
ListWarpDevImagesResult::ListWarpDevImagesOutput(output) => {
|
|
if output.images.is_empty() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("No Warp dev images available."),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
println!(
|
|
"No docker image provided, please select a base image.\n"
|
|
);
|
|
println!(
|
|
"All warpdotdev images contain Python and Node, in addition to language-specific tooling. For more info: {}\n",
|
|
WARP_DEV_ENVIRONMENTS_REPO
|
|
);
|
|
|
|
let mut image_choices: Vec<String> =
|
|
output.images.into_iter().map(|img| img.image).collect();
|
|
image_choices.push(CUSTOM_IMAGE_OPTION.to_string());
|
|
|
|
let selected_image = match Select::new("Select a base image:", image_choices)
|
|
.prompt()
|
|
{
|
|
Ok(image) => image,
|
|
Err(err) => {
|
|
if !Self::handle_inquire_error(err, ctx) {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Error selecting image"),
|
|
ctx,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
};
|
|
|
|
let final_image = if selected_image == CUSTOM_IMAGE_OPTION {
|
|
match inquire::Text::new("Enter custom Docker image name:").prompt() {
|
|
Ok(custom) => custom,
|
|
Err(err) => {
|
|
if !Self::handle_inquire_error(err, ctx) {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Error entering custom image"),
|
|
ctx,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
selected_image
|
|
};
|
|
|
|
continuation(final_image, ctx);
|
|
}
|
|
ListWarpDevImagesResult::UserFacingError(_) | ListWarpDevImagesResult::Unknown => {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Failed to fetch list of base images"),
|
|
ctx,
|
|
);
|
|
}
|
|
},
|
|
Err(err) => {
|
|
super::report_fatal_error(anyhow::anyhow!("Failed to fetch images: {err}"), ctx);
|
|
}
|
|
});
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn create(
|
|
&mut self,
|
|
name: String,
|
|
description: Option<String>,
|
|
docker_image: Option<String>,
|
|
github_repos: Vec<GithubRepo>,
|
|
setup_commands: Vec<String>,
|
|
scope: galaxy_cli::scope::ObjectScope,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
if let Some(image) = docker_image {
|
|
Self::create_with_image(
|
|
name,
|
|
description,
|
|
image,
|
|
github_repos,
|
|
setup_commands,
|
|
scope,
|
|
ctx,
|
|
);
|
|
} else {
|
|
Self::prompt_for_docker_image(
|
|
move |image, ctx| {
|
|
Self::create_with_image(
|
|
name,
|
|
description,
|
|
image,
|
|
github_repos,
|
|
setup_commands,
|
|
scope,
|
|
ctx,
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn create_with_image(
|
|
name: String,
|
|
description: Option<String>,
|
|
docker_image: String,
|
|
github_repos: Vec<GithubRepo>,
|
|
setup_commands: Vec<String>,
|
|
scope: galaxy_cli::scope::ObjectScope,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let initial_sync = UpdateManager::as_ref(ctx)
|
|
.initial_load_complete()
|
|
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT);
|
|
|
|
ctx.spawn(initial_sync, move |_, result, ctx| {
|
|
if result.is_err() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Start the iterative auth + create flow.
|
|
Self::auth_repos_then_execute(
|
|
github_repos.clone(),
|
|
1,
|
|
"create",
|
|
move |ctx| {
|
|
Self::create_environment_after_auth_check(
|
|
name,
|
|
description,
|
|
github_repos,
|
|
docker_image,
|
|
setup_commands,
|
|
scope,
|
|
ctx,
|
|
);
|
|
},
|
|
ctx,
|
|
);
|
|
});
|
|
}
|
|
|
|
/// Generic auth flow that checks repo authorization and handles OAuth.
|
|
/// Takes a closure that will be called after successful auth.
|
|
fn auth_repos_then_execute<F>(
|
|
repos: Vec<GithubRepo>,
|
|
attempt: u32,
|
|
operation_name: &'static str,
|
|
on_success: F,
|
|
ctx: &mut ModelContext<Self>,
|
|
) where
|
|
F: FnOnce(&mut ModelContext<Self>) + Send + 'static,
|
|
{
|
|
const MAX_AUTH_ATTEMPTS: u32 = 8;
|
|
|
|
if repos.is_empty() {
|
|
on_success(ctx);
|
|
return;
|
|
}
|
|
|
|
if attempt > MAX_AUTH_ATTEMPTS {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"Exceeded maximum number of authorization attempts ({}). Please try again later.",
|
|
MAX_AUTH_ATTEMPTS
|
|
))),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get IntegrationsClient for auth checks and polling
|
|
let integrations_client = ServerApiProvider::as_ref(ctx).get_integrations_client();
|
|
|
|
let repo_tuples: Vec<(String, String)> = repos
|
|
.iter()
|
|
.map(|repo| (repo.owner.clone(), repo.repo.clone()))
|
|
.collect();
|
|
|
|
let auth_check_future = async move {
|
|
integrations_client
|
|
.check_user_repo_auth_status(repo_tuples)
|
|
.await
|
|
};
|
|
|
|
ctx.spawn(auth_check_future, move |_, auth_result, ctx| {
|
|
match auth_result {
|
|
Ok(response) => {
|
|
let mut has_blocking_private_issues = false;
|
|
let mut has_public_auth_gaps = false;
|
|
let mut private_repo_owners = HashSet::new();
|
|
|
|
for status in &response.statuses {
|
|
match status.status {
|
|
UserRepoAuthStatusEnum::Success => {
|
|
if !status.is_public {
|
|
private_repo_owners.insert(status.owner.clone());
|
|
}
|
|
}
|
|
UserRepoAuthStatusEnum::NoInstallationOrAccessForRepo => {
|
|
if !status.is_public {
|
|
eprintln!(
|
|
"Cannot access private repo {}/{}",
|
|
status.owner, status.repo,
|
|
);
|
|
has_blocking_private_issues = true;
|
|
private_repo_owners.insert(status.owner.clone());
|
|
} else {
|
|
has_public_auth_gaps = true;
|
|
}
|
|
}
|
|
UserRepoAuthStatusEnum::UserNotConnectedToGithub => {
|
|
eprintln!("User not connected to GitHub");
|
|
has_blocking_private_issues = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check that all private repos have the same owner
|
|
if private_repo_owners.len() > 1 {
|
|
let owners_str = private_repo_owners.into_iter().collect::<Vec<_>>().join(", ");
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"All private repositories in an environment must belong to the same owner. Found multiple owners: {}.\nIf you need support for private repos from multiple owners, please submit a GitHub issue.",
|
|
owners_str
|
|
))),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if !has_blocking_private_issues {
|
|
// No blocking issues with private repos.
|
|
// Public repos without auth can proceed with warnings.
|
|
if has_public_auth_gaps {
|
|
for status in &response.statuses {
|
|
if status.is_public
|
|
&& matches!(
|
|
status.status,
|
|
UserRepoAuthStatusEnum::NoInstallationOrAccessForRepo
|
|
)
|
|
{
|
|
eprintln!(
|
|
"Warning: using public repo {}/{} without authorization. Read-only access is available, but you need to authorize if you want full access.",
|
|
status.owner, status.repo
|
|
);
|
|
}
|
|
}
|
|
if let Some(auth_url) = response.auth_url {
|
|
println!("\nAuthorize access here: {auth_url}\n");
|
|
}
|
|
}
|
|
|
|
// Proceed with the operation after successful auth
|
|
on_success(ctx);
|
|
return;
|
|
}
|
|
|
|
// We have blocking issues with private repos.
|
|
// Handle OAuth flow if server provides auth_url + tx_id.
|
|
match (response.auth_url, response.tx_id) {
|
|
(Some(auth_url), Some(tx_id)) => {
|
|
// Open URL and poll for OAuth completion.
|
|
println!("\nAuthorization required for private repository access.");
|
|
println!("Opening browser for GitHub authorization: {auth_url}\n");
|
|
ctx.open_url(&auth_url);
|
|
|
|
let integrations_client = ServerApiProvider::as_ref(ctx)
|
|
.get_integrations_client();
|
|
let tx_id = tx_id.into_inner();
|
|
let poll_future = poll_oauth_until_terminal(integrations_client, tx_id);
|
|
|
|
let next_attempt = attempt + 1;
|
|
|
|
ctx.spawn(
|
|
poll_future,
|
|
move |_, poll_result, ctx| {
|
|
match poll_result {
|
|
Ok(OauthConnectTxStatus::Completed) => {
|
|
// OAuth completed, retry auth check and operation.
|
|
Self::auth_repos_then_execute(repos, next_attempt, operation_name, on_success, ctx);
|
|
}
|
|
Ok(OauthConnectTxStatus::Failed) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"GitHub authorization failed. Please try again."
|
|
))),
|
|
);
|
|
}
|
|
Ok(OauthConnectTxStatus::Expired) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"GitHub authorization expired. Please try again."
|
|
))),
|
|
);
|
|
}
|
|
Ok(OauthConnectTxStatus::Pending)
|
|
| Ok(OauthConnectTxStatus::InProgress) => {
|
|
// Should not be returned by poll_oauth_until_terminal.
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"Unexpected non-terminal OAuth status returned"
|
|
))),
|
|
);
|
|
}
|
|
Err(err) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"Error polling OAuth status: {err}"
|
|
))),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
(Some(auth_url), None) => {
|
|
// Legacy flow: no txId, print URL and exit.
|
|
println!("\nAuthorize access here: {auth_url}\n");
|
|
println!("After authorizing, please re-run this command.");
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
None,
|
|
);
|
|
}
|
|
(None, Some(_)) => {
|
|
// Server returned txId without authUrl - unexpected.
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"Server error: did not receive auth URL for OAuth flow"
|
|
))),
|
|
);
|
|
}
|
|
(None, None) => {
|
|
// No auth URL or txId provided, but we have auth issues.
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"Cannot {} environment: authorization required but no auth flow provided by server",
|
|
operation_name
|
|
))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(e.context("Failed to check GitHub auth status"))),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper function to create environment after successful auth check
|
|
fn create_environment_after_auth_check(
|
|
name: String,
|
|
description: Option<String>,
|
|
github_repos: Vec<GithubRepo>,
|
|
docker_image: String,
|
|
setup_commands: Vec<String>,
|
|
scope: ObjectScope,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let environment = AmbientAgentEnvironment::new(
|
|
name,
|
|
description,
|
|
github_repos,
|
|
docker_image,
|
|
setup_commands,
|
|
);
|
|
let client_id = ClientId::default();
|
|
|
|
let owner = match super::common::resolve_owner(scope.team, scope.personal, ctx) {
|
|
Ok(owner) => owner,
|
|
Err(e) => {
|
|
super::report_fatal_error(e, ctx);
|
|
return;
|
|
}
|
|
};
|
|
|
|
// Create on the server
|
|
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
|
update_manager.create_ambient_agent_environment(environment, client_id, owner, ctx);
|
|
});
|
|
|
|
// Await creation on the server, then return.
|
|
// We should subscribe to the UpdateManager here because we want to wait
|
|
// for our environment to be assigned a ServerId. Environments are not
|
|
// usable without first being synced.
|
|
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
|
|
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
|
|
if matches!(result.operation, ObjectOperation::Create { .. })
|
|
&& matches!(result.success_type, OperationSuccessType::Success)
|
|
&& result.client_id == Some(client_id)
|
|
{
|
|
let server_id = result.server_id.unwrap();
|
|
println!("Environment created successfully with ID: {server_id}");
|
|
ctx.terminate_app(galaxyui::platform::TerminationMode::ForceTerminate, None);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Before doing an action like `update` or `delete`, use this function to check whether
|
|
// they are currently being used in any integrations -- if they are, ask the user to confirm
|
|
// before running the supplied `on_confirm` function
|
|
fn confirm_if_integrations_using_environment<F>(
|
|
id: String,
|
|
action: &'static str,
|
|
on_confirm: F,
|
|
ctx: &mut ModelContext<Self>,
|
|
) where
|
|
F: FnOnce(&mut ModelContext<Self>) + Send + 'static,
|
|
{
|
|
let integrations_client = ServerApiProvider::as_ref(ctx).get_integrations_client();
|
|
|
|
let check_integrations_future = async move {
|
|
integrations_client
|
|
.get_integrations_using_environment(id)
|
|
.await
|
|
};
|
|
|
|
ctx.spawn(check_integrations_future, move |_, result, ctx| {
|
|
match result {
|
|
Ok(output) => {
|
|
if !output.provider_names.is_empty() {
|
|
let integration_list = output.provider_names.join(", ");
|
|
let prompt_message = format!(
|
|
"This environment is used in the following integration(s): {integration_list}. Are you sure you want to {action} it?"
|
|
);
|
|
|
|
let confirmation = Confirm::new(&prompt_message)
|
|
.with_default(false)
|
|
.prompt();
|
|
|
|
match confirmation {
|
|
Ok(true) => on_confirm(ctx),
|
|
Ok(false) | Err(InquireError::OperationCanceled | InquireError::OperationInterrupted) => {
|
|
println!("Environment {action} canceled.");
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
None,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!("Error prompting for confirmation: {err}"))),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
on_confirm(ctx);
|
|
}
|
|
}
|
|
Err(_) => {
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(anyhow::anyhow!(
|
|
"Aborting environment {action} because integration usage could not be determined. Re-run with --force to override."
|
|
))),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn update_environment(
|
|
&mut self,
|
|
id: String,
|
|
name: Option<String>,
|
|
description: Option<String>,
|
|
remove_description: bool,
|
|
docker_image: Option<String>,
|
|
add_repos: Vec<GithubRepo>,
|
|
add_setup_commands: Vec<String>,
|
|
remove_repos: Vec<GithubRepo>,
|
|
remove_setup_commands: Vec<String>,
|
|
force: bool,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let initial_sync = UpdateManager::as_ref(ctx)
|
|
.initial_load_complete()
|
|
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT);
|
|
|
|
ctx.spawn(initial_sync, move |_, result, ctx| {
|
|
if result.is_err() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get the ServerId and check if the environment exists
|
|
let server_id = match ServerId::try_from(id.as_str()) {
|
|
Ok(sid) => sid,
|
|
Err(_) => {
|
|
let error = anyhow::anyhow!("Environment {} not found", id);
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(error)),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let sync_id = SyncId::ServerId(server_id);
|
|
let environment = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx);
|
|
let Some(environment) = environment else {
|
|
let error = anyhow::anyhow!("Environment {} not found", id);
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(error)),
|
|
);
|
|
return;
|
|
};
|
|
|
|
// Set up the update environment callback, to be run after we've
|
|
// confirmed with the user and checked on auth.
|
|
let environment_clone = environment.clone();
|
|
let repos_clone = add_repos.clone();
|
|
let execute_update = move |ctx: &mut ModelContext<Self>| {
|
|
Self::update_environment_after_auth_check(
|
|
&environment_clone,
|
|
server_id,
|
|
name,
|
|
description,
|
|
remove_description,
|
|
docker_image,
|
|
repos_clone,
|
|
add_setup_commands,
|
|
remove_repos,
|
|
remove_setup_commands,
|
|
ctx,
|
|
);
|
|
};
|
|
|
|
// Set up the auth check, to be run before updating but after we've
|
|
// confirmed with the user if the operation should occur.
|
|
let auth_repos_before_update = move |ctx: &mut ModelContext<Self>| {
|
|
Self::auth_repos_then_execute(add_repos, 1, "update", execute_update, ctx);
|
|
};
|
|
|
|
// Check if any integrations are using this environment
|
|
if force {
|
|
auth_repos_before_update(ctx);
|
|
} else {
|
|
Self::confirm_if_integrations_using_environment(
|
|
id,
|
|
"update",
|
|
auth_repos_before_update,
|
|
ctx,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Helper function to update environment after successful auth check
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn update_environment_after_auth_check(
|
|
environment: &CloudAmbientAgentEnvironment,
|
|
server_id: ServerId,
|
|
name: Option<String>,
|
|
description: Option<String>,
|
|
remove_description: bool,
|
|
docker_image: Option<String>,
|
|
add_repos: Vec<GithubRepo>,
|
|
add_setup_commands: Vec<String>,
|
|
remove_repos: Vec<GithubRepo>,
|
|
remove_setup_commands: Vec<String>,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let mut updated_env = environment.model().string_model.clone();
|
|
|
|
// Update whatever fields we have been provided
|
|
if let Some(new_name) = name {
|
|
updated_env.name = new_name;
|
|
}
|
|
|
|
if remove_description {
|
|
updated_env.description = None;
|
|
} else if let Some(new_description) = description {
|
|
updated_env.description = Some(new_description);
|
|
}
|
|
|
|
if let Some(new_docker_image) = docker_image {
|
|
updated_env.base_image = BaseImage::DockerImage(new_docker_image);
|
|
}
|
|
|
|
for repo in add_repos {
|
|
if !updated_env.github_repos.contains(&repo) {
|
|
updated_env.github_repos.push(repo);
|
|
}
|
|
}
|
|
|
|
for repo in &remove_repos {
|
|
if let Some(pos) = updated_env.github_repos.iter().position(|r| r == repo) {
|
|
updated_env.github_repos.remove(pos);
|
|
} else {
|
|
eprintln!(
|
|
"Warning: repository {}/{} not found in environment, skipping removal",
|
|
repo.owner, repo.repo
|
|
);
|
|
}
|
|
}
|
|
|
|
for cmd in add_setup_commands {
|
|
updated_env.setup_commands.push(cmd);
|
|
}
|
|
|
|
for cmd in &remove_setup_commands {
|
|
if let Some(pos) = updated_env.setup_commands.iter().position(|c| c == cmd) {
|
|
updated_env.setup_commands.remove(pos);
|
|
} else {
|
|
eprintln!(
|
|
"Warning: setup command '{cmd}' not found in environment, skipping removal"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Update the environment via UpdateManager
|
|
let revision = environment.metadata.revision.clone();
|
|
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
|
update_manager
|
|
.update_object::<GenericStringObjectId, CloudAmbientAgentEnvironmentModel>(
|
|
CloudAmbientAgentEnvironmentModel::new(updated_env.clone()),
|
|
environment.sync_id(),
|
|
revision,
|
|
ctx,
|
|
);
|
|
});
|
|
|
|
// Subscribe to UpdateManager to wait for the update to complete
|
|
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
|
|
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
|
|
if matches!(result.operation, ObjectOperation::Update)
|
|
&& result.server_id == Some(server_id)
|
|
{
|
|
match result.success_type {
|
|
OperationSuccessType::Success => {
|
|
println!("Environment updated successfully!\n");
|
|
Self::print_environment_details(&updated_env);
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
None,
|
|
);
|
|
}
|
|
_ => {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Failed to update environment"),
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn delete(&mut self, id: String, force: bool, ctx: &mut ModelContext<Self>) {
|
|
let initial_sync = UpdateManager::as_ref(ctx)
|
|
.initial_load_complete()
|
|
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT);
|
|
|
|
ctx.spawn(initial_sync, move |_, result, ctx| {
|
|
if result.is_err() {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
|
|
ctx,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get the ServerId and check if the environment exists
|
|
let server_id = match ServerId::try_from(id.as_str()) {
|
|
Ok(sid) => sid,
|
|
Err(_) => {
|
|
let error = anyhow::anyhow!("Environment {} not found", id);
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(error)),
|
|
);
|
|
return;
|
|
}
|
|
};
|
|
let sync_id = SyncId::ServerId(server_id);
|
|
let environment = CloudAmbientAgentEnvironment::get_by_id(&sync_id, ctx);
|
|
let Some(environment) = environment else {
|
|
let error = anyhow::anyhow!("Environment {} not found", id);
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
Some(Err(error)),
|
|
);
|
|
return;
|
|
};
|
|
let type_and_id = environment.cloud_object_type_and_id();
|
|
|
|
// Check if any integrations are using this environment
|
|
if force {
|
|
Self::execute_delete(type_and_id, ctx);
|
|
} else {
|
|
Self::confirm_if_integrations_using_environment(
|
|
id,
|
|
"delete",
|
|
move |ctx| {
|
|
Self::execute_delete(type_and_id, ctx);
|
|
},
|
|
ctx,
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
fn execute_delete(type_and_id: CloudObjectTypeAndId, ctx: &mut ModelContext<Self>) {
|
|
UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| {
|
|
update_manager.delete_object_by_user(type_and_id, ctx);
|
|
});
|
|
|
|
// Listen to the UpdateManager for a completed object deletion
|
|
ctx.subscribe_to_model(&UpdateManager::handle(ctx), move |_, _, event, ctx| {
|
|
if let UpdateManagerEvent::ObjectOperationComplete { result } = event {
|
|
if matches!(result.operation, ObjectOperation::Delete { .. }) {
|
|
match result.success_type {
|
|
OperationSuccessType::Success => {
|
|
println!("Environment deleted successfully");
|
|
ctx.terminate_app(
|
|
galaxyui::platform::TerminationMode::ForceTerminate,
|
|
None,
|
|
);
|
|
}
|
|
_ => {
|
|
super::report_fatal_error(
|
|
anyhow::anyhow!("Failed to delete environment"),
|
|
ctx,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
impl galaxyui::Entity for EnvironmentCommandRunner {
|
|
type Event = ();
|
|
}
|
|
impl SingletonEntity for EnvironmentCommandRunner {}
|
|
|
|
/// Environment information that's shown in the `list` command.
|
|
#[derive(Serialize)]
|
|
struct EnvironmentInfo {
|
|
id: String,
|
|
name: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
description: Option<String>,
|
|
base_image: BaseImage,
|
|
github_repos: Vec<GithubRepo>,
|
|
setup_commands: Vec<String>,
|
|
creator_email: String,
|
|
#[serde(skip_serializing)]
|
|
last_edited: String,
|
|
#[serde(rename = "last_edited")]
|
|
last_edited_utc: Option<chrono::DateTime<chrono::Utc>>,
|
|
scope: String,
|
|
}
|
|
|
|
impl TableFormat for EnvironmentInfo {
|
|
fn header() -> Vec<Cell> {
|
|
vec![
|
|
Cell::new("ID"),
|
|
Cell::new("Name"),
|
|
Cell::new("Description"),
|
|
Cell::new("Base image"),
|
|
Cell::new("Git repos"),
|
|
Cell::new("Setup commands"),
|
|
Cell::new("Creator"),
|
|
Cell::new("Last edited"),
|
|
Cell::new("Scope"),
|
|
]
|
|
}
|
|
|
|
fn row(&self) -> Vec<Cell> {
|
|
let github_repos_display = self
|
|
.github_repos
|
|
.iter()
|
|
.map(|repo| repo.to_string())
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let setup_commands_display = self.setup_commands.join("\n");
|
|
let description_display = self.description.as_deref().unwrap_or("");
|
|
|
|
vec![
|
|
Cell::new(&self.id),
|
|
Cell::new(&self.name),
|
|
Cell::new(description_display),
|
|
Cell::new(self.base_image.to_string()),
|
|
Cell::new(github_repos_display),
|
|
Cell::new(setup_commands_display),
|
|
Cell::new(&self.creator_email),
|
|
Cell::new(&self.last_edited),
|
|
Cell::new(&self.scope),
|
|
]
|
|
}
|
|
}
|
|
|
|
/// Image information that's shown in the `image list` command.
|
|
#[derive(Serialize)]
|
|
struct ImageInfo {
|
|
image: String,
|
|
repository: String,
|
|
tag: String,
|
|
}
|
|
|
|
impl TableFormat for ImageInfo {
|
|
fn header() -> Vec<Cell> {
|
|
vec![
|
|
Cell::new("Image"),
|
|
Cell::new("Repository"),
|
|
Cell::new("Tag"),
|
|
]
|
|
}
|
|
|
|
fn row(&self) -> Vec<Cell> {
|
|
vec![
|
|
Cell::new(&self.image),
|
|
Cell::new(&self.repository),
|
|
Cell::new(&self.tag),
|
|
]
|
|
}
|
|
}
|