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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Agents learn the CLI from `--help`, which always matches the installed version,

## Commands

- **One shape per resource.** A resource (`product`, and later accounts or members) has `list`, `view`, `create`, `edit` and `delete` under one noun. Name records as `ACCOUNT/PRODUCT`.
- **One shape per resource.** A resource (`product`, `member`, and later accounts) has `list`, `view`, `create`, `edit` and `delete` under one noun. Name records as `ACCOUNT/PRODUCT`. Where another verb is what people say, use it and keep the standard one as a hidden alias: `member invite` (`create`) and `member revoke` (`delete`).
- **Signing in lives under `auth`:** `auth login | status | logout | token`. `creds` stays top-level because people's AWS configs run `credential_process = source-coop creds`.
- **Renaming a command keeps the old name working** as a hidden command that runs the same code (`#[command(hide = true)]`, like top-level `login`). Scripts, docs and habits use the old name. The README shows only the new one, with a note that the old still works.
- **`-i` wherever a record is named or listed** (everything but `create`, which prompts anyway). It opens the picker, or the record's screen when the record is named. With a name omitted, it picks one first. The test checks this.
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,40 @@ Before asking anything, `create`, `edit` and `delete` check that you're signed i

Output is for people on a terminal and for scripts when piped: `list` shows each product's fields (`--format table` prints an aligned table with a header on a terminal, and tab-separated rows without one when piped); `create` and `edit` print the product's URL on stdout and their message on stderr, so `url=$(source-coop product create ...)` works.

## Managing members

`source-coop member` manages who belongs to an organization (`ACCOUNT`) or a product (`ACCOUNT/PRODUCT`), and `source-coop invitation` answers the invitations sent to you. Members are named by their account ID; there's no inviting by email.

```bash
source-coop member list my-org # members and pending invitations
source-coop member list my-org/my-data --json # a product's own members, as JSON
source-coop member list my-org --state invited # only invitations not yet answered
source-coop member list my-org -i # move through the members and act on them
source-coop member view my-org alice # one member; -i for its screen
source-coop member invite my-org alice bob --role maintainers
source-coop member invite my-org/my-data -A people.txt --role read_data
source-coop member invite my-org # prompts for who and which role
source-coop member edit my-org alice --role owners
source-coop member edit my-org -i # pick a member, then a role
source-coop member revoke my-org alice # also withdraws a pending invitation
source-coop member leave my-org # give up your own membership

source-coop invitation list # invitations waiting for you
source-coop invitation list -i # open one to accept (a) or reject (r) it
source-coop invitation accept my-org/my-data
source-coop invitation reject my-org
```

With [shell completion](#shell-completion), TAB offers the organizations you belong to and their products for `ACCOUNT` or `ACCOUNT/PRODUCT`, the members of the one you named for a member, its organization's members who aren't on a product yet for `invite`, and your own invitations and memberships for `invitation accept`/`reject` and `member leave`.

Roles, most powerful first, are `owners`, `maintainers`, `write_data` and `read_data`. Only owners can make someone an owner, or change or revoke an owner's membership; owners and maintainers handle the other roles. A product's members list holds only that product's own members: the organization's members reach its products as well, and are listed with the organization.

`invite` takes any number of account IDs, and `--accounts-file PATH` (`-A`, `-` for stdin) adds one per line, skipping blank lines and lines starting with `#`. Each is sent as its own invitation: one the API refuses is reported and the rest are still sent, and the command fails at the end if any did. In a terminal, missing accounts and role are asked for (the role defaulting to `read_data`, and `owners` offered only if you can grant it), then shown for you to confirm; ← goes back a question. If some invitations fail, the ones not sent are saved as a draft that the next `invite` to the same place offers back. A product owner's service account is added at once rather than invited, with `read_data` or `write_data` only.

`edit` with no `--role` asks for one, starting from the current role. `revoke` asks you to confirm, and to type the organization or product back when it's your own membership; `leave` always asks you to type it back, since only an owner or maintainer can let you back in. Without a terminal both need `--yes`. An invitation's role can't be changed: revoke it and invite again.

With `-i`, `member list` answers to the same keys as `product list -i`. On a member, `e` changes the role and `d` revokes the membership (on an invitation, only `d`, which withdraws it); `b` opens their profile. `--json` on any of these prints the memberships as the API returns them (`membership_id`, `account_id`, `membership_account_id`, `repository_id` for a product, `role`, `state`, `state_changed`); `invite --json` prints an array of the memberships it created. `create` and `delete` also work as names for `invite` and `revoke`.

## Credential storage

The CLI caches the login session (the Ory refresh, ID and access tokens) and the temporary STS credentials for each role, so that `creds` and API commands work without re-authenticating. The refresh token is kept in the session only: it rotates on use, so every role and every API call refreshes through it, one at a time.
Expand Down
175 changes: 175 additions & 0 deletions src/complete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
//! reading the login from the keyring raises a password dialog (`complete`).

use crate::api::Client;
use crate::member::{self, Scope};
use crate::{cache, defaults, product, session};
use clap::ValueEnum;
use clap_complete::engine::CompletionCandidate;
Expand Down Expand Up @@ -90,6 +91,44 @@ pub fn account(current: &OsStr) -> Vec<CompletionCandidate> {
})
}

