Skip to content

Commit d2cd479

Browse files
art049claude
andcommitted
feat(cli): add profile system with versioned config
Introduce named profiles in the CodSpeed CLI config. Each profile carries its own auth token plus optional api-url/upload-url overrides. Profile selection at runtime follows: `--profile` / `CODSPEED_PROFILE` env var, then a per-shell-session selection registered by `codspeed profile use` (parent-PID keyed file under `$XDG_RUNTIME_DIR/codspeed_profile`, mirroring how `codspeed use <mode>` works), then the built-in `default` profile. There is no globally persisted default profile. The on-disk config gains a `version: 1` schema field. A private `RawConfig` deserialisation type and a `migrate` function are the only place legacy YAML shapes are mentioned; when migration is needed the canonical form is rewritten to disk immediately so the rest of the app only ever sees the clean shape. Today this folds the legacy top-level `auth.token` into `profiles.default`. `CodSpeedConfig` is split: a private `PersistedConfig` is the on-disk shape (version + profiles), and `CodSpeedConfig` wraps it with the runtime-resolved auth/URLs/selected_profile. `persist` writes only the persisted half, so runtime overrides (e.g. `CODSPEED_OAUTH_TOKEN`) can never leak to disk. The parent-PID shell-session machinery used by `codspeed use <mode>` is extracted into a generic `shell_session_store` module so profile and runner-mode share the same implementation. `src/runner_mode/` is flattened into `src/runner_mode.rs` now that the sub-file is gone. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent ba2714d commit d2cd479

15 files changed

Lines changed: 772 additions & 185 deletions

File tree

src/cli/auth.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,13 @@ pub async fn run(
3939
args: AuthArgs,
4040
api_client: &CodSpeedAPIClient,
4141
config_name: Option<&str>,
42+
config: CodSpeedConfig,
4243
) -> Result<()> {
4344
match args.command {
44-
AuthCommands::Login { with_token } => login(api_client, config_name, with_token).await?,
45-
AuthCommands::Status => status(api_client).await?,
45+
AuthCommands::Login { with_token } => {
46+
login(api_client, config_name, config, with_token).await?
47+
}
48+
AuthCommands::Status => status(api_client, &config).await?,
4649
}
4750
Ok(())
4851
}
@@ -52,6 +55,7 @@ const LOGIN_SESSION_MAX_DURATION: Duration = Duration::from_secs(60 * 5); // 5 m
5255
async fn login(
5356
api_client: &CodSpeedAPIClient,
5457
config_name: Option<&str>,
58+
mut config: CodSpeedConfig,
5559
with_token: bool,
5660
) -> Result<()> {
5761
debug!("Login to CodSpeed");
@@ -118,12 +122,15 @@ async fn login(
118122
SessionError::Other(err) => err,
119123
})?;
120124

121-
let mut config = CodSpeedConfig::load_with_override(config_name, None)?;
122-
config.auth.token = Some(token);
125+
let selected = config.selected_profile_name().to_owned();
126+
config.profile_mut(&selected).auth.token = Some(token);
123127
config.persist(config_name)?;
124128
debug!("Token saved to configuration file");
125129

126-
info!("Login successful, your are now authenticated on CodSpeed");
130+
info!(
131+
"Login successful, you are now authenticated on CodSpeed (profile: {})",
132+
config.selected_profile_name()
133+
);
127134

128135
Ok(())
129136
}
@@ -147,8 +154,7 @@ struct AuthStatus {
147154
detected_repository: Option<(ParsedRepository, Option<RepositoryOverviewPayload>)>,
148155
}
149156

