diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 5e5a3a2f6..e761524dd 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -74,6 +74,7 @@ server has none); accounts on other servers are never replaced. # Add (or log in again to) a specific account without prompting sentry auth login jane@example.com sentry auth login jane@example.com --url https://sentry.example.com +sentry auth login jane@example.com:sentry.example.com # same as --url # Show stored accounts; * marks the active one sentry auth list @@ -90,16 +91,19 @@ nothing) if it belongs to someone else. Select an account by email, username, user ID, or account ID (from `sentry auth list --json`). `sentry auth switch` also accepts a close match — `sentry auth switch jane` picks `jane@example.com` with a warning when it's the -only candidate. If the same email is stored on several servers, add `--url`: +only candidate. If the same email is stored on several servers, add the server +after a colon, as `sentry org list` shows it +(`sentry auth switch jane@example.com:sentry.example.com`), or pass `--url`: `sentry auth switch jane@example.com --url https://sentry.example.com`. Most of the time you don't need to switch: commands pick the stored account that fits what they target, without changing the active account: - **Organization** — when the target organization isn't in the active - account's organization list (cached at login and by `sentry org list`) but - another stored account has used it, that account is used. This includes - `oNNNN` IDs from DSNs. + account's organization list (cached at login and by `sentry org list`, + which lists every stored account's organizations) but another stored + account has used it, that account is used. This includes `oNNNN` IDs from + DSNs. - **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry URL for another account's server (for example a self-hosted issue link) uses the account stored for that server. diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index d102dfde4..8e7de48c5 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -13,6 +13,40 @@ my-org My Organization owner another-org Another Org member ``` +With several stored accounts (see `sentry auth list`), `sentry org list` lists +the organizations of every logged-in account together, each fetched with that +account's own credentials on its own server, plus an `ACCOUNT` column naming +each account as `:` — the form `--account` and +`sentry auth login` accept. An +organization reachable from several accounts on the same server is listed once, +under the active account if it is one of them, else the most recently used. +Accounts whose login fails are skipped with a warning. `SENTRY_HOST`/`SENTRY_URL` limits the list to accounts on that server, +and `--account` to a single account. The stored active account never changes. +With `--json`, each entry's `account` carries that notation as `name`, with its +parts alongside: `email` (or `username`) and `server`. + +``` +$ sentry org list +SLUG NAME ACCOUNT +my-org My Organization jane@example.com:sentry.io +acme-internal Acme Internal jane@acme.com:sentry.acme.com +``` + +```bash +# Only one account's organizations +sentry org list --account jane@acme.com +# The same email on several servers: add the server +sentry org list -a jane@example.com:sentry.io +``` + +```bash +# Only organizations whose slug or name matches "acme", best matches first +# (case-insensitive; prefix, substring, and small typos match too) +sentry org list acme +# ...or matches a glob +sentry org list 'acme-*' +``` + ```bash # View organization details sentry org view my-org diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index b98a64094..933993124 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -394,7 +394,7 @@ Authenticate with Sentry Work with Sentry organizations -- `sentry org list` — List organizations +- `sentry org list []` — List organizations - `sentry org view []` — View details of an organization → Full flags and examples: `references/org.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md index 8cede4959..b8c75eeae 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/auth.md @@ -122,6 +122,7 @@ Switch the active account # Add (or log in again to) a specific account without prompting sentry auth login jane@example.com sentry auth login jane@example.com --url https://sentry.example.com +sentry auth login jane@example.com:sentry.example.com # same as --url # Show stored accounts; * marks the active one sentry auth list diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md index 1eaf2afbe..d9a4205a7 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md @@ -11,13 +11,14 @@ requires: Work with Sentry organizations -### `sentry org list` +### `sentry org list []` List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "25")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `-a, --account - Only list organizations of this stored account (email, username, or account id; : for one server)` ### `sentry org view []` @@ -33,6 +34,17 @@ View details of an organization # List organizations sentry org list +# Only one account's organizations +sentry org list --account jane@acme.com +# The same email on several servers: add the server +sentry org list -a jane@example.com:sentry.io + +# Only organizations whose slug or name matches "acme", best matches first +# (case-insensitive; prefix, substring, and small typos match too) +sentry org list acme +# ...or matches a glob +sentry org list 'acme-*' + # View organization details sentry org view my-org diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index d62e12325..56299e96c 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -21,7 +21,11 @@ import { setActiveAccount, setAuthToken, } from "../../lib/db/auth.js"; -import { getAccountRow, isAccountRowUsable } from "../../lib/db/accounts.js"; +import { + getAccountRow, + isAccountRowUsable, + splitAccountSelector, +} from "../../lib/db/accounts.js"; import { getDbPath } from "../../lib/db/index.js"; import { getUserInfo, setUserInfo } from "../../lib/db/user.js"; import { getEnv } from "../../lib/env.js"; @@ -154,20 +158,77 @@ function resolveLoginScope(flags: LoginFlags): string | undefined { */ /** @internal exported for testing */ /** - * Parse the optional `email` argument of `sentry auth login`. + * Parse the optional `email` argument of `sentry auth login`: an email, or + * `:` (the notation `sentry org list` shows, e.g. + * `jane@acme.com:sentry.acme.com`) to also pick the server like `--url`. + * Returns the trimmed argument; {@link splitLoginTarget} splits it. * - * @throws {ValidationError} when the value is not an email address + * @throws {ValidationError} when the value is not an email address, or the + * server part is not a valid URL */ export function parseLoginEmail(raw: string): string { - const email = raw.trim(); - const at = email.indexOf("@"); - if (at <= 0 || at === email.length - 1 || /\s/.test(email)) { + const target = raw.trim(); + splitLoginTarget(target, raw); + return target; +} + +/** + * Split a `sentry auth login` target into its email and, for + * `:`, the server's normalized origin. Uses the same + * {@link splitAccountSelector} as `--account`, plus an email check. + * + * @throws {ValidationError} when either part is invalid + */ +export function splitLoginTarget( + target: string, + raw = target, +): { email: string; url?: string } { + const parts = splitAccountSelector(target); + if (!(parts && isEmailAddress(parts.who))) { throw new ValidationError( - `Expected an email address to log in as, got '${raw}'.`, + `Expected an email address, or : with a valid server, to log in as; got '${raw}'.`, "email", ); } - return email; + return parts.server + ? { email: parts.who, url: parts.server } + : { email: parts.who }; +} + +/** One `@` with text on both sides, and no whitespace. */ +function isEmailAddress(value: string): boolean { + const at = value.indexOf("@"); + return ( + at > 0 && + at < value.length - 1 && + value.indexOf("@", at + 1) === -1 && + !/\s/.test(value) + ); +} + +/** + * Apply the server of an `:` login target as `--url`. + * + * @throws {ValidationError} when `--url` names a different server + */ +function applyLoginTarget( + flags: LoginFlags, + target: string | undefined, +): { flags: LoginFlags; email?: string } { + if (target === undefined) { + return { flags }; + } + const { email, url } = splitLoginTarget(target); + if (!url) { + return { flags, email }; + } + if (flags.url && flags.url !== url) { + throw new ValidationError( + `'${target}' names ${url}, but --url is ${flags.url}. Pass only one server.`, + "url", + ); + } + return { flags: { ...flags, url }, email }; } export function parseLoginUrl(raw: string): string { @@ -470,21 +531,22 @@ export const loginCommand = buildCommand({ "Log in to Sentry using OAuth or an API token.\n\n" + "The OAuth flow uses a device code - you'll be given a code to enter at a URL.\n" + "Alternatively, use --token to authenticate with an existing API token.\n\n" + - "For self-hosted Sentry, pass --url to authenticate against that\n" + - "instance. This is the ONLY way to trust a new Sentry host — URL\n" + - "arguments and config files are refused when they don't match the\n" + - "currently-authenticated host.\n\n" + + "For self-hosted Sentry, pass --url (or an :\n" + + "argument) to authenticate against that instance. This is the ONLY way\n" + + "to trust a new Sentry host — URL arguments and config files are\n" + + "refused when they don't match the currently-authenticated host.\n\n" + "When already logged in, the new login is stored as another account\n" + "(see 'sentry auth list' / 'sentry auth switch'); --force instead\n" + "replaces the stored login for that server (accounts on other servers\n" + - "are kept). Pass an email to add or log in\n" + - "again to that account without prompting: Sentry approves the request\n" + - "as whoever is signed in to the browser, so a login approved as another\n" + - "user is refused and nothing is saved.\n\n" + + "are kept). Pass an email (or :, as 'sentry org list'\n" + + "shows it) to add or log in again to that account without prompting:\n" + + "Sentry approves the request as whoever is signed in to the browser,\n" + + "so a login approved as another user is refused and nothing is saved.\n\n" + "Examples:\n" + " sentry auth login\n" + " sentry auth login jane@acme.com\n" + - " sentry auth login jane@acme.com --url https://sentry.acme.com", + " sentry auth login jane@acme.com --url https://sentry.acme.com\n" + + " sentry auth login jane@acme.com:sentry.acme.com", }, parameters: { flags: { @@ -539,7 +601,7 @@ export const loginCommand = buildCommand({ { placeholder: "email", brief: - "Email of the account to add or log in to again; the login is refused if you approve it as someone else", + "Email of the account to add or log in to again (: also sets --url); the login is refused if you approve it as someone else", parse: parseLoginEmail, optional: true, }, @@ -547,7 +609,9 @@ export const loginCommand = buildCommand({ }, }, output: { human: formatLoginResult }, - async *func(this: SentryContext, flags: LoginFlags, email?: string) { + async *func(this: SentryContext, rawFlags: LoginFlags, target?: string) { + // `:` picks the server just like `--url `. + const { flags, email } = applyLoginTarget(rawFlags, target); // Resolve OAuth scopes up front so conflicting flag combinations // (--token + --read-only/--scope, --read-only + --scope) and invalid // scope values fail fast before any network or DB work. diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 5a6a43eb5..7e66b133b 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -1,17 +1,34 @@ /** * sentry org list * - * List organizations the user has access to. + * List organizations the user has access to — across every stored account + * when several are logged in. */ import type { SentryContext } from "../../context.js"; +import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; +import { + type AccountSummary, + accountSelectorOf, + accountServerOf, + serverKey, + toAccountSummary, +} from "../../lib/db/accounts.js"; +import { + listUsableAccountsInScope, + withStoredAccount, +} from "../../lib/db/auth.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; +import { ApiError, AuthError } from "../../lib/errors.js"; +import { formatAccountLabel } from "../../lib/formatters/accounts.js"; +import { getNestedValue } from "../../lib/formatters/json.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; +import { filterByName } from "../../lib/list-filter.js"; import { applyFreshFlag, buildListLimitFlag, @@ -26,17 +43,51 @@ type ListFlags = { readonly limit: number; readonly json: boolean; readonly fresh: boolean; + readonly account?: string; readonly fields?: string[]; }; +/** The stored account an organization was listed under (no secrets). */ +type OrgListAccount = { + /** + * What `--account` and `sentry auth login` accept: `:`, e.g. + * `jane@acme.com:sentry.io` (just `` when no server is recorded). + */ + name: string; + id: string; + email?: string; + username?: string; + /** + * The server part of `name`: `host[:port]`, or the full origin such as + * `http://localhost:8000` for a non-HTTPS server. + */ + server?: string; + /** The server's full origin, e.g. `https://sentry.io`. */ + host?: string; + /** Human label, e.g. `Jane on sentry.io`. */ + label: string; +}; + /** - * An organization enriched with an optional region display name. + * An organization enriched with an optional region display name and the + * stored account it was listed under. * * The `region` field is only present when the user's orgs span multiple - * regions (e.g. both US and EU). It is included in JSON output as well, - * providing region context to machine consumers. + * regions (e.g. both US and EU). `account` is only present when orgs from + * several stored accounts are listed. Both are included in JSON output as + * well, providing context to machine consumers. */ -type OrgListEntry = SentryOrganization & { region?: string }; +type OrgListEntry = SentryOrganization & { + region?: string; + account?: OrgListAccount; +}; + +/** One account's fetched orgs plus its slug → region URL cache. */ +type AccountOrgs = { + account?: AccountSummary; + orgs: SentryOrganization[]; + regions: Map; +}; /** * Extract a human-readable region name from a region URL. @@ -77,6 +128,28 @@ function getRegionDisplayName(regionUrl: string): string { } } +/** + * Table columns in display order: header → the entry's field, as a + * `--fields` path (`account.name`). + */ +const ORG_LIST_COLUMNS: Record = { + SLUG: "slug", + REGION: "region", + NAME: "name", + ACCOUNT: "account.name", +}; + +/** The string at `path` in `entry`, or `undefined` when unset. */ +function cellOf(entry: OrgListEntry, path: string): string | undefined { + const lookup = getNestedValue(entry, path); + return lookup.found && typeof lookup.value === "string" + ? lookup.value + : undefined; +} + +/** Columns shown only when at least one entry has a value for them. */ +const OPTIONAL_ORG_LIST_COLUMNS = new Set(["REGION", "ACCOUNT"]); + /** * Format org list entries as a human-readable table. * @@ -88,38 +161,203 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { return "No organizations found."; } - const showRegion = entries.some((e) => e.region !== undefined); - - type OrgRow = { slug: string; name: string; region?: string }; - const rows: OrgRow[] = entries.map((org) => ({ - slug: org.slug, - name: org.name, - region: showRegion ? (org.region ?? "") : undefined, - })); - - const columns: Column[] = [ - { header: "SLUG", value: (r) => r.slug }, - ...(showRegion - ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }] - : []), - { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, - ]; + // REGION and ACCOUNT only appear when some entry has one. + const columns: Column[] = Object.entries(ORG_LIST_COLUMNS) + .filter( + ([header, path]) => + !OPTIONAL_ORG_LIST_COLUMNS.has(header) || + entries.some((e) => cellOf(e, path) !== undefined), + ) + .map(([header, path]) => ({ + header, + value: (e) => escapeMarkdownCell(cellOf(e, path) ?? ""), + })); const parts: string[] = []; const buffer: Writer = { write: (s: string) => parts.push(s) }; - writeTable(buffer, rows, columns); + writeTable(buffer, entries, columns); return parts.join("").trimEnd(); } +function toOrgListAccount(account: AccountSummary): OrgListAccount { + const optional = { + email: account.email, + username: account.username, + server: accountServerOf(account), + host: account.host, + }; + const entry: OrgListAccount = { + name: accountSelectorOf(account), + id: account.id, + label: formatAccountLabel(account), + }; + for (const [field, value] of Object.entries(optional)) { + if (value) { + entry[field as keyof typeof optional] = value; + } + } + return entry; +} + +/** Fetch orgs (and their cached regions) with the credentials in effect. */ +async function fetchOrgs(account?: AccountSummary): Promise { + const orgs = await listOrganizationsUncached(); + return { account, orgs, regions: getAllOrgRegions() }; +} + +/** Fetch `account`'s orgs with its own credentials, on its own server. */ +async function fetchAccountOrgs(account: AccountSummary): Promise { + const host = account.host; + if (!host) { + throw new AuthError( + "not_authenticated", + "Account has no server recorded.", + { skipAutoAuth: true }, + ); + } + return await withStoredAccount({ id: account.id, host }, () => + fetchOrgs(account), + ); +} + +/** + * Fetch every account's orgs concurrently. An account that fails (expired + * login, unreachable server) is skipped with a warning so the others are + * still listed; when every account fails, the first error is thrown. + */ +async function fetchOrgsAcrossAccounts( + accounts: AccountSummary[], +): Promise { + const settled = await Promise.allSettled(accounts.map(fetchAccountOrgs)); + const fetched: AccountOrgs[] = []; + let firstError: unknown; + for (const [i, result] of settled.entries()) { + if (result.status === "fulfilled") { + fetched.push(result.value); + continue; + } + const error = result.reason; + if (!(error instanceof AuthError || error instanceof ApiError)) { + throw error; + } + firstError ??= error; + const account = accounts[i]; + log.warn( + `Skipped ${account ? formatAccountLabel(account) : "an account"}: ${error.message}`, + ); + } + if (fetched.length === 0 && firstError !== undefined) { + throw firstError; + } + return fetched; +} + +/** + * Flatten per-account results into list entries. An org reachable from + * several accounts on the same server is listed once, under the first of + * them (the active account, else the most recently used). + */ +function mergeAccountOrgs(results: AccountOrgs[]): { + orgs: SentryOrganization[]; + accountOf: Map; + regionOf: Map; +} { + const orgs: SentryOrganization[] = []; + const accountOf = new Map(); + const regionOf = new Map(); + // ` ` of every org listed so far. + const seen = new Set(); + for (const { account, orgs: accountOrgs, regions } of results) { + const server = account?.host ? serverKey(account.host) : undefined; + for (const org of accountOrgs) { + if (server) { + const key = `${server} ${org.slug}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + } + orgs.push(org); + accountOf.set(org, account); + regionOf.set(org, regions.get(org.slug)); + } + } + return { orgs, accountOf, regionOf }; +} + +/** + * The command that logs back in to `account`: `sentry auth login + * :` when its email is known, else `--url `. + */ +function reloginCommand(account: AccountSummary): string { + if (account.email) { + return `sentry auth login ${accountSelectorOf(account)}`; + } + return account.host + ? `sentry auth login --url ${account.host}` + : "sentry auth login"; +} + +/** + * Which stored accounts to list: the one named by `--account`, else every + * usable account in scope. `undefined` means "the credentials in effect" + * (no usable stored account, only the active one, or a forced env token), + * which keeps the single-account output unchanged. A single usable account + * that isn't the active one (say, the active login was logged out) is + * listed with its own credentials. + */ +function resolveAccounts(selector?: string): AccountSummary[] | undefined { + if (selector !== undefined) { + const account = resolveAccountSelector( + selector, + "sentry org list --account", + { + fuzzy: true, + }, + ); + if (!account.usable) { + throw new AuthError( + "expired", + `${formatAccountLabel(account)} is logged out. Run '${reloginCommand(account)}'.`, + { skipAutoAuth: true }, + ); + } + return [account]; + } + // The active account first, then most recently used: its orgs lead the + // list, and an org it shares with another account is listed under it. + const accounts = listUsableAccountsInScope() + .map(toAccountSummary) + .sort((a, b) => Number(b.active) - Number(a.active)); + const [only] = accounts; + return accounts.length > 1 || (only && !only.active) ? accounts : undefined; +} + export const listCommand = buildCommand({ docs: { brief: "List organizations", fullDescription: "List organizations that you have access to.\n\n" + + "With several stored accounts (see 'sentry auth list'), organizations\n" + + "from every logged-in account are listed together, with an ACCOUNT\n" + + "column showing which login reaches each one. Commands targeting an\n" + + "organization use that account automatically. An organization reachable\n" + + "from several accounts on the same server is listed once. Accounts whose\n" + + "login fails are skipped with a warning. SENTRY_HOST/SENTRY_URL limits the\n" + + "list to accounts on that server; --account limits it to one account\n" + + "(an email, username, or account id; add : to pick one server's\n" + + "login, e.g. jane@example.com:sentry.example.com, as shown in ACCOUNT).\n\n" + + "Pass a filter to only list organizations whose slug or name matches it,\n" + + "ignoring case: exactly, as a prefix or substring, or with a small typo\n" + + "(best matches first), or as a glob like 'acme-*'.\n\n" + "Examples:\n" + " sentry org list\n" + + " sentry org list acme\n" + + " sentry org list 'acme-*'\n" + " sentry org list --limit 10\n" + + " sentry org list --account jane@example.com\n" + + " sentry org list -a jane@example.com:sentry.example.com\n" + " sentry org list --json\n\n" + "Alias: `sentry orgs` → `sentry org list`", }, @@ -128,33 +366,80 @@ export const listCommand = buildCommand({ flags: { limit: buildListLimitFlag("organizations"), fresh: FRESH_FLAG, + account: { + kind: "parsed", + parse: String, + brief: + "Only list organizations of this stored account (email, username, or account id; : for one server)", + optional: true, + }, }, // Only -n for --limit; no -c since org list has no --cursor flag - aliases: { ...FRESH_ALIASES, n: "limit" }, + aliases: { ...FRESH_ALIASES, n: "limit", a: "account" }, + positional: { + kind: "tuple", + parameters: [ + { + placeholder: "filter", + brief: + "Only list organizations whose slug or name fuzzy-matches this text or matches this glob", + parse: String, + optional: true, + }, + ], + }, }, - async *func(this: SentryContext, flags: ListFlags) { + async *func(this: SentryContext, flags: ListFlags, filter?: string) { applyFreshFlag(flags); - const orgs = await withProgress( + const accounts = resolveAccounts(flags.account); + const crossAccount = accounts !== undefined && accounts.length > 1; + const results = await withProgress( { - message: `Fetching organizations (up to ${flags.limit})...`, + message: crossAccount + ? `Fetching organizations from ${accounts.length} accounts (up to ${flags.limit})...` + : `Fetching organizations (up to ${flags.limit})...`, json: flags.json, }, - () => listOrganizationsUncached(), + async () => { + if (!accounts) { + return [await fetchOrgs()]; + } + return crossAccount + ? await fetchOrgsAcrossAccounts(accounts) + : await Promise.all(accounts.map(fetchAccountOrgs)); + }, ); + const merged = mergeAccountOrgs(results); + const { accountOf, regionOf } = merged; + const orgs = filterByName(merged.orgs, filter, (org) => [ + org.slug, + org.name, + ]); const limitedOrgs = orgs.slice(0, flags.limit); - // Check if user has orgs in multiple regions - const orgRegions = getAllOrgRegions(); - const uniqueRegions = new Set(orgRegions.values()); - const showRegion = uniqueRegions.size > 1; + // Show regions when one login's orgs span several (e.g. US and EU). + // Accounts on different servers alone don't count: ACCOUNT shows those. + const showRegion = results.some( + ({ orgs: accountOrgs, regions }) => + new Set( + accountOrgs.flatMap((org) => { + const region = regions.get(org.slug); + return region ? [region] : []; + }), + ).size > 1, + ); - const entries: OrgListEntry[] = limitedOrgs.map((org) => ({ - ...org, - region: showRegion - ? getRegionDisplayName(orgRegions.get(org.slug) ?? "") - : undefined, - })); + const entries: OrgListEntry[] = limitedOrgs.map((org) => { + const account = crossAccount ? accountOf.get(org) : undefined; + return { + ...org, + region: showRegion + ? getRegionDisplayName(regionOf.get(org) ?? "") + : undefined, + ...(account ? { account: toOrgListAccount(account) } : {}), + }; + }); const hints: string[] = []; if (orgs.length > flags.limit) { @@ -162,6 +447,10 @@ export const listCommand = buildCommand({ } if (entries.length > 0) { hints.push("Tip: Use 'sentry org view ' for details"); + } else if (filter && merged.orgs.length > 0) { + hints.push( + `None of your ${merged.orgs.length} organizations match '${filter}'. Run 'sentry org list' to see them all.`, + ); } yield new CommandOutput(entries); diff --git a/packages/cli/src/lib/account-selection.ts b/packages/cli/src/lib/account-selection.ts index fd39cc821..eb08ed3f1 100644 --- a/packages/cli/src/lib/account-selection.ts +++ b/packages/cli/src/lib/account-selection.ts @@ -48,7 +48,8 @@ function describeAccount(account: AccountSummary): string { /** * Resolve a user-supplied selector (account id, email, username, user id, - * or `@`) to exactly one stored account. Without an + * `:`, or `@`) to exactly one stored + * account. Without an * exact match, similar accounts from the shared fuzzy matcher are suggested * (or, with `fuzzy`, a sole one is used). * @@ -72,7 +73,7 @@ export function resolveAccountSelector( `${command} `, [ ...matches.map(describeAccount), - "Add --url or use the account id to pick one", + "Pick one with :, --url , or the account id", ], ); } diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 198b78856..19e427da2 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -9,6 +9,7 @@ import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { uuidv7 } from "uuidv7"; +import { normalizeUrl } from "../constants.js"; import { fuzzyMatch } from "../fuzzy.js"; import { normalizeHttpOrigin } from "../sentry-urls.js"; import { storedCredentialIdentity } from "./identity-hash.js"; @@ -188,13 +189,66 @@ export function listAccounts(): AccountSummary[] { return listAccountRows().map(toAccountSummary); } +/** + * Split a `[:]` account selector (e.g. + * `jane@acme.com:sentry.acme.com`) on its first `:`. Emails, usernames, and + * account ids never contain one, so everything after it is the server: a + * bare host (HTTPS, may include a port) or a URL. Shared by `--account`, + * `auth switch`/`logout`, and `auth login`. + * + * @returns `{ who }` without a `:`; `{ who, server }` with the server's + * normalized origin; `undefined` when a side of the `:` is empty or the + * server is not an HTTP(S) URL + */ +export function splitAccountSelector( + selector: string, +): { who: string; server?: string } | undefined { + const trimmed = selector.trim(); + const colon = trimmed.indexOf(":"); + if (colon < 0) { + return { who: trimmed }; + } + const who = trimmed.slice(0, colon); + const server = normalizeHttpOrigin(normalizeUrl(trimmed.slice(colon + 1))); + return who && server ? { who, server } : undefined; +} + +/** + * The `:` selector naming an account, e.g. + * `jane@acme.com:sentry.acme.com`: what `--account` and + * `sentry auth login` accept. Uses the email, else the username, else the + * account id; the server part is omitted when no host is recorded. + */ +export function accountSelectorOf(account: AccountSummary): string { + const who = account.email ?? account.username ?? account.id; + const server = accountServerOf(account); + return server ? `${who}:${server}` : who; +} + +/** + * The server part of an account's selector: `host[:port]` for HTTPS, the + * full origin (`http://host[:port]`) otherwise, since selector parsing + * assumes HTTPS for a bare host. `undefined` when no host is recorded. + */ +export function accountServerOf(account: { + readonly host?: string; +}): string | undefined { + const host = account.host; + const origin = normalizeHttpOrigin(host); + if (!origin) { + return hostnameOf(host); + } + const url = new URL(origin); + return url.protocol === "https:" ? url.host : origin; +} + /** * Find stored accounts matching a user-supplied selector. * * Matches, in order of precedence: exact account id, then case-insensitive - * email, username, or user id. A selector of the form `@` where - * `` matches the account's hostname narrows by server as well. - * Returns every match so callers can report ambiguity. + * email, username, or user id. A selector of the form `:` + * (see {@link accountSelectorOf}) or `@` narrows by server + * as well. Returns every match so callers can report ambiguity. */ export function findAccounts(selector: string): AccountSummary[] { const accounts = listAccounts(); @@ -207,6 +261,13 @@ export function findAccounts(selector: string): AccountSummary[] { if (direct.length > 0) { return direct; } + const parts = splitAccountSelector(needle); + const server = parts?.server; + if (parts && server) { + return accounts.filter( + (a) => matchesIdentity(a, parts.who) && isAccountOnServer(a, server), + ); + } const at = needle.lastIndexOf("@"); if (at <= 0) { return []; @@ -240,6 +301,7 @@ export function findSimilarAccounts( account.username, account.name, account.email && host ? `${account.email}@${host}` : undefined, + account.host ? accountSelectorOf(account) : undefined, ]; for (const value of values) { if (value) { @@ -292,14 +354,25 @@ export function hostnameOf(host: string | undefined): string | undefined { * is already allowed to reach. */ export function isSameServer(a: string, b: string): boolean { - const left = normalizeHttpOrigin(a); - const right = normalizeHttpOrigin(b); - if (!(left && right)) { - return false; + const left = serverKey(a); + return left !== undefined && left === serverKey(b); +} + +/** Every SaaS origin (`*.sentry.io`) shares one {@link serverKey}. */ +const SAAS_SERVER_KEY = "saas"; + +/** + * Canonical key for a server URL: equal for exactly the URLs + * {@link isSameServer} treats as one installation (the same origin, or any + * SaaS origin), so it can key a `Set` or `Map`. `undefined` for a URL that + * isn't a credential-free HTTP(S) origin. + */ +export function serverKey(url: string): string | undefined { + const origin = normalizeHttpOrigin(url); + if (!origin) { + return; } - return ( - left === right || (isSaaSTrustOrigin(left) && isSaaSTrustOrigin(right)) - ); + return isSaaSTrustOrigin(origin) ? SAAS_SERVER_KEY : origin; } /** diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 314707d42..06e0682c4 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -23,7 +23,7 @@ import { isPersistedDefaultUrlInEffect, markPersistedDefaultUrl, } from "../constants.js"; -import { getEnv } from "../env.js"; +import { getEnv, withEnv } from "../env.js"; import { getBootConfiguredSentryUrl, getEnvTokenHost, @@ -936,6 +936,59 @@ export function selectAccountForOrg(orgSlug: string, orgId?: string): void { } } +/** A stored account a command can run as: its id and server. */ +export type StoredAccountRef = Readonly<{ id: string; host: string }>; + +/** + * Usable stored accounts a cross-account command (`sentry org list`) + * covers, most recently used first. An explicit server (`SENTRY_HOST`/ + * `SENTRY_URL`, `.sentryclirc`) limits them to accounts on that server, the + * same rule as {@link selectAccountForOrg}. Empty when a forced env token + * (`SENTRY_FORCE_ENV_TOKEN`) overrides stored accounts. + */ +export function listUsableAccountsInScope(): AccountRow[] { + if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() && getRawEnvToken()) { + return []; + } + const explicitHost = getExplicitServerUrl(); + return withDbSpan("listUsableAccountsInScope", () => + listUsableAccountRowsByRecency().filter((row) => { + const host = row.host ?? migrateNullHost(row); + return !explicitHost || isSameServer(host, explicitHost); + }), + ); +} + +/** + * Run `fn` as the stored `account`, on its own server, without touching the + * invocation's account selection or the stored active account. + * + * `fn` runs under a copy of the invocation env (see `withEnv`), so its + * account scope, memoized credentials, and configured server are its own: + * several accounts can be queried concurrently, and credential reads, token + * refreshes, and invalidation inside `fn` all target `account`. The URL is + * pinned to the account's server so its token is never sent to another, and + * env tokens are dropped so no other credential is sent to that server. + */ +export function withStoredAccount( + account: StoredAccountRef, + fn: () => Promise, +): Promise { + const env: NodeJS.ProcessEnv = { ...getEnv() }; + delete env.SENTRY_HOST; + env.SENTRY_URL = account.host; + // Only the account's own credentials: an env token (forced or as a + // fallback when the stored one is unusable) must never be sent to the + // account's server. + delete env.SENTRY_AUTH_TOKEN; + delete env.SENTRY_TOKEN; + delete env.SENTRY_FORCE_ENV_TOKEN; + return withEnv(env, () => { + getAccountScope().accountId = account.id; + return fn(); + }); +} + /** * The stored account whose credentials hash to `identity` (following * in-process refresh-token rotations), so cached org data can record which @@ -965,11 +1018,6 @@ export function getAccountIdForIdentity(identity: string): string | undefined { } } -/** - * Whether a usable stored account is on `url`'s server. Lets a pasted - * Sentry URL for another login's server select that login instead of - * failing the host check against the active account. - */ /** * Whether a usable stored account is on `url`'s server. Lets a pasted * Sentry URL for another login's server select that login instead of diff --git a/packages/cli/src/lib/formatters/json.ts b/packages/cli/src/lib/formatters/json.ts index 5b678b3bd..0ef4b5cdd 100644 --- a/packages/cli/src/lib/formatters/json.ts +++ b/packages/cli/src/lib/formatters/json.ts @@ -22,7 +22,7 @@ import type { Writer } from "../../types/index.js"; * @param obj - Source object to traverse * @param path - Key path: literal property name or dot-separated nesting */ -function getNestedValue( +export function getNestedValue( obj: unknown, path: string, ): { found: true; value: unknown } | { found: false } { diff --git a/packages/cli/src/lib/list-filter.ts b/packages/cli/src/lib/list-filter.ts new file mode 100644 index 000000000..00b76f301 --- /dev/null +++ b/packages/cli/src/lib/list-filter.ts @@ -0,0 +1,59 @@ +/** + * Name filters for list commands (`sentry org list `): a glob, or + * text matched with the shared fuzzy search. + */ + +import picomatch from "picomatch"; +import { fuzzyMatch } from "./fuzzy.js"; + +/** Glob metacharacters: a filter with any of them is matched as a glob. */ +const GLOB_CHARS_RE = /[*?[\]{}]/; + +/** Whether `filter` is a glob (`acme-*`) rather than plain text. */ +export function isGlobFilter(filter: string): boolean { + return GLOB_CHARS_RE.test(filter); +} + +/** + * Items with a name matching `filter`, ignoring case. `namesOf` gives each + * item's names (e.g. slug and display name). A glob must match a whole name + * and keeps the input order. Plain text uses {@link fuzzyMatch} search + * (exact, prefix, substring, then small typos), best matches first; ties + * keep the input order. An empty filter returns every item. + */ +export function filterByName( + items: readonly T[], + filter: string | undefined, + namesOf: (item: T) => readonly string[], +): T[] { + const needle = filter?.trim().toLowerCase(); + if (!needle) { + return [...items]; + } + if (isGlobFilter(needle)) { + const matches = picomatch(needle, { dot: true }); + return items.filter((item) => + namesOf(item).some((name) => matches(name.toLowerCase())), + ); + } + const candidates = [...new Set(items.flatMap((item) => namesOf(item)))]; + const rankOf = new Map( + fuzzyMatch(needle, candidates, { mode: "search" }).map((name, rank) => [ + name, + rank, + ]), + ); + // An item ranks by its best-matching name; the sort is stable. + return items + .map((item) => ({ + item, + rank: Math.min( + ...namesOf(item).map( + (name) => rankOf.get(name) ?? Number.POSITIVE_INFINITY, + ), + ), + })) + .filter(({ rank }) => rank !== Number.POSITIVE_INFINITY) + .sort((a, b) => a.rank - b.rank) + .map(({ item }) => item); +} diff --git a/packages/cli/test/commands/auth/login.test.ts b/packages/cli/test/commands/auth/login.test.ts index 5733ab681..b0da37917 100644 --- a/packages/cli/test/commands/auth/login.test.ts +++ b/packages/cli/test/commands/auth/login.test.ts @@ -83,7 +83,7 @@ vi.mock("../../../src/lib/logger.js", () => ({ })); // Dynamic import: must run AFTER vi.mock() so login.ts picks up fakeLog. -const { loginCommand, rcTokenHint } = +const { loginCommand, parseLoginEmail, rcTokenHint, splitLoginTarget } = await import("../../../src/commands/auth/login.js"); vi.mock("../../../src/lib/account-selection.js", async (importOriginal) => { @@ -429,6 +429,58 @@ describe("loginCommand.func --token path", () => { } }); + test(": logs in to that server, like --url", async () => { + isAuthenticatedSpy.mockReturnValue(false); + setAuthTokenSpy.mockReturnValue("new-account"); + getUserRegionsSpy.mockResolvedValue([]); + getCurrentUserSpy.mockResolvedValue(SAMPLE_USER); + setUserInfoSpy.mockReturnValue(undefined); + const pointDefaultUrlSpy = vi + .spyOn(accountSelection, "pointDefaultUrlAtAccount") + .mockReturnValue(undefined); + + try { + const { context } = createContext(); + await func.call( + context, + { token: "new-token", force: false, timeout: 900 }, + `${SAMPLE_USER.email}:other.example.com`, + ); + + expect(setAuthTokenSpy).toHaveBeenCalledWith( + "new-token", + undefined, + undefined, + expect.objectContaining({ host: "https://other.example.com" }), + ); + expect(pointDefaultUrlSpy).toHaveBeenCalledWith( + "https://other.example.com", + ); + } finally { + pointDefaultUrlSpy.mockRestore(); + } + }); + + test(": with a different --url is refused before anything is saved", async () => { + isAuthenticatedSpy.mockReturnValue(false); + + const { context } = createContext(); + await expect( + func.call( + context, + { + token: "new-token", + force: false, + timeout: 900, + url: "https://sentry.io", + }, + `${SAMPLE_USER.email}:other.example.com`, + ), + ).rejects.toBeInstanceOf(ValidationError); + + expect(setAuthTokenSpy).not.toHaveBeenCalled(); + }); + test("already authenticated (env token SENTRY_AUTH_TOKEN): warns and proceeds to OAuth login", async () => { isAuthenticatedSpy.mockReturnValue(true); isEnvTokenActiveSpy.mockReturnValue(true); @@ -923,6 +975,41 @@ describe("login re-authentication interactive prompt", () => { }); }); +describe("login email argument", () => { + test("accepts an email", () => { + expect(parseLoginEmail(" jane@acme.com ")).toBe("jane@acme.com"); + expect(splitLoginTarget("jane@acme.com")).toEqual({ + email: "jane@acme.com", + }); + }); + + test(": also names the server, with or without a scheme or port", () => { + expect(splitLoginTarget("jane@acme.com:sentry.acme.com")).toEqual({ + email: "jane@acme.com", + url: "https://sentry.acme.com", + }); + expect(splitLoginTarget("jane@acme.com:sentry.acme.com:9000")).toEqual({ + email: "jane@acme.com", + url: "https://sentry.acme.com:9000", + }); + expect(splitLoginTarget("jane@acme.com:http://localhost:8000")).toEqual({ + email: "jane@acme.com", + url: "http://localhost:8000", + }); + }); + + test.each([ + "jane", + "@acme.com", + "jane@", + "jane@:sentry.io", + "jane@acme.com:", + "jane@acme.com@sentry.io", + ])("rejects %s", (raw) => { + expect(() => parseLoginEmail(raw)).toThrow(ValidationError); + }); +}); + describe("applyLoginUrl (host resolution)", () => { let savedHost: string | undefined; let savedUrl: string | undefined; diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts new file mode 100644 index 000000000..40b11d4cd --- /dev/null +++ b/packages/cli/test/commands/org/list.test.ts @@ -0,0 +1,479 @@ +/** + * `sentry org list` across stored accounts, against a real test database + * and a fetch mock that answers per server and per token. + */ + +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listCommand } from "../../../src/commands/org/list.js"; +import { + getActiveAccountId, + getAuthToken, + selectAccountForOrg, + setActiveAccount, + setAuthToken, +} from "../../../src/lib/db/auth.js"; +import { resetAccountScopes } from "../../../src/lib/db/account-scope.js"; +import { getDatabase } from "../../../src/lib/db/index.js"; +import { setUserInfo } from "../../../src/lib/db/user.js"; +import { AuthError, ResolutionError } from "../../../src/lib/errors.js"; +import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; + +type ListFunc = ( + this: unknown, + flags: { + limit: number; + json: boolean; + fresh: boolean; + account?: string; + }, + filter?: string, +) => Promise; + +useTestConfigDir("org-list-"); +useEnvSandbox([ + "SENTRY_AUTH_TOKEN", + "SENTRY_TOKEN", + "SENTRY_FORCE_ENV_TOKEN", + "SENTRY_HOST", + "SENTRY_URL", +]); + +const SAAS = "https://sentry.io"; +const ACME = "https://sentry.acme.com"; + +type Org = { id: string; slug: string; name: string; regionUrl: string }; + +/** Orgs each token can see; a token missing here gets a 401. */ +let orgsByToken: Record; +/** ` ` for every org-list request, to check token routing. */ +let requests: string[]; +let originalFetch: typeof globalThis.fetch; + +function org(slug: string, regionUrl = SAAS, id = slug): Org { + return { id, slug, name: `${slug} name`, regionUrl }; +} + +beforeEach(() => { + orgsByToken = {}; + requests = []; + originalFetch = globalThis.fetch; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + const url = new URL(req.url); + const token = req.headers.get("Authorization")?.replace(/^Bearer /, ""); + if (!url.pathname.startsWith("/api/0/organizations/")) { + return Response.json({ detail: "Not found" }, { status: 404 }); + } + requests.push(`${url.origin} ${token}`); + const orgs = token ? orgsByToken[token] : undefined; + if (!orgs) { + return Response.json({ detail: "Invalid token" }, { status: 401 }); + } + return Response.json( + orgs.map((o) => ({ + id: o.id, + slug: o.slug, + name: o.name, + links: { organizationUrl: o.regionUrl, regionUrl: o.regionUrl }, + })), + ); + }) as typeof globalThis.fetch; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function addAccount(token: string, host: string, email: string): string { + const id = setAuthToken(token, undefined, undefined, { + host, + newAccount: true, + }); + setUserInfo({ userId: email, email }); + return id; +} + +function createContext() { + const chunks: string[] = []; + return { + context: { + stdout: { write: vi.fn((s: string) => chunks.push(s)) }, + stderr: { write: vi.fn(() => true) }, + cwd: "/tmp", + }, + getOutput: () => chunks.join(""), + }; +} + +async function runList( + flags: { account?: string; limit?: number } = {}, + filter?: string, +): Promise[]> { + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + await func.call( + context, + { + limit: flags.limit ?? 100, + json: true, + fresh: true, + ...(flags.account ? { account: flags.account } : {}), + }, + filter, + ); + return JSON.parse(getOutput()) as Record[]; +} + +async function runListHuman(filter?: string): Promise { + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + await func.call(context, { limit: 100, json: false, fresh: true }, filter); + return getOutput(); +} + +function slugsWithAccount(entries: Record[]): string[][] { + return entries.map((e) => [ + e.slug as string, + ((e.account as { email?: string } | undefined)?.email ?? "") as string, + ]); +} + +describe("org list with one stored account", () => { + test("lists that account's orgs without account info", async () => { + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha"), org("beta")]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["alpha", "beta"]); + expect(entries.every((e) => e.account === undefined)).toBe(true); + expect(requests).toEqual([`${SAAS} saas-token`]); + }); +}); + +describe("org list across stored accounts", () => { + test("lists every account's orgs, each fetched with its own token on its own server", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([ + ["alpha", "jane@example.com"], + ["acme-internal", "jane@acme.com"], + ]); + expect(entries[1]?.account).toEqual({ + name: "jane@acme.com:sentry.acme.com", + id: expect.any(String), + email: "jane@acme.com", + server: "sentry.acme.com", + host: ACME, + label: "jane@acme.com on sentry.acme.com", + }); + expect(requests.sort()).toEqual( + [`${SAAS} saas-token`, `${ACME} acme-token`].sort(), + ); + }); + + test("caches each account's orgs, so later commands route to the right account", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + await runList(); + resetAccountScopes(); + selectAccountForOrg("acme-internal"); + + expect(getAuthToken()).toBe("acme-token"); + }); + + test("never changes the stored active account", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + const saas = addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + await runList(); + + expect(getActiveAccountId()).toBe(saas); + }); + + test("an org reachable from two accounts on the same server is listed once, under the active account", async () => { + const work = addAccount("work-token", SAAS, "jane@work.com"); + addAccount("personal-token", SAAS, "jane@example.com"); + setActiveAccount(work); + orgsByToken["work-token"] = [org("shared"), org("work-only")]; + orgsByToken["personal-token"] = [org("shared"), org("personal-only")]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([ + ["shared", "jane@work.com"], + ["work-only", "jane@work.com"], + ["personal-only", "jane@example.com"], + ]); + }); + + test("the same slug on different servers is listed for each", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("acme")]; + orgsByToken["acme-token"] = [org("acme", ACME)]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([ + ["acme", "jane@example.com"], + ["acme", "jane@acme.com"], + ]); + }); + + test("an account whose login is rejected is skipped; the others are still listed", async () => { + addAccount("revoked-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + + const entries = await runList(); + + expect(slugsWithAccount(entries)).toEqual([["alpha", "jane@example.com"]]); + // Only the rejected account's credentials are touched. + expect(getAuthToken()).toBe("saas-token"); + }); + + test("fails when every account fails", async () => { + addAccount("revoked-a", ACME, "jane@acme.com"); + addAccount("revoked-b", SAAS, "jane@example.com"); + + await expect(runList()).rejects.toThrow(); + }); + + test("an explicit server limits the list to accounts on it", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + process.env.SENTRY_URL = ACME; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests).toEqual([`${ACME} acme-token`]); + }); + + test("skips accounts that are logged out", async () => { + const acme = addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(acme); + orgsByToken["saas-token"] = [org("alpha")]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["alpha"]); + expect(requests).toEqual([`${SAAS} saas-token`]); + }); + + test("lists the one usable account when the active login is logged out", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + const saas = addAccount("saas-token", SAAS, "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(saas); + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests).toEqual([`${ACME} acme-token`]); + expect(getActiveAccountId()).toBe(saas); + }); + + test("--limit applies to the merged list", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha"), org("beta")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList({ limit: 2 }); + + expect(entries.map((e) => e.slug)).toEqual(["alpha", "beta"]); + }); + + test("human output adds an ACCOUNT column", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const out = await runListHuman(); + + expect(out).toContain("ACCOUNT"); + // The notation --account and `sentry auth login` accept. + expect(out).toContain("jane@acme.com:sentry.acme.com"); + expect(out).toContain("jane@example.com:sentry.io"); + expect(out).not.toContain("REGION"); + }); +}); + +describe("org list filter", () => { + beforeEach(() => { + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [ + { id: "1", slug: "acme-prod", name: "Acme Production", regionUrl: SAAS }, + { id: "2", slug: "acme-dev", name: "Acme Dev", regionUrl: SAAS }, + { id: "3", slug: "globex", name: "Globex Corp", regionUrl: SAAS }, + ]; + }); + + async function slugsFor( + filter: string, + flags: { limit?: number } = {}, + ): Promise { + return (await runList(flags, filter)).map((e) => e.slug as string); + } + + test("matches the slug or name, ignoring case", async () => { + expect(await slugsFor("PROD")).toEqual(["acme-prod"]); + expect(await slugsFor("corp")).toEqual(["globex"]); + }); + + test("uses the CLI's fuzzy matching, so small typos still match", async () => { + expect(await slugsFor("globx")).toEqual(["globex"]); + // The closest match leads; other near matches follow it. + expect((await slugsFor("acme-prd"))[0]).toBe("acme-prod"); + expect(await slugsFor("initech")).toEqual([]); + }); + + test("lists the best matches first: exact, prefix, substring, then typos", async () => { + orgsByToken["saas-token"] = [ + org("my-sentry"), + org("sentry-eu"), + org("sentri"), + org("sentry"), + ]; + + expect(await slugsFor("sentry")).toEqual([ + "sentry", + "sentry-eu", + "my-sentry", + "sentri", + ]); + }); + + test("a glob must match the whole slug or name", async () => { + expect(await slugsFor("acme-*")).toEqual(["acme-prod", "acme-dev"]); + expect(await slugsFor("*corp")).toEqual(["globex"]); + expect(await slugsFor("acme?")).toEqual([]); + }); + + test("--limit applies after filtering", async () => { + expect(await slugsFor("acme", { limit: 1 })).toHaveLength(1); + expect(await slugsFor("acme-dev", { limit: 1 })).toEqual(["acme-dev"]); + }); + + test("no match says how many organizations were filtered out", async () => { + const out = await runListHuman("initech"); + + expect(out).toContain("No organizations found."); + expect(out).toContain("None of your 3 organizations match 'initech'"); + }); +}); + +describe("org list --account", () => { + test("lists only that account's orgs, with its own token", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList({ account: "jane@acme.com" }); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(entries[0]?.account).toBeUndefined(); + expect(requests).toEqual([`${ACME} acme-token`]); + }); + + test("uses the account's own token even when an env token is forced", async () => { + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + orgsByToken["env-token"] = [org("env-org")]; + + const entries = await runList({ account: "jane@acme.com" }); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests).toEqual([`${ACME} acme-token`]); + }); + + test(": picks one of the same email's accounts", async () => { + addAccount("acme-token", ACME, "jane@example.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + await expect(runList({ account: "jane@example.com" })).rejects.toThrow( + ResolutionError, + ); + const entries = await runList({ + account: "jane@example.com:sentry.acme.com", + }); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests).toEqual([`${ACME} acme-token`]); + }); + + test("an unknown account is an error", async () => { + addAccount("saas-token", SAAS, "jane@example.com"); + + await expect(runList({ account: "nobody@nowhere.dev" })).rejects.toThrow( + ResolutionError, + ); + }); + + test("a logged-out account is an auth error naming how to log back in", async () => { + const acme = addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(acme); + + const error = await runList({ account: "jane@acme.com" }).catch( + (e: unknown) => e, + ); + + expect(error).toBeInstanceOf(AuthError); + // `:` logs in to that account's server. + expect((error as AuthError).message).toContain( + "sentry auth login jane@acme.com:sentry.acme.com", + ); + expect(requests).toEqual([]); + }); + + test("the log-in-again hint for an HTTP server keeps the scheme", async () => { + const local = addAccount( + "local-token", + "http://localhost:8000", + "jane@local.dev", + ); + addAccount("saas-token", SAAS, "jane@example.com"); + getDatabase() + .query("UPDATE accounts SET token = NULL WHERE account_id = ?") + .run(local); + + const error = await runList({ + account: "jane@local.dev:http://localhost:8000", + }).catch((e: unknown) => e); + + expect(error).toBeInstanceOf(AuthError); + expect((error as AuthError).message).toContain( + "sentry auth login jane@local.dev:http://localhost:8000", + ); + }); +}); diff --git a/packages/cli/test/lib/db/accounts.test.ts b/packages/cli/test/lib/db/accounts.test.ts index 6eda014a0..60e86f01f 100644 --- a/packages/cli/test/lib/db/accounts.test.ts +++ b/packages/cli/test/lib/db/accounts.test.ts @@ -8,10 +8,15 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { + accountSelectorOf, + accountServerOf, + splitAccountSelector, findAccounts, getAccountRow, getActiveAccountRow, + isSameServer, listAccounts, + serverKey, } from "../../../src/lib/db/accounts.js"; import { getActiveAccountId, @@ -275,4 +280,114 @@ describe("findAccounts", () => { findAccounts("jane@example.com@sentry.example.com").map((a) => a.id), ).toEqual([selfHosted]); }); + + test("narrows with :, the notation accountSelectorOf shows", () => { + const saas = login("saas", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + const selfHosted = login("self", "https://sentry.example.com:9000", { + userId: "1", + email: "jane@example.com", + username: "jane", + }); + + const [saasAccount, selfAccount] = [saas, selfHosted].map((id) => + listAccounts().find((a) => a.id === id), + ); + expect(saasAccount && accountSelectorOf(saasAccount)).toBe( + "jane@example.com:sentry.io", + ); + expect(selfAccount && accountSelectorOf(selfAccount)).toBe( + "jane@example.com:sentry.example.com:9000", + ); + expect(selfAccount && accountServerOf(selfAccount)).toBe( + "sentry.example.com:9000", + ); + for (const selector of [ + "jane@example.com:sentry.io", + "jane@example.com:us.sentry.io", + "JANE@example.com:https://sentry.io", + ]) { + expect(findAccounts(selector).map((a) => a.id)).toEqual([saas]); + } + for (const selector of [ + "jane@example.com:sentry.example.com:9000", + "jane:sentry.example.com:9000", + ]) { + expect(findAccounts(selector).map((a) => a.id)).toEqual([selfHosted]); + } + expect(findAccounts("jane@example.com:sentry.example.com")).toEqual([]); + }); + + test("an HTTP account's selector keeps its scheme, so it round-trips", () => { + login("saas", "https://sentry.io", { + userId: "1", + email: "jane@example.com", + }); + const local = login("local", "http://localhost:8000", { + userId: "1", + email: "jane@example.com", + }); + + const account = listAccounts().find((a) => a.id === local); + const selector = account && accountSelectorOf(account); + expect(selector).toBe("jane@example.com:http://localhost:8000"); + expect(account && accountServerOf(account)).toBe("http://localhost:8000"); + expect(findAccounts(selector ?? "").map((a) => a.id)).toEqual([local]); + // A bare host means HTTPS, which is a different server. + expect(findAccounts("jane@example.com:localhost:8000")).toEqual([]); + }); +}); + +describe("serverKey", () => { + test("is shared by every SaaS origin, and otherwise by the same origin only", () => { + const groups = [ + [ + "https://sentry.io", + "https://us.sentry.io", + "https://de.sentry.io/api/0/", + ], + ["https://sentry.example.com", "https://sentry.example.com/path"], + ["https://sentry.example.com:9000"], + ["http://sentry.example.com"], + ]; + const keys = groups.map((urls) => new Set(urls.map(serverKey))); + for (const set of keys) { + expect(set.size).toBe(1); + expect(set.has(undefined)).toBe(false); + } + expect(new Set(keys.flatMap((set) => [...set])).size).toBe(groups.length); + expect(isSameServer("https://us.sentry.io", "https://sentry.io")).toBe( + true, + ); + expect(serverKey("not a url")).toBeUndefined(); + expect(isSameServer("not a url", "not a url")).toBe(false); + }); +}); + +describe("splitAccountSelector", () => { + test("splits on the first colon; the server may have a scheme or port", () => { + expect(splitAccountSelector(" jane@acme.com ")).toEqual({ + who: "jane@acme.com", + }); + expect(splitAccountSelector("jane@acme.com:sentry.acme.com")).toEqual({ + who: "jane@acme.com", + server: "https://sentry.acme.com", + }); + expect(splitAccountSelector("jane:sentry.acme.com:9000")).toEqual({ + who: "jane", + server: "https://sentry.acme.com:9000", + }); + expect(splitAccountSelector("jane@acme.com:http://localhost:8000")).toEqual( + { who: "jane@acme.com", server: "http://localhost:8000" }, + ); + }); + + test.each([":sentry.io", "jane@acme.com:", "jane@acme.com: "])( + "rejects %s", + (selector) => { + expect(splitAccountSelector(selector)).toBeUndefined(); + }, + ); }); diff --git a/packages/cli/test/lib/list-filter.test.ts b/packages/cli/test/lib/list-filter.test.ts new file mode 100644 index 000000000..ea40c7f70 --- /dev/null +++ b/packages/cli/test/lib/list-filter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "vitest"; +import { filterByName, isGlobFilter } from "../../src/lib/list-filter.js"; + +type Item = { slug: string; name: string }; + +const ITEMS: Item[] = [ + { slug: "acme-prod", name: "Acme Production" }, + { slug: "acme-dev", name: "Acme Development" }, + { slug: "widgets", name: "Widget Co" }, +]; + +const namesOf = (item: Item) => [item.slug, item.name]; +const slugs = (items: Item[]) => items.map((item) => item.slug); + +describe("filterByName", () => { + test("an empty filter keeps every item", () => { + expect(slugs(filterByName(ITEMS, undefined, namesOf))).toEqual( + slugs(ITEMS), + ); + expect(slugs(filterByName(ITEMS, " ", namesOf))).toEqual(slugs(ITEMS)); + }); + + test("a glob matches any whole name, ignoring case, in input order", () => { + expect(slugs(filterByName(ITEMS, "ACME-*", namesOf))).toEqual([ + "acme-prod", + "acme-dev", + ]); + expect(slugs(filterByName(ITEMS, "widget*", namesOf))).toEqual(["widgets"]); + expect(filterByName(ITEMS, "acme?", namesOf)).toEqual([]); + }); + + test("plain text is a fuzzy search, best match first", () => { + expect(slugs(filterByName(ITEMS, "acme-dev", namesOf))[0]).toBe("acme-dev"); + expect(slugs(filterByName(ITEMS, "widgts", namesOf))).toEqual(["widgets"]); + }); +}); + +describe("isGlobFilter", () => { + test.each([ + ["acme-*", true], + ["acme-?", true], + ["{a,b}", true], + ["acme", false], + ])("%s → %s", (filter, expected) => { + expect(isGlobFilter(filter)).toBe(expected); + }); +});