/// An organization you belong to (`ACCOUNT`), or one of its products
/// (`ACCOUNT/PRODUCT`).
pub fn scope(current: &OsStr) -> Vec<CompletionCandidate> {
complete(client, ANSWER, TIMEOUT, current, |c, s| async move {
scopes(&c, &s).await
})
}

/// A member of the organization or product named before it.
pub fn member(current: &OsStr) -> Vec<CompletionCandidate> {
complete(client, ANSWER, TIMEOUT, current, |c, s| async move {
members_of_named(&c, &s, false).await
})
}

/// Someone to invite to the product named before it: its organization's
/// members who aren't on it yet. Nothing for an organization, since there's
/// no listing of accounts to offer.
pub fn invitee(current: &OsStr) -> Vec<CompletionCandidate> {
complete(client, ANSWER, TIMEOUT, current, |c, s| async move {
members_of_named(&c, &s, true).await
})
}

/// Where you're invited.
pub fn invitation(current: &OsStr) -> Vec<CompletionCandidate> {
complete(client, ANSWER, TIMEOUT, current, |c, s| async move {
mine(&c, &s, "invited").await
})
}

/// Where you're a member.
pub fn membership(current: &OsStr) -> Vec<CompletionCandidate> {
complete(client, ANSWER, TIMEOUT, current, |c, s| async move {
mine(&c, &s, "member").await
})
}

/// Make the client with `client`, run `find` with `current`, and offer what
/// it returns, or nothing once `timeout` passes. `client` is passed in so
/// tests never reach `client()`, which reads the login session from the OS
Expand Down Expand Up @@ -211,6 +250,88 @@ async fn accounts(client: &Client, current: &str) -> Result<Vec<String>, String>
Ok(starting_with(all, current))
}

/// Before a `/`, your accounts both as they are (an organization) and as
/// the start of a product; after it, that account's products.
async fn scopes(client: &Client, current: &str) -> Result<Vec<String>, String> {
if current.contains('/') {
return products(client, current).await;
}
let accounts = accounts(client, current).await?;
Ok(accounts
.into_iter()
.flat_map(|a| [format!("{a}/"), a])
.collect())
}

