diff --git a/Cargo.lock b/Cargo.lock index b33447d883f9a..57e2dabc6b254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3985,9 +3985,9 @@ dependencies = [ [[package]] name = "nasm-rs" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce095842aee9aa3ecbda7a5d2a4df680375fd128a8596b6b56f8e497e231f483" +checksum = "fe4d98d0065f4b1daf164b3eafb11974c94662e5e2396cf03f32d0bb5c17da51" dependencies = [ "rayon", ] diff --git a/crates/turborepo-api-client/src/lib.rs b/crates/turborepo-api-client/src/lib.rs index 20d37e2d3ec25..ef242b7099178 100644 --- a/crates/turborepo-api-client/src/lib.rs +++ b/crates/turborepo-api-client/src/lib.rs @@ -76,11 +76,22 @@ impl Team { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Space { + pub id: String, + pub name: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TeamsResponse { pub teams: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpacesResponse { + pub spaces: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct User { pub id: String, @@ -213,6 +224,37 @@ impl APIClient { }) } + pub async fn get_spaces(&self, token: &str, team_id: Option<&str>) -> Result { + // create url with teamId if provided + let endpoint = match team_id { + Some(team_id) => format!("/v0/spaces?limit=100&teamId={}", team_id), + None => "/v0/spaces?limit=100".to_string(), + }; + + let response = self + .make_retryable_request(|| { + let request_builder = self + .client + .get(self.make_url(endpoint.as_str())) + .header("User-Agent", self.user_agent.clone()) + .header("Content-Type", "application/json") + .header("Authorization", format!("Bearer {}", token)); + + request_builder.send() + }) + .await? + .error_for_status()?; + + response.json().await.map_err(|err| { + anyhow!( + "Error getting spaces: {}", + err.status() + .and_then(|status| status.canonical_reason()) + .unwrap_or(&err.to_string()) + ) + }) + } + pub async fn verify_sso_token(&self, token: &str, token_name: &str) -> Result { let response = self .make_retryable_request(|| { diff --git a/crates/turborepo-lib/src/cli.rs b/crates/turborepo-lib/src/cli.rs index 237dd4c5e41bf..6ad731a68b467 100644 --- a/crates/turborepo-lib/src/cli.rs +++ b/crates/turborepo-lib/src/cli.rs @@ -177,6 +177,12 @@ pub enum DaemonCommand { Stop, } +#[derive(Copy, Clone, Debug, PartialEq, Serialize, ValueEnum)] +pub enum LinkTarget { + RemoteCache, + Spaces, +} + impl Args { pub fn new() -> Result { let mut clap_args = match Args::try_parse() { @@ -250,6 +256,10 @@ pub enum Command { /// Do not create or modify .gitignore (default false) #[clap(long)] no_gitignore: bool, + + /// Specify what should be linked (default "remote cache") + #[clap(long, value_enum, default_value_t = LinkTarget::RemoteCache)] + target: LinkTarget, }, /// Login to your Vercel account Login { @@ -280,7 +290,11 @@ pub enum Command { Run(Box), /// Unlink the current directory from your Vercel organization and disable /// Remote Caching - Unlink {}, + Unlink { + /// Specify what should be unlinked (default "remote cache") + #[clap(long, value_enum, default_value_t = LinkTarget::RemoteCache)] + target: LinkTarget, + }, } #[derive(Parser, Clone, Debug, Default, Serialize, PartialEq)] @@ -506,30 +520,32 @@ pub async fn run(repo_state: Option) -> Result { Ok(Payload::Rust(Ok(0))) } - Command::Link { no_gitignore } => { + Command::Link { no_gitignore, target} => { if clap_args.test_run { println!("Link test run successful"); return Ok(Payload::Rust(Ok(0))); } let modify_gitignore = !*no_gitignore; + let to = *target; let mut base = CommandBase::new(clap_args, repo_root, version)?; - if let Err(err) = link::link(&mut base, modify_gitignore).await { + if let Err(err) = link::link(&mut base, modify_gitignore, to).await { error!("error: {}", err.to_string()) }; Ok(Payload::Rust(Ok(0))) } - Command::Unlink { .. } => { + Command::Unlink { target } => { if clap_args.test_run { println!("Unlink test run successful"); return Ok(Payload::Rust(Ok(0))); } + let from = *target; let mut base = CommandBase::new(clap_args, repo_root, version)?; - unlink::unlink(&mut base)?; + unlink::unlink(&mut base, from)?; Ok(Payload::Rust(Ok(0))) } @@ -1212,7 +1228,9 @@ mod test { assert_eq!( Args::try_parse_from(["turbo", "unlink"]).unwrap(), Args { - command: Some(Command::Unlink {}), + command: Some(Command::Unlink { + target: crate::cli::LinkTarget::RemoteCache + }), ..Args::default() } ); @@ -1222,7 +1240,9 @@ mod test { command_args: vec![], global_args: vec![vec!["--cwd", "../examples/with-yarn"]], expected_output: Args { - command: Some(Command::Unlink {}), + command: Some(Command::Unlink { + target: crate::cli::LinkTarget::RemoteCache, + }), cwd: Some(PathBuf::from("../examples/with-yarn")), ..Args::default() }, diff --git a/crates/turborepo-lib/src/commands/link.rs b/crates/turborepo-lib/src/commands/link.rs index 66bf0fd190cff..80948fb878aad 100644 --- a/crates/turborepo-lib/src/commands/link.rs +++ b/crates/turborepo-lib/src/commands/link.rs @@ -16,12 +16,14 @@ use dialoguer::{theme::ColorfulTheme, Confirm}; use dirs_next::home_dir; #[cfg(test)] use rand::Rng; -use turborepo_api_client::{APIClient, CachingStatus, Team}; +use turborepo_api_client::{APIClient, CachingStatus, Space, Team}; #[cfg(not(test))] use crate::ui::CYAN; use crate::{ + cli::LinkTarget, commands::CommandBase, + config::{SpacesJson, TurboJson}, ui::{BOLD, GREY, UNDERLINE}, }; @@ -31,6 +33,11 @@ pub(crate) enum SelectedTeam<'a> { Team(&'a Team), } +#[derive(Clone)] +pub(crate) enum SelectedSpace<'a> { + Space(&'a Space), +} + pub(crate) const REMOTE_CACHING_INFO: &str = " Remote Caching shares your cached Turborepo task \ outputs and logs across all your team’s Vercel projects. It also can share outputs @@ -38,6 +45,7 @@ pub(crate) const REMOTE_CACHING_INFO: &str = " Remote Caching shares your cache This results in faster build times and deployments for your team."; pub(crate) const REMOTE_CACHING_URL: &str = "https://turbo.build/repo/docs/core-concepts/remote-caching"; +pub(crate) const SPACES_URL: &str = "https://vercel.com/docs/spaces"; /// Verifies that caching status for a team is enabled, or prompts the user to /// enable it. @@ -100,25 +108,14 @@ pub(crate) async fn verify_caching_enabled<'a>( } } -pub async fn link(base: &mut CommandBase, modify_gitignore: bool) -> Result<()> { +pub async fn link( + base: &mut CommandBase, + modify_gitignore: bool, + target: LinkTarget, +) -> Result<()> { let homedir_path = home_dir().ok_or_else(|| anyhow!("could not find home directory."))?; let homedir = homedir_path.to_string_lossy(); - println!( - ">>> Remote Caching - -{} - For more info, see {} - ", - REMOTE_CACHING_INFO, - base.ui.apply(UNDERLINE.apply_to(REMOTE_CACHING_URL)) - ); - let repo_root_with_tilde = base.repo_root.to_string_lossy().replacen(&*homedir, "~", 1); - - if !should_link(base, &repo_root_with_tilde)? { - return Err(anyhow!("canceled")); - } - let api_client = base.api_client()?; let token = base.user_config()?.token().ok_or_else(|| { anyhow!( @@ -127,56 +124,125 @@ pub async fn link(base: &mut CommandBase, modify_gitignore: bool) -> Result<()> ) })?; - let teams_response = api_client - .get_teams(token) - .await - .context("could not get team information")?; + match target { + LinkTarget::RemoteCache => { + println!( + ">>> Remote Caching - let user_response = api_client - .get_user(token) - .await - .context("could not get user information")?; + {} + For more info, see {} + ", + REMOTE_CACHING_INFO, + base.ui.apply(UNDERLINE.apply_to(REMOTE_CACHING_URL)) + ); - let user_display_name = user_response - .user - .name - .as_deref() - .unwrap_or(user_response.user.username.as_str()); + if !should_link_remote_cache(base, &repo_root_with_tilde)? { + return Err(anyhow!("canceled")); + } - let selected_team = select_team(base, &teams_response.teams, user_display_name)?; + let user_response = api_client + .get_user(token) + .await + .context("could not get user information")?; - let team_id = match selected_team { - SelectedTeam::User => user_response.user.id.as_str(), - SelectedTeam::Team(team) => team.id.as_str(), - }; + let user_display_name = user_response + .user + .name + .as_deref() + .unwrap_or(user_response.user.username.as_str()); - verify_caching_enabled(&api_client, team_id, token, Some(selected_team.clone())).await?; + let teams_response = api_client + .get_teams(token) + .await + .context("could not get team information")?; - fs::create_dir_all(base.repo_root.join(".turbo")) - .context("could not create .turbo directory")?; - base.repo_config_mut()? - .set_team_id(Some(team_id.to_string()))?; + let selected_team = select_team(base, &teams_response.teams, user_display_name)?; - let chosen_team_name = match selected_team { - SelectedTeam::User => user_display_name, - SelectedTeam::Team(team) => team.name.as_str(), - }; + let team_id = match selected_team { + SelectedTeam::User => user_response.user.id.as_str(), + SelectedTeam::Team(team) => team.id.as_str(), + }; - if modify_gitignore { - add_turbo_to_gitignore(base)?; - } + verify_caching_enabled(&api_client, team_id, token, Some(selected_team.clone())) + .await?; - println!( - " -{} Turborepo CLI authorized for {} + fs::create_dir_all(base.repo_root.join(".turbo")) + .context("could not create .turbo directory")?; + base.repo_config_mut()? + .set_team_id(Some(team_id.to_string()))?; -{} - ", - base.ui.rainbow(">>> Success!"), - base.ui.apply(BOLD.apply_to(chosen_team_name)), - GREY.apply_to("To disable Remote Caching, run `npx turbo unlink`") - ); - Ok(()) + let chosen_team_name = match selected_team { + SelectedTeam::User => user_display_name, + SelectedTeam::Team(team) => team.name.as_str(), + }; + + if modify_gitignore { + add_turbo_to_gitignore(base)?; + } + + println!( + " + {} Turborepo CLI authorized for {} + + {} + ", + base.ui.rainbow(">>> Success!"), + base.ui.apply(BOLD.apply_to(chosen_team_name)), + GREY.apply_to("To disable Remote Caching, run `npx turbo unlink`") + ); + return Ok(()); + } + LinkTarget::Spaces => { + println!( + ">>> Vercel Spaces (Beta) + + For more info, see {} + ", + base.ui.apply(UNDERLINE.apply_to(SPACES_URL)) + ); + + if !should_link_spaces(base, &repo_root_with_tilde)? { + return Err(anyhow!("canceled")); + } + + let spaces_response = api_client + .get_spaces(token, base.repo_config()?.team_id()) + .await + .context("could not get spaces information")?; + + let selected_space = select_space(base, &spaces_response.spaces)?; + + // print result from selected_space + let space = match selected_space { + SelectedSpace::Space(space) => space, + }; + + add_space_id_to_turbo_json(base, &space.id).map_err(|err| { + return anyhow!( + "Could not persist selected space ({}) to `experimentalSpaces.id` in \ + turbo.json {}", + space.id, + err + ); + })?; + + println!( + " + {} {} linked to {} + + {} + ", + base.ui.rainbow(">>> Success!"), + base.ui.apply(BOLD.apply_to(&repo_root_with_tilde)), + base.ui.apply(BOLD.apply_to(&space.name)), + GREY.apply_to( + "To remove Spaces integration, run `npx turbo unlink --target spaces`" + ) + ); + + return Ok(()); + } + } } fn should_enable_caching() -> Result { @@ -241,12 +307,52 @@ fn select_team<'a>( } #[cfg(test)] -fn should_link(_: &CommandBase, _: &str) -> Result { +fn select_space<'a>(_: &CommandBase, spaces: &'a [Space]) -> Result> { + let mut rng = rand::thread_rng(); + let idx = rng.gen_range(0..spaces.len()); + Ok(SelectedSpace::Space(&spaces[idx])) +} + +#[cfg(not(test))] +fn select_space<'a>(base: &CommandBase, spaces: &'a [Space]) -> Result> { + let space_names = spaces + .iter() + .map(|space| space.name.as_str()) + .collect::>(); + + let theme = ColorfulTheme { + active_item_style: Style::new().cyan().bold(), + active_item_prefix: Style::new().cyan().bold().apply_to(">".to_string()), + prompt_prefix: Style::new().dim().bold().apply_to("?".to_string()), + values_style: Style::new().cyan(), + ..ColorfulTheme::default() + }; + + let prompt = format!( + "{}\n {}", + base.ui.apply( + BOLD.apply_to("Which Vercel space do you want associated with this Turborepo?",) + ), + base.ui + .apply(CYAN.apply_to("[Use arrows to move, type to filter]")) + ); + + let selection = FuzzySelect::with_theme(&theme) + .with_prompt(prompt) + .items(&space_names) + .default(0) + .interact()?; + + Ok(SelectedSpace::Space(&spaces[selection])) +} + +#[cfg(test)] +fn should_link_remote_cache(_: &CommandBase, _: &str) -> Result { Ok(true) } #[cfg(not(test))] -fn should_link(base: &CommandBase, location: &str) -> Result { +fn should_link_remote_cache(base: &CommandBase, location: &str) -> Result { let prompt = format!( "{}{} {}", base.ui.apply(BOLD.apply_to(GREY.apply_to("? "))), @@ -258,6 +364,24 @@ fn should_link(base: &CommandBase, location: &str) -> Result { Ok(Confirm::new().with_prompt(prompt).interact()?) } +#[cfg(test)] +fn should_link_spaces(_: &CommandBase, _: &str) -> Result { + Ok(true) +} + +#[cfg(not(test))] +fn should_link_spaces(base: &CommandBase, location: &str) -> Result { + let prompt = format!( + "{}{} {} {}", + base.ui.apply(BOLD.apply_to(GREY.apply_to("? "))), + base.ui.apply(BOLD.apply_to("Would you like to link")), + base.ui.apply(BOLD.apply_to(CYAN.apply_to(location))), + base.ui.apply(BOLD.apply_to("to Vercel Spaces")), + ); + + Ok(Confirm::new().with_prompt(prompt).interact()?) +} + fn enable_caching(url: &str) -> Result<()> { webbrowser::open(url).with_context(|| { format!( @@ -296,23 +420,53 @@ fn add_turbo_to_gitignore(base: &CommandBase) -> Result<()> { Ok(()) } +fn add_space_id_to_turbo_json(base: &CommandBase, space_id: &str) -> Result<()> { + let turbo_json_path = base.repo_root.join("turbo.json"); + + if !turbo_json_path.exists() { + return Err(anyhow!("turbo.json not found.")); + } + + let turbo_json_file = File::open(&turbo_json_path)?; + let mut turbo_json: TurboJson = serde_json::from_reader(turbo_json_file)?; + match turbo_json.experimental_spaces { + Some(mut spaces_config) => { + spaces_config.id = Some(space_id.to_string()); + turbo_json.experimental_spaces = Some(spaces_config); + } + None => { + turbo_json.experimental_spaces = Some(SpacesJson { + id: Some(space_id.to_string()), + other: None, + }); + } + } + + // write turbo_json back to file + let config_file = File::create(&turbo_json_path)?; + serde_json::to_writer_pretty(&config_file, &turbo_json)?; + + Ok(()) +} + #[cfg(test)] mod test { use std::fs; - use tempfile::NamedTempFile; + use tempfile::{NamedTempFile, TempDir}; use tokio::sync::OnceCell; use vercel_api_mock::start_test_server; use crate::{ + cli::LinkTarget, commands::{link, CommandBase}, - config::{ClientConfigLoader, RepoConfigLoader, UserConfigLoader}, + config::{ClientConfigLoader, RepoConfigLoader, TurboJson, UserConfigLoader}, ui::UI, Args, }; #[tokio::test] - async fn test_link() { + async fn test_link_remote_cache() { let user_config_file = NamedTempFile::new().unwrap(); fs::write(user_config_file.path(), r#"{ "token": "hello" }"#).unwrap(); let repo_config_file = NamedTempFile::new().unwrap(); @@ -345,7 +499,9 @@ mod test { version: "", }; - link::link(&mut base, false).await.unwrap(); + link::link(&mut base, false, LinkTarget::RemoteCache) + .await + .unwrap(); handle.abort(); let team_id = base.repo_config().unwrap().team_id(); @@ -354,4 +510,64 @@ mod test { || team_id == Some(vercel_api_mock::EXPECTED_TEAM_ID) ); } + + #[tokio::test] + async fn test_link_spaces() { + // user config + let user_config_file = NamedTempFile::new().unwrap(); + fs::write(user_config_file.path(), r#"{ "token": "hello" }"#).unwrap(); + + // repo config + let repo_config_file = NamedTempFile::new().unwrap(); + fs::write( + repo_config_file.path(), + r#"{ "apiurl": "http://localhost:3000" }"#, + ) + .unwrap(); + + let port = port_scanner::request_open_port().unwrap(); + let handle = tokio::spawn(start_test_server(port)); + let mut base = CommandBase { + repo_root: TempDir::new().unwrap().into_path(), + ui: UI::new(false), + client_config: OnceCell::from(ClientConfigLoader::new().load().unwrap()), + user_config: OnceCell::from( + UserConfigLoader::new(user_config_file.path().to_path_buf()) + .with_token(Some("token".to_string())) + .load() + .unwrap(), + ), + repo_config: OnceCell::from( + RepoConfigLoader::new(repo_config_file.path().to_path_buf()) + .with_api(Some(format!("http://localhost:{}", port))) + .with_login(Some(format!("http://localhost:{}", port))) + .load() + .unwrap(), + ), + args: Args::default(), + version: "", + }; + + // turbo config + let turbo_json_file = base.repo_root.join("turbo.json"); + fs::write( + turbo_json_file.as_path(), + r#"{ "globalEnv": [], "pipeline": {} }"#, + ) + .unwrap(); + + link::link(&mut base, false, LinkTarget::Spaces) + .await + .unwrap(); + + handle.abort(); + + // verify space id is added to turbo.json + let turbo_json_file = fs::File::open(&turbo_json_file).unwrap(); + let turbo_json: TurboJson = serde_json::from_reader(turbo_json_file).unwrap(); + assert_eq!( + turbo_json.experimental_spaces.unwrap().id.unwrap(), + vercel_api_mock::EXPECTED_SPACE_ID + ); + } } diff --git a/crates/turborepo-lib/src/commands/unlink.rs b/crates/turborepo-lib/src/commands/unlink.rs index 115ee0a3fadf3..f9ab4732ee37a 100644 --- a/crates/turborepo-lib/src/commands/unlink.rs +++ b/crates/turborepo-lib/src/commands/unlink.rs @@ -1,8 +1,15 @@ -use anyhow::{Context, Result}; +use std::fs::File; -use crate::{commands::CommandBase, ui::GREY}; +use anyhow::{anyhow, Context, Result}; -pub fn unlink(base: &mut CommandBase) -> Result<()> { +use crate::{cli::LinkTarget, commands::CommandBase, config::TurboJson, ui::GREY}; + +enum UnlinkSpacesResult { + Unlinked, + NoSpacesFound, +} + +fn unlink_remote_caching(base: &mut CommandBase) -> Result<()> { base.delete_repo_config_file() .context("could not unlink. Something went wrong")?; @@ -13,3 +20,58 @@ pub fn unlink(base: &mut CommandBase) -> Result<()> { Ok(()) } + +fn unlink_spaces(base: &mut CommandBase) -> Result<()> { + let result = + remove_spaces_from_turbo_json(base).context("could not unlink. Something went wrong")?; + + match result { + UnlinkSpacesResult::Unlinked => { + println!("{}", base.ui.apply(GREY.apply_to("> Unlinked Spaces"))); + } + UnlinkSpacesResult::NoSpacesFound => { + println!( + "{}", + base.ui.apply(GREY.apply_to("> No Spaces config found")) + ); + } + } + + Ok(()) +} + +pub fn unlink(base: &mut CommandBase, target: LinkTarget) -> Result<()> { + match target { + LinkTarget::RemoteCache => { + unlink_remote_caching(base)?; + } + LinkTarget::Spaces => { + unlink_spaces(base)?; + } + } + Ok(()) +} + +fn remove_spaces_from_turbo_json(base: &CommandBase) -> Result { + let turbo_json_path = base.repo_root.join("turbo.json"); + + let turbo_json_file = File::open(&turbo_json_path).context("unable to open turbo.json file")?; + let mut turbo_json: TurboJson = serde_json::from_reader(turbo_json_file)?; + let has_spaces_id = turbo_json + .experimental_spaces + .unwrap_or_default() + .id + .is_some(); + // remove the spaces config + // TODO: in the future unlink should possible just remove the spaces id + turbo_json.experimental_spaces = None; + + // write turbo_json back to file + let config_file = File::create(&turbo_json_path)?; + serde_json::to_writer_pretty(&config_file, &turbo_json)?; + + match has_spaces_id { + true => Ok(UnlinkSpacesResult::Unlinked), + false => Ok(UnlinkSpacesResult::NoSpacesFound), + } +} diff --git a/crates/turborepo-lib/src/config/mod.rs b/crates/turborepo-lib/src/config/mod.rs index e8fa52c2bc015..a1c229358700c 100644 --- a/crates/turborepo-lib/src/config/mod.rs +++ b/crates/turborepo-lib/src/config/mod.rs @@ -1,6 +1,7 @@ mod client; mod env; mod repo; +mod turbo; mod user; use std::path::{Path, PathBuf}; @@ -18,6 +19,7 @@ use dirs_next::data_local_dir as config_dir; pub use env::MappedEnvironment; pub use repo::{get_repo_config_path, RepoConfig, RepoConfigLoader}; use serde::Serialize; +pub use turbo::{SpacesJson, TurboJson}; pub use user::{UserConfig, UserConfigLoader}; pub fn default_user_config_path() -> Result { diff --git a/crates/turborepo-lib/src/config/turbo.rs b/crates/turborepo-lib/src/config/turbo.rs new file mode 100644 index 0000000000000..ab882dddb6f6a --- /dev/null +++ b/crates/turborepo-lib/src/config/turbo.rs @@ -0,0 +1,18 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct SpacesJson { + pub id: Option, + #[serde(flatten)] + pub other: Option, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TurboJson { + #[serde(flatten)] + other: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental_spaces: Option, +} diff --git a/crates/turborepo-vercel-api-mock/src/lib.rs b/crates/turborepo-vercel-api-mock/src/lib.rs index 799b610f6727b..af8d2749b38fb 100644 --- a/crates/turborepo-vercel-api-mock/src/lib.rs +++ b/crates/turborepo-vercel-api-mock/src/lib.rs @@ -3,8 +3,8 @@ use std::net::SocketAddr; use anyhow::Result; use axum::{routing::get, Json, Router}; use turborepo_api_client::{ - CachingStatus, CachingStatusResponse, Membership, Role, Team, TeamsResponse, User, - UserResponse, VerificationResponse, + CachingStatus, CachingStatusResponse, Membership, Role, Space, SpacesResponse, Team, + TeamsResponse, User, UserResponse, VerificationResponse, }; pub const EXPECTED_TOKEN: &str = "expected_token"; @@ -18,6 +18,9 @@ pub const EXPECTED_TEAM_SLUG: &str = "expected_team_slug"; pub const EXPECTED_TEAM_NAME: &str = "expected_team_name"; pub const EXPECTED_TEAM_CREATED_AT: u64 = 0; +pub const EXPECTED_SPACE_ID: &str = "expected_space_id"; +pub const EXPECTED_SPACE_NAME: &str = "expected_space_name"; + pub const EXPECTED_SSO_TEAM_ID: &str = "expected_sso_team_id"; pub const EXPECTED_SSO_TEAM_SLUG: &str = "expected_sso_team_slug"; @@ -52,6 +55,17 @@ pub async fn start_test_server(port: u16) -> Result<()> { }) }), ) + .route( + "/v0/spaces", + get(|| async move { + Json(SpacesResponse { + spaces: vec![Space { + id: EXPECTED_SPACE_ID.to_string(), + name: EXPECTED_SPACE_NAME.to_string(), + }], + }) + }), + ) .route( "/v8/artifacts/status", get(|| async {