Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ RW_VERSION=1.2.3 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Roundin
| Flag | Short | Description |
|----------------|-------|------------------------------------------------------------------------|
| `--profile` | `-p` | Named profile to use |
| `--auth` | `-A` | Use stored credentials from another profile (overrides only the auth) |
| `--auth` | `-A` | Use stored credentials from another profile (overrides only the auth) |
| `--stage` | `-g` | Stage to target, overriding the profile's configured stage |
| `--config-dir` | `-c` | Configuration directory |
| `--json` | | Change all output to JSON |

Expand All @@ -48,6 +49,31 @@ rw config profile set mercy -g sandbox # Update stage for a profile
rw config profile auth mercy # Save basic auth credentials for a profile (see below)
```

#### Overriding the stage

A profile pins an organization to one stage. Override it for a single invocation
with `--stage` (`-g`):

```sh
rw -g local workspaces list # Target localhost with the active profile
rw clinicians show me -g sandbox # The flag is global, so it works here too
```

The override changes only which host is called. Credentials still come from the
profile (or from `--auth`), and token refresh still uses the profile's stored
stage. Because `prod` and `sandbox` authenticate against a different tenant than
`qa`, `dev`, and `local`, an override that crosses that boundary sends a token the
target rejects, producing a 401 — overrides within a group work.

The exception is `rw config profile add` and `rw config profile set`, which
each have their own local `-g` / `--stage` flag for the stage to store in the
profile. Because it shares the arg id with the global flag, `-g`/`--stage`
anywhere on those two commands sets the profile's stored stage — it does not
act as a per-invocation override there.

`--stage` cannot be combined with `rw auth login` or `rw auth logout`, which
create and remove a specific profile's credentials.

#### Adding a profile

```sh
Expand Down
1 change: 1 addition & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
superpowers/
7 changes: 7 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ By default, `rw` stores these files under `~/.config/rw/`.
}
```

A profile's `stage` is the default for every invocation. Pass `--stage` (`-g`) to
target a different stage for one command without editing the profile; credentials
are still read from `auth/{profile}.json` either way. The exception is `rw config
profile add` and `rw config profile set`, whose own local `-g` / `--stage` flag
shares the same arg id — there, `-g`/`--stage` sets and persists the profile's
stored stage instead of overriding it for one command.

### `auth/{profile}.json`

Bearer token (written after `rw auth login`):
Expand Down
3 changes: 2 additions & 1 deletion skills/rw-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ These flags work on every command:
|----------------|-------|------------------------------------------------------------------------|
| `--profile` | `-p` | Named profile to use |
| `--auth` | `-A` | Use credentials from another profile (overrides only the auth source) |
| `--stage` | `-g` | Stage to target, overriding the profile's configured stage |
| `--config-dir` | `-c` | Configuration directory path |
| `--json` | | Output results as JSON |

All commands that call the API require a configured profile. Ensure that a profile has been set using `rw config profile show` or pass `--profile` on each invocation.
All commands that call the API require a configured profile. Ensure that a profile has been set using `rw config profile show` or pass `--profile` on each invocation. Pass `--stage` to target a different stage than the profile's configured one; it cannot be combined with `rw auth login` or `rw auth logout`. It also does not act as an override on `rw config profile add` or `rw config profile set` — those commands have their own local `-g`/`--stage` flag sharing the same arg id, so `-g`/`--stage` anywhere on those commands instead sets and persists the profile's stored stage. Do not use `--stage` expecting a one-off override on those two commands; it will silently mutate stored config.

## Commands

Expand Down
76 changes: 76 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ pub struct Cli {
#[arg(short = 'A', long, value_parser = validate_slug, global = true)]
pub auth: Option<String>,

/// Stage to target for this invocation, overriding the profile's configured stage.
#[arg(short = 'g', long, global = true)]
pub stage: Option<Stage>,

/// Output results as JSON.
#[arg(long, global = true)]
pub json: bool,
Expand Down Expand Up @@ -545,4 +549,76 @@ mod tests {
let cli = Cli::try_parse_from(["rw", "auth", "status", "-A", "mercy"]).unwrap();
assert_eq!(cli.auth.as_deref(), Some("mercy"));
}

#[test]
fn test_stage_flag_long() {
use clap::Parser;
let cli = Cli::try_parse_from(["rw", "--stage", "dev", "auth", "status"]).unwrap();
assert_eq!(cli.stage, Some(Stage::Dev));
}

#[test]
fn test_stage_flag_short() {
use clap::Parser;
let cli = Cli::try_parse_from(["rw", "-g", "local", "auth", "status"]).unwrap();
assert_eq!(cli.stage, Some(Stage::Local));
}

#[test]
fn test_stage_flag_default_none() {
use clap::Parser;
let cli = Cli::try_parse_from(["rw", "auth", "status"]).unwrap();
assert!(cli.stage.is_none());
}

#[test]
fn test_stage_flag_rejects_unknown_stage() {
use clap::Parser;
let err = Cli::try_parse_from(["rw", "-g", "staging", "auth", "status"]).unwrap_err();
assert!(err.to_string().contains("invalid value"));
}

#[test]
fn test_stage_flag_propagates_to_subcommands() {
use clap::Parser;
// The flag is global, so it works after the subcommand too.
let cli = Cli::try_parse_from(["rw", "clinicians", "show", "me", "-g", "dev"]).unwrap();
assert_eq!(cli.stage, Some(Stage::Dev));
}

#[test]
fn test_config_profile_add_keeps_its_own_stage_flag() {
use clap::Parser;
// clap skips propagating a global arg into a subcommand that already
// declares the same arg id, so `add` keeps its local `-g`.
let cli =
Cli::try_parse_from(["rw", "config", "profile", "add", "demo", "-g", "prod"]).unwrap();
let Commands::Config(config_args) = cli.command else {
panic!("expected the config subcommand");
};
let ConfigCommands::Profile(profile_args) = config_args.command else {
panic!("expected the profile subcommand");
};
let ConfigProfileCommands::Add(add_args) = profile_args.command else {
panic!("expected the add subcommand");
};
assert_eq!(add_args.stage, Some(Stage::Prod));
}

#[test]
fn test_config_profile_set_keeps_its_own_stage_flag() {
use clap::Parser;
let cli = Cli::try_parse_from(["rw", "config", "profile", "set", "demo", "-g", "sandbox"])
.unwrap();
let Commands::Config(config_args) = cli.command else {
panic!("expected the config subcommand");
};
let ConfigCommands::Profile(profile_args) = config_args.command else {
panic!("expected the profile subcommand");
};
let ConfigProfileCommands::Set(set_args) = profile_args.command else {
panic!("expected the set subcommand");
};
assert_eq!(set_args.stage, Some(Stage::Sandbox));
}
}
84 changes: 68 additions & 16 deletions src/commands/config/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use std::time::Instant;

use crate::api::resolve_api;
use crate::auth_cache::{load_auth_cache, AuthCache};
use crate::cli::Stage;
use crate::config::Config;
use crate::output::{CommandOutput, Output};

Expand Down Expand Up @@ -70,9 +71,10 @@ pub async fn doctor(
config: &Config,
config_dir: &Path,
profile_override: Option<&str>,
stage_override: Option<&Stage>,
out: &Output,
) -> Result<()> {
let report = run_checks(config, config_dir, profile_override).await;
let report = run_checks(config, config_dir, profile_override, stage_override).await;
out.print(&report);
if !report.ok {
return Err(anyhow!("doctor checks failed"));
Expand All @@ -86,14 +88,15 @@ pub(crate) async fn run_checks(
config: &Config,
config_dir: &Path,
profile_override: Option<&str>,
stage_override: Option<&Stage>,
) -> DoctorOutput {
let mut checks: Vec<CheckResult> = Vec::with_capacity(4);

// 1. Profile.
let profile = check_profile(config, profile_override);
let profile = check_profile(config, profile_override, stage_override);
let profile_ok = profile.status == CheckStatus::Pass;
let profile_ctx = if profile_ok {
resolve_profile_ctx(config, profile_override)
resolve_profile_ctx(config, profile_override, stage_override)
} else {
None
};
Expand Down Expand Up @@ -138,9 +141,13 @@ struct ProfileCtx {
base_url: String,
}

fn resolve_profile_ctx(config: &Config, profile_override: Option<&str>) -> Option<ProfileCtx> {
fn resolve_profile_ctx(
config: &Config,
profile_override: Option<&str>,
stage_override: Option<&Stage>,
) -> Option<ProfileCtx> {
let (profile, organization, stage) =
crate::config::resolve_profile(config, profile_override).ok()?;
crate::config::resolve_profile(config, profile_override, stage_override).ok()?;
let base_url = resolve_api(&organization, &stage);
Some(ProfileCtx {
profile,
Expand All @@ -158,8 +165,12 @@ fn skip(name: &str, reason: &str) -> CheckResult {
}
}

fn check_profile(config: &Config, profile_override: Option<&str>) -> CheckResult {
match crate::config::resolve_profile(config, profile_override) {
fn check_profile(
config: &Config,
profile_override: Option<&str>,
stage_override: Option<&Stage>,
) -> CheckResult {
match crate::config::resolve_profile(config, profile_override, stage_override) {
Ok((name, organization, stage)) => {
let mut details = BTreeMap::new();
details.insert("profile".to_string(), serde_json::json!(name));
Expand Down Expand Up @@ -366,7 +377,6 @@ fn remaining(expires_at: i64) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Stage;
use crate::config::Profile;
use mockito::Server;

Expand Down Expand Up @@ -396,7 +406,7 @@ mod tests {
#[test]
fn test_check_profile_pass() {
let config = cfg_with_default(Stage::Prod);
let r = check_profile(&config, None);
let r = check_profile(&config, None, None);
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.message.contains("demo"));
assert!(r.message.contains("demonstration"));
Expand All @@ -406,15 +416,15 @@ mod tests {
#[test]
fn test_check_profile_fails_without_default() {
let config = Config::default();
let r = check_profile(&config, None);
let r = check_profile(&config, None, None);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.message.contains("no profile selected"));
}

#[test]
fn test_check_profile_fails_for_unknown_override() {
let config = cfg_with_default(Stage::Prod);
let r = check_profile(&config, Some("nope"));
let r = check_profile(&config, Some("nope"), None);
assert_eq!(r.status, CheckStatus::Fail);
assert!(r.message.contains("nope"));
}
Expand Down Expand Up @@ -631,7 +641,7 @@ mod tests {
async fn test_run_checks_no_profile_cascades_skips() {
let dir = tempfile::TempDir::new().unwrap();
let config = Config::default();
let report = run_checks(&config, dir.path(), None).await;
let report = run_checks(&config, dir.path(), None, None).await;
assert!(!report.ok);
assert_eq!(report.checks.len(), 4);
assert_eq!(report.checks[0].status, CheckStatus::Fail);
Expand All @@ -644,7 +654,7 @@ mod tests {
async fn test_run_checks_no_auth_skips_api_but_runs_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let config = cfg_with_default(Stage::Prod);
let report = run_checks(&config, dir.path(), None).await;
let report = run_checks(&config, dir.path(), None, None).await;
assert!(!report.ok);
assert_eq!(report.checks[0].status, CheckStatus::Pass);
assert_eq!(report.checks[1].status, CheckStatus::Fail);
Expand All @@ -657,7 +667,7 @@ mod tests {
async fn test_doctor_output_plain_format() {
let dir = tempfile::TempDir::new().unwrap();
let config = Config::default();
let report = run_checks(&config, dir.path(), None).await;
let report = run_checks(&config, dir.path(), None, None).await;
let plain = report.plain();
// Each check renders one line with a glyph + name.
assert_eq!(plain.lines().count(), 4);
Expand All @@ -671,7 +681,7 @@ mod tests {
async fn test_doctor_output_json_shape() {
let dir = tempfile::TempDir::new().unwrap();
let config = Config::default();
let report = run_checks(&config, dir.path(), None).await;
let report = run_checks(&config, dir.path(), None, None).await;
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["ok"], false);
assert!(json["checks"].is_array());
Expand All @@ -684,7 +694,49 @@ mod tests {
let dir = tempfile::TempDir::new().unwrap();
let config = Config::default();
let out = Output { json: true };
let result = doctor(&config, dir.path(), None, &out).await;
let result = doctor(&config, dir.path(), None, None, &out).await;
assert!(result.is_err());
}

#[test]
fn test_check_profile_reports_stage_override() {
let config = cfg_with_default(Stage::Prod);
let r = check_profile(&config, None, Some(&Stage::Local));
assert_eq!(r.status, CheckStatus::Pass);
assert!(r.message.contains("local"));
assert_eq!(r.details["stage"], serde_json::json!("local"));
}

#[test]
fn test_check_profile_without_override_reports_configured_stage() {
let config = cfg_with_default(Stage::Prod);
let r = check_profile(&config, None, None);
assert_eq!(r.status, CheckStatus::Pass);
assert_eq!(r.details["stage"], serde_json::json!("prod"));
}

#[test]
fn test_resolve_profile_ctx_honors_stage_override() {
let config = cfg_with_default(Stage::Prod);
let ctx = resolve_profile_ctx(&config, None, Some(&Stage::Local)).unwrap();
assert_eq!(ctx.profile, "demo");
assert_eq!(ctx.base_url, "http://localhost:8080");
}

#[test]
fn test_resolve_profile_ctx_without_override_uses_configured_stage() {
let config = cfg_with_default(Stage::Prod);
let ctx = resolve_profile_ctx(&config, None, None).unwrap();
assert_eq!(ctx.base_url, "https://demonstration.roundingwell.com/api");
}

#[tokio::test]
async fn test_run_checks_profile_check_reflects_stage_override() {
let dir = tempfile::TempDir::new().unwrap();
let config = cfg_with_default(Stage::Prod);
let report = run_checks(&config, dir.path(), None, Some(&Stage::Local)).await;
let profile = report.checks.iter().find(|c| c.name == "profile").unwrap();
assert_eq!(profile.status, CheckStatus::Pass);
assert_eq!(profile.details["stage"], serde_json::json!("local"));
}
}
8 changes: 6 additions & 2 deletions src/commands/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ use anyhow::Result;
use std::path::Path;

use crate::cli::{
ConfigArgs, ConfigCommands, ConfigDefaultCommands, ConfigProfileCommands, ConfigUpdatesCommands,
ConfigArgs, ConfigCommands, ConfigDefaultCommands, ConfigProfileCommands,
ConfigUpdatesCommands, Stage,
};
use crate::config::Config;
use crate::output::Output;
Expand All @@ -29,10 +30,13 @@ pub async fn dispatch(
cfg_path: &Path,
config_dir: &Path,
profile_override: Option<&str>,
stage_override: Option<&Stage>,
out: &Output,
) -> Result<()> {
match args.command {
ConfigCommands::Doctor => doctor::doctor(config, config_dir, profile_override, out).await,
ConfigCommands::Doctor => {
doctor::doctor(config, config_dir, profile_override, stage_override, out).await
}
ConfigCommands::Profile(profile_args) => match profile_args.command {
ConfigProfileCommands::List => {
profile_list(config, out);
Expand Down
Loading