/// The members of the scope named earlier on the command line or, for
/// `invitees`, the members of a product's organization not on the product.
async fn members_of_named(
client: &Client,
current: &str,
invitees: bool,
) -> Result<Vec<String>, String> {
let args: Vec<String> = std::env::args().collect();
let Some(scope) = scope_on_line(&args) else {
return Ok(vec![]);
};
let ids = |ms: Vec<Value>| -> Vec<String> {
ms.iter()
.filter_map(|m| m.get("account_id")?.as_str().map(String::from))
.collect()
};
let on_scope = ids(member::members(client, &scope).await?);
let names = match (invitees, &scope.product_id) {
(false, _) => on_scope,
(true, None) => vec![],
(true, Some(_)) => {
let org = Scope {
account_id: scope.account_id.clone(),
product_id: None,
};
let mut org_members = ids(member::members(client, &org).await?);
org_members.retain(|a| !on_scope.contains(a));
org_members
}
};
Ok(starting_with(names, current))
}

/// Where your memberships in `state` are, as `ACCOUNT` or `ACCOUNT/PRODUCT`.
async fn mine(client: &Client, current: &str, state: &str) -> Result<Vec<String>, String> {
let ms = member::my_memberships(client, state).await?;
let names = ms.iter().map(|m| Scope::of(m).to_string()).collect();
Ok(starting_with(names, current))
}

/// The SCOPE in `member SUBCOMMAND … SCOPE …`: the first word after the
/// subcommand that is neither a flag nor a flag's value. Completers see only
/// the word being completed, but the shell passes the whole line as our
/// arguments.
fn scope_on_line(args: &[String]) -> Option<Scope> {
const TAKE_VALUES: [&str; 10] = [
"--api-url",
"--proxy-url",
"-r",
"--role",
"-F",
"--from-file",
"-A",
"--accounts-file",
"--state",
"-L",
];
let at = args.iter().position(|a| a == "member")?;
let mut words = args.get(at + 2..)?.iter();
while let Some(w) = words.next() {
if TAKE_VALUES.contains(&w.as_str()) {
words.next();
} else if !w.starts_with('-') {
return w.parse().ok();
}
}
None
}

fn starting_with(names: Vec<String>, prefix: &str) -> Vec<String> {
names
.into_iter()
Expand Down Expand Up @@ -313,6 +434,60 @@ mod tests {
assert_eq!(products(&client, "org/z").await.unwrap(), ["org/zebra"]);
}

#[tokio::test]
async fn a_scope_is_an_account_or_one_of_its_products() {
let (server, client) = server().await;
assert_eq!(
scopes(&client, "o").await.unwrap(),
["org/", "org", "other/", "other"]
);
Mock::given(method("GET"))
.and(path("/api/v1/products/org"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"items": [{"account_id": "org", "product_id": "data"}], "next_cursor": null
})))
.mount(&server)
.await;
assert_eq!(scopes(&client, "org/").await.unwrap(), ["org/data"]);
}

#[test]
fn a_member_completes_from_the_scope_named_before_it() {
let line = |s: &str| -> Vec<String> { s.split(' ').map(String::from).collect() };
let scope = |s: &str| scope_on_line(&line(s)).map(|s| s.to_string());
// As the shell calls us: the binary, `--`, then the line.
assert_eq!(
scope("source-coop -- source-coop member edit org/data al"),
Some("org/data".into())
);
// Flags and their values before it are passed over.
assert_eq!(
scope("source-coop -- source-coop member edit --role owners -F x.json org al"),
Some("org".into())
);
assert_eq!(scope("source-coop -- source-coop member edit"), None);
assert_eq!(scope("source-coop -- source-coop product view"), None);
}

#[tokio::test]
async fn your_invitations_and_memberships_complete_by_where_they_are() {
let (server, client) = server().await;
Mock::given(method("GET"))
.and(path("/api/v1/memberships"))
.and(query_param("state", "invited"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!([
{"membership_account_id": "geo", "repository_id": "elevation"},
{"membership_account_id": "river"}
])))
.mount(&server)
.await;
assert_eq!(
mine(&client, "", "invited").await.unwrap(),
["geo/elevation", "river"]
);
assert_eq!(mine(&client, "r", "invited").await.unwrap(), ["river"]);
}

#[tokio::test]
async fn create_offers_only_accounts_you_may_create_under() {
let (_server, client) = server().await;
Expand Down
Loading
Loading