150-
pub async fn status(api_client: &CodSpeedAPIClient) -> Result<()> {
151-
let config = CodSpeedConfig::load_with_override(None, None)?;
157+
pub async fn status(api_client: &CodSpeedAPIClient, config: &CodSpeedConfig) -> Result<()> {
152158
let has_token = config.auth.token.is_some();
153159
let parsed = detect_repository();
154160

@@ -161,7 +167,11 @@ pub async fn status(api_client: &CodSpeedAPIClient) -> Result<()> {
161167
}
162168
};
163169

164-
info!("{}", style("Authentication").bold());
170+
info!(
171+
"{} (profile: {})",
172+
style("Authentication").bold(),
173+
config.selected_profile_name()
174+
);
165175
print_authentication_section(has_token, auth_status.session.as_ref());
166176
info!("");
167177

src/cli/exec/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::ExecAndRunSharedArgs;
22
use crate::api_client::CodSpeedAPIClient;
33
use crate::executor;
4-
use crate::executor::config::{self, OrchestratorConfig, RepositoryOverride};
4+
use crate::executor::config::{OrchestratorConfig, RepositoryOverride};
55
use crate::instruments::Instruments;
66
use crate::prelude::*;
77
use crate::project_config::ProjectConfig;
@@ -60,7 +60,7 @@ fn build_orchestrator_config(
6060
let raw_upload_url = args
6161
.shared
6262
.upload_url
63-
.unwrap_or_else(|| config::DEFAULT_UPLOAD_URL.into());
63+
.unwrap_or_else(|| crate::config::DEFAULT_UPLOAD_URL.into());
6464
let upload_url = Url::parse(&raw_upload_url)
6565
.map_err(|e| anyhow!("Invalid upload URL: {raw_upload_url}, {e}"))?;
6666

src/cli/mod.rs

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
mod auth;
22
pub(crate) mod exec;
33
pub(crate) mod experimental;
4+
mod profile;
45
pub(crate) mod run;
56
pub(crate) mod samply;
67
mod setup;
@@ -41,25 +42,23 @@ fn create_styles() -> Styles {
4142
#[command(version, about = "The CodSpeed CLI tool", styles = create_styles())]
4243
pub struct Cli {
4344
/// The URL of the CodSpeed GraphQL API
44-
#[arg(
45-
long,
46-
env = "CODSPEED_API_URL",
47-
global = true,
48-
hide = true,
49-
default_value = "https://gql.codspeed.io/"
50-
)]
51-
pub api_url: String,
45+
#[arg(long, env = "CODSPEED_API_URL", global = true, hide = true)]
46+
pub api_url: Option<String>,
5247

5348
/// The OAuth token to use for all requests
5449
#[arg(long, env = "CODSPEED_OAUTH_TOKEN", global = true, hide = true)]
5550
pub oauth_token: Option<String>,
5651

57-
/// The configuration name to use
58-
/// If provided, the configuration will be loaded from ~/.config/codspeed/{config-name}.yaml
59-
/// Otherwise, loads from ~/.config/codspeed/config.yaml
60-
#[arg(long, env = "CODSPEED_CONFIG_NAME", global = true)]
52+
/// [deprecated] Load configuration from `~/.config/codspeed/{config-name}.yaml`
53+
/// instead of the default `config.yaml`. Prefer `--profile` instead.
54+
#[arg(long, env = "CODSPEED_CONFIG_NAME", global = true, hide = true)]
55+
#[deprecated(note = "use `--profile` / `CODSPEED_PROFILE` instead")]
6156
pub config_name: Option<String>,
6257

58+
/// The CodSpeed profile to use
59+
#[arg(long, env = "CODSPEED_PROFILE", global = true)]
60+
pub profile: Option<String>,
61+
6362
/// Path to project configuration file (codspeed.yaml)
6463
/// If provided, loads config from this path. Otherwise, searches for config files
6564
/// in the current directory and upward to the git root.
@@ -88,6 +87,8 @@ enum Commands {
8887
Exec(Box<exec::ExecArgs>),
8988
/// Manage the CLI authentication state
9089
Auth(auth::AuthArgs),
90+
/// Manage CodSpeed profiles
91+
Profile(profile::ProfileArgs),
9192
/// Pre-install the codspeed executors
9293
Setup(setup::SetupArgs),
9394
/// Show the overall status of CodSpeed (authentication, tools, system)
@@ -130,7 +131,8 @@ impl InternalCommands {
130131

131132
pub async fn run() -> Result<()> {
132133
let cli = Cli::parse();
133-
let mut api_client = build_api_client(&cli)?;
134+
let codspeed_config = load_config(&cli)?;
135+
let mut api_client = build_api_client(&cli, &codspeed_config);
134136

135137
// Discover project configuration file
136138
let discovered_config = DiscoveredProjectConfig::discover_and_load(
@@ -154,28 +156,45 @@ pub async fn run() -> Result<()> {
154156

155157
match cli.command {
156158
Commands::Run(args) => {
159+
let mut args = *args;
160+
args.shared
161+
.upload_url
162+
.get_or_insert_with(|| codspeed_config.upload_url.clone());
157163
args.shared.experimental.warn_if_active();
158164
run::run(
159-
*args,
165+
args,
160166
&mut api_client,
161167
discovered_config.as_ref(),
162168
setup_cache_dir,
163169
)
164170
.await?
165171
}
166172
Commands::Exec(args) => {
173+
let mut args = *args;
174+
args.shared
175+
.upload_url
176+
.get_or_insert_with(|| codspeed_config.upload_url.clone());
167177
args.shared.experimental.warn_if_active();
168178
exec::run(
169-
*args,
179+
args,
170180
&mut api_client,
171181
discovered_config.as_ref().map(|d| &d.config),
172182
setup_cache_dir,
173183
)
174184
.await?
175185
}
176-
Commands::Auth(args) => auth::run(args, &api_client, cli.config_name.as_deref()).await?,
186+
Commands::Auth(args) => {
187+
#[allow(deprecated)]
188+
let config_name = cli.config_name.as_deref();
189+
auth::run(args, &api_client, config_name, codspeed_config).await?
190+
}
191+
Commands::Profile(args) => {
192+
#[allow(deprecated)]
193+
let config_name = cli.config_name.as_deref();
194+
profile::run(args, config_name, cli.profile.as_deref())?
195+
}
177196
Commands::Setup(args) => setup::run(args, setup_cache_dir).await?,
178-
Commands::Status => status::run(&api_client).await?,
197+
Commands::Status => status::run(&api_client, &codspeed_config).await?,
179198
Commands::Use(args) => use_mode::run(args)?,
180199
Commands::Show => show::run()?,
181200
Commands::Update => update::run().await?,
@@ -184,6 +203,32 @@ pub async fn run() -> Result<()> {
184203
Ok(())
185204
}
186205

206+
/// Load the CodSpeed config for this invocation, resolving the active
207+
/// profile (CLI `--profile` / `CODSPEED_PROFILE` / shell-session / built-in
208+
/// `default`) and applying CLI overrides for the OAuth token and api URL.
209+
///
210+
/// `auth` and `profile` subcommands are allowed to run against a config
211+
/// where the selected profile does not yet exist (e.g. first-time setup).
212+
fn load_config(cli: &Cli) -> Result<CodSpeedConfig> {
213+
// The field carries a `#[deprecated]` marker but we still need to
214+
// honour it during the deprecation window.
215+
#[allow(deprecated)]
216+
let config_name = cli.config_name.as_deref();
217+
if config_name.is_some() {
218+
warn!(
219+
"`--config-name` / `CODSPEED_CONFIG_NAME` is deprecated; use `--profile` / `CODSPEED_PROFILE` instead."
220+
);
221+
}
222+
CodSpeedConfig::load_with_profile(
223+
config_name,
224+
cli.profile.as_deref(),
225+
cli.oauth_token.as_deref(),
226+
cli.api_url.as_deref(),
227+
None,
228+
matches!(&cli.command, Commands::Auth(_) | Commands::Profile(_)),
229+
)
230+
}
231+
187232
/// Build the api client for this invocation, resolving the auth token
188233
/// from the most specific source available. This is the single source
189234
/// of truth for token resolution; the result lives on the returned
@@ -193,28 +238,16 @@ pub async fn run() -> Result<()> {
193238
/// Priority (most specific first):
194239
/// 1. `--token` / `CODSPEED_TOKEN` — run/exec-level override
195240
/// 2. `--oauth-token` / `CODSPEED_OAUTH_TOKEN` and the persisted CLI
196-
/// token — both live on disk and are loaded together by
197-
/// [`CodSpeedConfig::load_with_override`].
198-
///
199-
/// The CLI config file is only read when no explicit token was passed,
200-
/// so an invocation like `codspeed run --token <X>` never touches the
201-
/// user's `~/.config/codspeed/`.
202-
fn build_api_client(cli: &Cli) -> Result<CodSpeedAPIClient> {
241+
/// token from the selected profile.
242+
fn build_api_client(cli: &Cli, config: &CodSpeedConfig) -> CodSpeedAPIClient {
203243
let explicit = match &cli.command {
204244
Commands::Run(args) => args.shared.token.clone(),
205245
Commands::Exec(args) => args.shared.token.clone(),
206246
_ => None,
207247
};
208248
let token = match explicit {
209249
Some(token) => Some(token),
210-
None => {
211-
CodSpeedConfig::load_with_override(
212-
cli.config_name.as_deref(),
213-
cli.oauth_token.as_deref(),
214-
)?
215-
.auth
216-
.token
217-
}
250+
None => config.auth.token.clone(),
218251
};
219-
Ok(CodSpeedAPIClient::new(token, cli.api_url.clone()))
252+
CodSpeedAPIClient::new(token, config.api_url.clone())
220253
}

src/cli/profile.rs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
use crate::config::{CodSpeedConfig, load_shell_session_profile, register_shell_session_profile};
2+
use crate::prelude::*;
3+
use clap::{Args, Subcommand};
4+
use console::style;
5+
6+
#[derive(Debug, Args)]
7+
pub struct ProfileArgs {
8+
#[command(subcommand)]
9+
command: ProfileCommands,
10+
}
11+
12+
#[derive(Debug, Subcommand)]
13+
enum ProfileCommands {
14+
/// List configured profiles
15+
List,
16+
/// Show a profile
17+
Show {
18+
/// Profile name. Defaults to the configured default profile.
19+
name: Option<String>,
20+
},
21+
/// Set profile URLs, creating the profile if it does not exist
22+
Set {
23+
/// Profile name
24+
name: String,
25+
/// The URL of the CodSpeed GraphQL API
26+
#[arg(long)]
27+
api_url: Option<String>,
28+
/// The URL to use for uploading results
29+
#[arg(long)]
30+
upload_url: Option<String>,
31+
},
32+
/// Set the active profile for the current shell session
33+
Use {
34+
/// Profile name
35+
name: String,
36+
},
37+
}
38+
39+
pub fn run(
40+
args: ProfileArgs,
41+
config_name: Option<&str>,
42+
selected_profile: Option<&str>,
43+
) -> Result<()> {
44+
match args.command {
45+
ProfileCommands::List => list(config_name),
46+
ProfileCommands::Show { name } => show(config_name, name.as_deref().or(selected_profile)),
47+
ProfileCommands::Set {
48+
name,
49+
api_url,
50+
upload_url,
51+
} => set(config_name, &name, api_url, upload_url),
52+
ProfileCommands::Use { name } => use_profile(config_name, &name),
53+
}
54+
}
55+
56+
fn list(config_name: Option<&str>) -> Result<()> {
57+
let config = CodSpeedConfig::load_with_override(config_name, None)?;
58+
let active = load_shell_session_profile()?;
59+
60+
info!("{}", style("Profiles").bold());
61+
for name in config.profiles().keys() {
62+
let marker = if Some(name) == active.as_ref() {
63+
"*"
64+
} else {
65+
" "
66+
};
67+
info!(" {marker} {name}");
68+
}
69+
70+
Ok(())
71+
}
72+
73+
fn show(config_name: Option<&str>, profile_name: Option<&str>) -> Result<()> {
74+
let config =
75+
CodSpeedConfig::load_with_profile(config_name, profile_name, None, None, None, false)?;
76+
77+
info!(
78+
"{} ({})",
79+
style("Profile").bold(),
80+
config.selected_profile_name()
81+
);
82+
info!(" api url: {}", config.api_url);
83+
info!(" upload url: {}", config.upload_url);
84+
info!(
85+
" authenticated: {}",
86+
if config.auth.token.is_some() {
87+
"yes"
88+
} else {
89+
"no"
90+
}
91+
);
92+
93+
Ok(())
94+
}
95+
96+
fn set(
97+
config_name: Option<&str>,
98+
profile_name: &str,
99+
api_url: Option<String>,
100+
upload_url: Option<String>,
101+
) -> Result<()> {
102+
let mut config = CodSpeedConfig::load_with_override(config_name, None)?;
103+
let profile = config.profile_mut(profile_name);
104+
105+
if let Some(api_url) = api_url {
106+
profile.api_url = Some(api_url);
107+
}
108+
if let Some(upload_url) = upload_url {
109+
profile.upload_url = Some(upload_url);
110+
}
111+
112+
config.persist(config_name)?;
113+
info!("Profile `{profile_name}` saved");
114+
115+
Ok(())
116+
}
117+
118+
fn use_profile(config_name: Option<&str>, profile_name: &str) -> Result<()> {
119+
let config = CodSpeedConfig::load_with_override(config_name, None)?;
120+
ensure!(
121+
config.profile(profile_name).is_some(),
122+
"CodSpeed profile `{profile_name}` does not exist. Run `codspeed profile set {profile_name}` to create it."
123+
);
124+
125+
register_shell_session_profile(profile_name)?;
126+
info!("Active CodSpeed profile set to `{profile_name}` for this shell session");
127+
128+
Ok(())
129+
}

0 commit comments

Comments
 (0)