From c53536ed4427bb9dfee9ec3db305c958acc88ca5 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:34:51 +0000 Subject: [PATCH 1/7] feat(cli): List organizations across all stored accounts With several stored accounts, `sentry org list` now lists the organizations of every logged-in account together. Each account is queried concurrently with its own credentials on its own server (`withStoredAccount`, an isolated env copy with its own account scope), so tokens never cross servers and the stored active account is unchanged. Human output adds an ACCOUNT column and JSON entries carry an `account` object. - An org 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. - An account whose request fails is skipped with a warning; the command only fails when every account does. - An explicit server (SENTRY_HOST/SENTRY_URL, .sentryclirc) limits the list to accounts on it; `--account ` limits it to one account. - Forced env tokens and single-account setups keep the previous output. Listing also caches each account's org list, which feeds automatic account selection for org-scoped commands. Co-Authored-By: byk --- apps/cli-docs/src/fragments/commands/auth.md | 7 +- apps/cli-docs/src/fragments/commands/org.md | 20 ++ .../skills/sentry-cli/references/org.md | 4 + packages/cli/src/commands/org/list.ts | 258 ++++++++++++-- packages/cli/src/lib/db/auth.ts | 53 ++- packages/cli/test/commands/org/list.test.ts | 335 ++++++++++++++++++ 6 files changed, 648 insertions(+), 29 deletions(-) create mode 100644 packages/cli/test/commands/org/list.test.ts diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 5e5a3a2f6..9a10277d5 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -97,9 +97,10 @@ 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..ad660bac7 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -13,6 +13,26 @@ 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. 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. + +``` +$ sentry org list +SLUG NAME ACCOUNT +my-org My Organization jane@example.com on sentry.io +acme-internal Acme Internal jane@acme.com on sentry.acme.com +``` + +```bash +# Only one account's organizations +sentry org list --account jane@acme.com +``` + ```bash # View organization details sentry org view my-org 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..a99dde68f 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 @@ -18,6 +18,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "25")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` +- `--account - Only list organizations of this stored account (email, username, or account id)` ### `sentry org view []` @@ -33,6 +34,9 @@ View details of an organization # List organizations sentry org list +# Only one account's organizations +sentry org list --account jane@acme.com + # View organization details sentry org view my-org diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 5a6a43eb5..cf5ec03e8 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -1,14 +1,27 @@ /** * 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, + isSameServer, + 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 { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; @@ -26,17 +39,40 @@ 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 = { + id: string; + host?: string; + email?: string; + username?: string; + /** Human label, e.g. `jane@acme.com 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. @@ -89,12 +125,19 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { } const showRegion = entries.some((e) => e.region !== undefined); + const showAccount = entries.some((e) => e.account !== undefined); - type OrgRow = { slug: string; name: string; region?: string }; + type OrgRow = { + slug: string; + name: string; + region?: string; + account?: string; + }; const rows: OrgRow[] = entries.map((org) => ({ slug: org.slug, name: org.name, region: showRegion ? (org.region ?? "") : undefined, + account: org.account?.label, })); const columns: Column[] = [ @@ -103,6 +146,14 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }] : []), { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, + ...(showAccount + ? [ + { + header: "ACCOUNT", + value: (r: OrgRow) => escapeMarkdownCell(r.account ?? ""), + }, + ] + : []), ]; const parts: string[] = []; @@ -112,14 +163,151 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { return parts.join("").trimEnd(); } +function toOrgListAccount(account: AccountSummary): OrgListAccount { + return { + id: account.id, + ...(account.host ? { host: account.host } : {}), + ...(account.email ? { email: account.email } : {}), + ...(account.username ? { username: account.username } : {}), + label: formatAccountLabel(account), + }; +} + +/** 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. */ +function fetchAccountOrgs(account: AccountSummary): Promise { + const host = account.host; + if (!host) { + return Promise.reject( + new AuthError("not_authenticated", "Account has no server recorded.", { + skipAutoAuth: true, + }), + ); + } + return 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; + settled.forEach((result, i) => { + if (result.status === "fulfilled") { + fetched.push(result.value); + return; + } + 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(); + const seen: { host: string; slug: string }[] = []; + for (const { account, orgs: accountOrgs, regions } of results) { + for (const org of accountOrgs) { + const host = account?.host; + if ( + host && + seen.some((s) => s.slug === org.slug && isSameServer(s.host, host)) + ) { + continue; + } + if (host) { + seen.push({ host, slug: org.slug }); + } + orgs.push(org); + accountOf.set(org, account); + regionOf.set(org, regions.get(org.slug)); + } + } + return { orgs, accountOf, regionOf }; +} + +/** + * Which stored accounts to list: the one named by `--account`, else every + * usable account in scope. `undefined` means "the credentials in effect" + * (one or no stored account, or a forced env token), which keeps the + * single-account output unchanged. + */ +function resolveAccounts(selector?: string): AccountSummary[] | undefined { + if (selector !== undefined) { + const account = resolveAccountSelector( + selector, + "sentry org list --account", + { + fuzzy: true, + }, + ); + if (!account.usable) { + const who = account.email ?? account.username; + const relogin = who ? `sentry auth login ${who}` : "sentry auth login"; + throw new AuthError( + "expired", + `${formatAccountLabel(account)} is logged out. Run '${relogin}'.`, + { 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)); + return accounts.length > 1 ? 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\n" + "Examples:\n" + " sentry org list\n" + " sentry org list --limit 10\n" + + " sentry org list --account jane@example.com\n" + " sentry org list --json\n\n" + "Alias: `sentry orgs` → `sentry org list`", }, @@ -128,6 +316,13 @@ 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)", + optional: true, + }, }, // Only -n for --limit; no -c since org list has no --cursor flag aliases: { ...FRESH_ALIASES, n: "limit" }, @@ -135,26 +330,49 @@ export const listCommand = buildCommand({ async *func(this: SentryContext, flags: ListFlags) { 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 { orgs, accountOf, regionOf } = mergeAccountOrgs(results); 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; - - const entries: OrgListEntry[] = limitedOrgs.map((org) => ({ - ...org, - region: showRegion - ? getRegionDisplayName(orgRegions.get(org.slug) ?? "") - : undefined, - })); + // 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) => { + 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) { diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 314707d42..44d02cac9 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,52 @@ 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. + */ +export function withStoredAccount( + account: StoredAccountRef, + fn: () => Promise, +): Promise { + const env: NodeJS.ProcessEnv = { ...getEnv() }; + delete env.SENTRY_HOST; + env.SENTRY_URL = account.host; + 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 +1011,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/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts new file mode 100644 index 000000000..fd281ca3e --- /dev/null +++ b/packages/cli/test/commands/org/list.test.ts @@ -0,0 +1,335 @@ +/** + * `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; + }, +) => 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 } = {}, +): 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 } : {}), + }); + return JSON.parse(getOutput()) as Record[]; +} + +async function runListHuman(): Promise { + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + await func.call(context, { limit: 100, json: false, fresh: true }); + 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).toMatchObject({ + 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("--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"); + expect(out).toContain("sentry.acme.com"); + expect(out).not.toContain("REGION"); + }); +}); + +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("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); + expect((error as AuthError).message).toContain( + "sentry auth login jane@acme.com", + ); + expect(requests).toEqual([]); + }); +}); From dd3de5a9c2342af9655e81c241140670f84f53fa Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:42:15 +0000 Subject: [PATCH 2/7] fix(cli): List a lone usable account and keep env tokens off account hosts When the active login is logged out and exactly one other stored account is usable, org list now queries that account instead of falling back to the invocation credentials. withStoredAccount drops SENTRY_AUTH_TOKEN, SENTRY_TOKEN, and SENTRY_FORCE_ENV_TOKEN so a forced env token is never sent to the selected account's server. --- packages/cli/src/commands/org/list.ts | 9 ++++--- packages/cli/src/lib/db/auth.ts | 9 ++++++- packages/cli/test/commands/org/list.test.ts | 29 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index cf5ec03e8..c5f1c6e96 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -261,8 +261,10 @@ function mergeAccountOrgs(results: AccountOrgs[]): { /** * Which stored accounts to list: the one named by `--account`, else every * usable account in scope. `undefined` means "the credentials in effect" - * (one or no stored account, or a forced env token), which keeps the - * single-account output unchanged. + * (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) { @@ -289,7 +291,8 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { const accounts = listUsableAccountsInScope() .map(toAccountSummary) .sort((a, b) => Number(b.active) - Number(a.active)); - return accounts.length > 1 ? accounts : undefined; + const [only] = accounts; + return accounts.length > 1 || (only && !only.active) ? accounts : undefined; } export const listCommand = buildCommand({ diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 44d02cac9..06e0682c4 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -967,7 +967,8 @@ export function listUsableAccountsInScope(): AccountRow[] { * 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. + * 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, @@ -976,6 +977,12 @@ export function withStoredAccount( 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(); diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index fd281ca3e..5a43ff83d 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -268,6 +268,21 @@ describe("org list across stored accounts", () => { 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"); @@ -307,6 +322,20 @@ describe("org list --account", () => { 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("an unknown account is an error", async () => { addAccount("saas-token", SAAS, "jane@example.com"); From 8e7911977baea0260c4f1db45ccb5f79220f54eb Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:32:18 +0000 Subject: [PATCH 3/7] feat(cli): Filter org list and name accounts as : Address review feedback on org list across stored accounts: - `sentry org list []` lists only orgs whose slug or name contains the text (case-insensitive) or matches it as a glob. - `-a` is a shorthand for `--account`. - Account selectors accept `:` (e.g. `jane@acme.com:sentry.acme.com`) to tell apart the same email on several servers, for `--account`, `auth switch`, and `auth logout`. The ACCOUNT column and JSON `account.selector` show that notation. - `sentry auth login :` sets the server like `--url`; a conflicting `--url` is refused. The logged-out hint uses it. - Dedupe merged orgs with a Set keyed by a normalized server key (`serverKey`, which `isSameServer` now uses). - Table columns come from a header -> value map; account JSON fields are copied from a field list; `fetchAccountOrgs` is async and awaits `withStoredAccount` so stack traces stay intact; no `forEach`. --- apps/cli-docs/src/fragments/commands/auth.md | 5 +- apps/cli-docs/src/fragments/commands/org.md | 17 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 2 +- .../skills/sentry-cli/references/org.md | 4 +- packages/cli/src/commands/auth/login.ts | 101 +++++++-- packages/cli/src/commands/org/list.ts | 197 ++++++++++++------ packages/cli/src/lib/account-selection.ts | 5 +- packages/cli/src/lib/db/accounts.ts | 78 ++++++- packages/cli/test/commands/auth/login.test.ts | 89 +++++++- packages/cli/test/commands/org/list.test.ts | 94 ++++++++- packages/cli/test/lib/db/accounts.test.ts | 65 ++++++ 11 files changed, 543 insertions(+), 114 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 9a10277d5..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,7 +91,9 @@ 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 diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index ad660bac7..71b1dd7fc 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -15,7 +15,9 @@ 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. An +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, @@ -24,13 +26,22 @@ and `--account` to a single account. The stored active account never changes. ``` $ sentry org list SLUG NAME ACCOUNT -my-org My Organization jane@example.com on sentry.io -acme-internal Acme Internal jane@acme.com on sentry.acme.com +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 contains "acme" (case-insensitive) +sentry org list acme +# ...or matches a glob +sentry org list 'acme-*' ``` ```bash 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/org.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md index a99dde68f..7f600e4d4 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,14 +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` -- `--account - Only list organizations of this stored account (email, username, or account id)` +- `-a, --account - Only list organizations of this stored account (email, username, or account id; : for one server)` ### `sentry org view []` diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index d62e12325..05fc93bd1 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -154,20 +154,82 @@ 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 normalized server origin. + * + * @throws {ValidationError} when either part is invalid + */ +export function splitLoginTarget( + target: string, + raw = target, +): { email: string; url?: string } { + const at = target.indexOf("@"); + // The separator is the first `:` after the `@`; the server part may + // itself contain `:` (a scheme or port). + const colon = at > 0 ? target.indexOf(":", at) : -1; + const email = colon > 0 ? target.slice(0, colon) : target; + if ( + at <= 0 || + at === email.length - 1 || + email.indexOf("@", at + 1) !== -1 || + /\s/.test(email) + ) { + throw new ValidationError( + `Expected an email address (or :) to log in as, got '${raw}'.`, + "email", + ); + } + if (colon < 0) { + return { email }; + } + const url = normalizeOrigin(normalizeUrl(target.slice(colon + 1))); + if (!url) { throw new ValidationError( - `Expected an email address to log in as, got '${raw}'.`, + `Expected : with a valid server, got '${raw}'.`, "email", ); } - return email; + return { email, url }; +} + +/** + * 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 +532,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 +602,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 +610,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 c5f1c6e96..14348ccf2 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -5,6 +5,7 @@ * when several are logged in. */ +import picomatch from "picomatch"; import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; @@ -12,7 +13,8 @@ import { buildCommand } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { type AccountSummary, - isSameServer, + accountSelectorOf, + serverKey, toAccountSummary, } from "../../lib/db/accounts.js"; import { @@ -49,10 +51,15 @@ type OrgListAccount = { host?: string; email?: string; username?: string; - /** Human label, e.g. `jane@acme.com on sentry.io`. */ + /** Human label, e.g. `Jane on sentry.io`. */ label: string; + /** What `--account` accepts, e.g. `jane@acme.com:sentry.io`. */ + selector: string; }; +/** Optional account fields copied into {@link OrgListAccount} when set. */ +const ORG_LIST_ACCOUNT_FIELDS = ["host", "email", "username"] as const; + /** * An organization enriched with an optional region display name and the * stored account it was listed under. @@ -113,6 +120,20 @@ function getRegionDisplayName(regionUrl: string): string { } } +/** Table columns in display order: header → the entry's cell value. */ +const ORG_LIST_COLUMNS: Record< + string, + (e: OrgListEntry) => string | undefined +> = { + SLUG: (e) => e.slug, + REGION: (e) => e.region, + NAME: (e) => e.name, + ACCOUNT: (e) => e.account?.selector, +}; + +/** 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. * @@ -124,53 +145,38 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { return "No organizations found."; } - const showRegion = entries.some((e) => e.region !== undefined); - const showAccount = entries.some((e) => e.account !== undefined); - - type OrgRow = { - slug: string; - name: string; - region?: string; - account?: string; - }; - const rows: OrgRow[] = entries.map((org) => ({ - slug: org.slug, - name: org.name, - region: showRegion ? (org.region ?? "") : undefined, - account: org.account?.label, - })); - - const columns: Column[] = [ - { header: "SLUG", value: (r) => r.slug }, - ...(showRegion - ? [{ header: "REGION", value: (r: OrgRow) => r.region ?? "" }] - : []), - { header: "NAME", value: (r) => escapeMarkdownCell(r.name) }, - ...(showAccount - ? [ - { - header: "ACCOUNT", - value: (r: OrgRow) => escapeMarkdownCell(r.account ?? ""), - }, - ] - : []), - ]; + // REGION and ACCOUNT only appear when some entry has one. + const columns: Column[] = Object.entries(ORG_LIST_COLUMNS) + .filter( + ([header, value]) => + !OPTIONAL_ORG_LIST_COLUMNS.has(header) || + entries.some((e) => value(e) !== undefined), + ) + .map(([header, value]) => ({ + header, + value: (e) => escapeMarkdownCell(value(e) ?? ""), + })); 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 { - return { + const entry: OrgListAccount = { id: account.id, - ...(account.host ? { host: account.host } : {}), - ...(account.email ? { email: account.email } : {}), - ...(account.username ? { username: account.username } : {}), label: formatAccountLabel(account), + selector: accountSelectorOf(account), }; + for (const field of ORG_LIST_ACCOUNT_FIELDS) { + const value = account[field]; + if (value) { + entry[field] = value; + } + } + return entry; } /** Fetch orgs (and their cached regions) with the credentials in effect. */ @@ -180,16 +186,18 @@ async function fetchOrgs(account?: AccountSummary): Promise { } /** Fetch `account`'s orgs with its own credentials, on its own server. */ -function fetchAccountOrgs(account: AccountSummary): Promise { +async function fetchAccountOrgs(account: AccountSummary): Promise { const host = account.host; if (!host) { - return Promise.reject( - new AuthError("not_authenticated", "Account has no server recorded.", { - skipAutoAuth: true, - }), + throw new AuthError( + "not_authenticated", + "Account has no server recorded.", + { skipAutoAuth: true }, ); } - return withStoredAccount({ id: account.id, host }, () => fetchOrgs(account)); + return await withStoredAccount({ id: account.id, host }, () => + fetchOrgs(account), + ); } /** @@ -203,10 +211,10 @@ async function fetchOrgsAcrossAccounts( const settled = await Promise.allSettled(accounts.map(fetchAccountOrgs)); const fetched: AccountOrgs[] = []; let firstError: unknown; - settled.forEach((result, i) => { + for (const [i, result] of settled.entries()) { if (result.status === "fulfilled") { fetched.push(result.value); - return; + continue; } const error = result.reason; if (!(error instanceof AuthError || error instanceof ApiError)) { @@ -217,7 +225,7 @@ async function fetchOrgsAcrossAccounts( log.warn( `Skipped ${account ? formatAccountLabel(account) : "an account"}: ${error.message}`, ); - }); + } if (fetched.length === 0 && firstError !== undefined) { throw firstError; } @@ -237,18 +245,17 @@ function mergeAccountOrgs(results: AccountOrgs[]): { const orgs: SentryOrganization[] = []; const accountOf = new Map(); const regionOf = new Map(); - const seen: { host: string; slug: string }[] = []; + // ` ` 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) { - const host = account?.host; - if ( - host && - seen.some((s) => s.slug === org.slug && isSameServer(s.host, host)) - ) { - continue; - } - if (host) { - seen.push({ host, slug: org.slug }); + if (server) { + const key = `${server} ${org.slug}`; + if (seen.has(key)) { + continue; + } + seen.add(key); } orgs.push(org); accountOf.set(org, account); @@ -258,6 +265,43 @@ function mergeAccountOrgs(results: AccountOrgs[]): { return { orgs, accountOf, regionOf }; } +/** Glob metacharacters: a filter with any of them is matched as a glob. */ +const GLOB_CHARS_RE = /[*?[\]{}]/; + +/** + * Orgs whose slug or name matches `filter`, case-insensitively: a glob + * (`acme-*`) must match the whole slug or name, plain text may appear + * anywhere in it. + */ +function filterOrgs( + orgs: SentryOrganization[], + filter: string | undefined, +): SentryOrganization[] { + const needle = filter?.trim().toLowerCase(); + if (!needle) { + return orgs; + } + const matches = GLOB_CHARS_RE.test(needle) + ? picomatch(needle, { dot: true }) + : (value: string) => value.includes(needle); + return orgs.filter( + (org) => matches(org.slug.toLowerCase()) || matches(org.name.toLowerCase()), + ); +} + +/** + * 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" @@ -276,11 +320,9 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { }, ); if (!account.usable) { - const who = account.email ?? account.username; - const relogin = who ? `sentry auth login ${who}` : "sentry auth login"; throw new AuthError( "expired", - `${formatAccountLabel(account)} is logged out. Run '${relogin}'.`, + `${formatAccountLabel(account)} is logged out. Run '${reloginCommand(account)}'.`, { skipAutoAuth: true }, ); } @@ -306,11 +348,18 @@ export const listCommand = buildCommand({ "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\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 contains it\n" + + "(case-insensitive), or matches it when it is 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`", }, @@ -323,14 +372,26 @@ export const listCommand = buildCommand({ kind: "parsed", parse: String, brief: - "Only list organizations of this stored account (email, username, or account id)", + "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 contains 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 accounts = resolveAccounts(flags.account); @@ -351,7 +412,9 @@ export const listCommand = buildCommand({ : await Promise.all(accounts.map(fetchAccountOrgs)); }, ); - const { orgs, accountOf, regionOf } = mergeAccountOrgs(results); + const merged = mergeAccountOrgs(results); + const { accountOf, regionOf } = merged; + const orgs = filterOrgs(merged.orgs, filter); const limitedOrgs = orgs.slice(0, flags.limit); // Show regions when one login's orgs span several (e.g. US and EU). @@ -383,6 +446,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..c733fc71d 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,50 @@ export function listAccounts(): AccountSummary[] { return listAccountRows().map(toAccountSummary); } +/** + * Split a `:` account selector (e.g. + * `jane@acme.com:sentry.acme.com`) into its identity and server origin. + * The server may be a bare host (with port) or a URL. `undefined` when the + * selector has no `:` or either side is empty or not a valid server. + */ +export function parseAccountServerSelector( + selector: string, +): { who: string; server: string } | undefined { + const trimmed = selector.trim(); + const colon = trimmed.indexOf(":"); + if (colon <= 0) { + return; + } + const who = trimmed.slice(0, colon); + const server = normalizeHttpOrigin(normalizeUrl(trimmed.slice(colon + 1))); + return 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 host = serverOf(account.host); + return host ? `${who}:${host}` : who; +} + +/** `host[:port]` of a stored account origin, lowercased. */ +function serverOf(host: string | undefined): string | undefined { + const origin = normalizeHttpOrigin(host); + return origin ? new URL(origin).host : hostnameOf(host); +} + /** * 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 +245,14 @@ export function findAccounts(selector: string): AccountSummary[] { if (direct.length > 0) { return direct; } + const onServer = parseAccountServerSelector(needle); + if (onServer) { + return accounts.filter( + (a) => + matchesIdentity(a, onServer.who) && + isAccountOnServer(a, onServer.server), + ); + } const at = needle.lastIndexOf("@"); if (at <= 0) { return []; @@ -240,6 +286,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 +339,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/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 index 5a43ff83d..a33e0773e 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -26,6 +26,7 @@ type ListFunc = ( fresh: boolean; account?: string; }, + filter?: string, ) => Promise; useTestConfigDir("org-list-"); @@ -107,22 +108,27 @@ function createContext() { 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 } : {}), - }); + 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(): Promise { +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 }); + await func.call(context, { limit: 100, json: false, fresh: true }, filter); return getOutput(); } @@ -159,9 +165,12 @@ describe("org list across stored accounts", () => { ["alpha", "jane@example.com"], ["acme-internal", "jane@acme.com"], ]); - expect(entries[1]?.account).toMatchObject({ + expect(entries[1]?.account).toEqual({ + id: expect.any(String), host: ACME, + email: "jane@acme.com", label: "jane@acme.com on sentry.acme.com", + selector: "jane@acme.com:sentry.acme.com", }); expect(requests.sort()).toEqual( [`${SAAS} saas-token`, `${ACME} acme-token`].sort(), @@ -303,11 +312,56 @@ describe("org list across stored accounts", () => { const out = await runListHuman(); expect(out).toContain("ACCOUNT"); - expect(out).toContain("sentry.acme.com"); + // 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 }, + ]; + }); + + test("plain text matches anywhere in the slug or name, ignoring case", async () => { + expect((await runList({}, "PROD")).map((e) => e.slug)).toEqual([ + "acme-prod", + ]); + expect((await runList({}, "corp")).map((e) => e.slug)).toEqual(["globex"]); + }); + + test("a glob must match the whole slug or name", async () => { + expect((await runList({}, "acme-*")).map((e) => e.slug)).toEqual([ + "acme-prod", + "acme-dev", + ]); + expect((await runList({}, "acme")).map((e) => e.slug)).toEqual([ + "acme-prod", + "acme-dev", + ]); + expect(await runList({}, "acme?")).toEqual([]); + }); + + test("--limit applies after filtering", async () => { + expect((await runList({ limit: 1 }, "acme")).map((e) => e.slug)).toEqual([ + "acme-prod", + ]); + }); + + 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"); @@ -336,6 +390,23 @@ describe("org list --account", () => { 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"); @@ -356,8 +427,9 @@ describe("org list --account", () => { ); expect(error).toBeInstanceOf(AuthError); + // `:` logs in to that account's server. expect((error as AuthError).message).toContain( - "sentry auth login jane@acme.com", + "sentry auth login jane@acme.com:sentry.acme.com", ); expect(requests).toEqual([]); }); diff --git a/packages/cli/test/lib/db/accounts.test.ts b/packages/cli/test/lib/db/accounts.test.ts index 6eda014a0..61482a611 100644 --- a/packages/cli/test/lib/db/accounts.test.ts +++ b/packages/cli/test/lib/db/accounts.test.ts @@ -8,10 +8,13 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { + accountSelectorOf, findAccounts, getAccountRow, getActiveAccountRow, + isSameServer, listAccounts, + serverKey, } from "../../../src/lib/db/accounts.js"; import { getActiveAccountId, @@ -275,4 +278,66 @@ 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", + ); + 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([]); + }); +}); + +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); + }); }); From 236f6f0c97fffd14a09b2e1b2fecd6684e0fbb3a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:39:53 +0000 Subject: [PATCH 4/7] feat(cli): Fuzzy-match the org list filter `sentry org list ` now uses the shared fuzzyMatch search (exact, prefix, substring, then small typos) on org slugs and names and lists the best matches first. Globs still match the whole slug or name. Also regenerate the skill references from the updated docs fragments. Co-Authored-By: byk --- apps/cli-docs/src/fragments/commands/org.md | 3 +- .../skills/sentry-cli/references/auth.md | 1 + .../skills/sentry-cli/references/org.md | 8 +++ packages/cli/src/commands/org/list.ts | 45 ++++++++++++---- packages/cli/test/commands/org/list.test.ts | 53 +++++++++++++------ 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index 71b1dd7fc..e562b780e 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -38,7 +38,8 @@ sentry org list -a jane@example.com:sentry.io ``` ```bash -# Only organizations whose slug or name contains "acme" (case-insensitive) +# 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-*' 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 7f600e4d4..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 @@ -36,6 +36,14 @@ 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/org/list.ts b/packages/cli/src/commands/org/list.ts index 14348ccf2..2000c4f5d 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -24,6 +24,7 @@ import { import { getAllOrgRegions } from "../../lib/db/regions.js"; import { ApiError, AuthError } from "../../lib/errors.js"; import { formatAccountLabel } from "../../lib/formatters/accounts.js"; +import { fuzzyMatch } from "../../lib/fuzzy.js"; import { escapeMarkdownCell } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { type Column, writeTable } from "../../lib/formatters/table.js"; @@ -269,9 +270,10 @@ function mergeAccountOrgs(results: AccountOrgs[]): { const GLOB_CHARS_RE = /[*?[\]{}]/; /** - * Orgs whose slug or name matches `filter`, case-insensitively: a glob - * (`acme-*`) must match the whole slug or name, plain text may appear - * anywhere in it. + * Orgs whose slug or name matches `filter`, case-insensitively. A glob + * (`acme-*`) must match the whole slug or name and keeps the list order. + * Plain text uses the shared {@link fuzzyMatch} search (exact, prefix, + * substring, then small typos), best matches first. */ function filterOrgs( orgs: SentryOrganization[], @@ -281,12 +283,32 @@ function filterOrgs( if (!needle) { return orgs; } - const matches = GLOB_CHARS_RE.test(needle) - ? picomatch(needle, { dot: true }) - : (value: string) => value.includes(needle); - return orgs.filter( - (org) => matches(org.slug.toLowerCase()) || matches(org.name.toLowerCase()), + if (GLOB_CHARS_RE.test(needle)) { + const matches = picomatch(needle, { dot: true }); + return orgs.filter( + (org) => + matches(org.slug.toLowerCase()) || matches(org.name.toLowerCase()), + ); + } + const candidates = [...new Set(orgs.flatMap((org) => [org.slug, org.name]))]; + const rankOf = new Map( + fuzzyMatch(needle, candidates, { mode: "search" }).map((value, rank) => [ + value, + rank, + ]), ); + // An org ranks by its better-matching slug or name; the sort is stable. + return orgs + .map((org) => ({ + org, + rank: Math.min( + rankOf.get(org.slug) ?? Number.POSITIVE_INFINITY, + rankOf.get(org.name) ?? Number.POSITIVE_INFINITY, + ), + })) + .filter(({ rank }) => rank !== Number.POSITIVE_INFINITY) + .sort((a, b) => a.rank - b.rank) + .map(({ org }) => org); } /** @@ -351,8 +373,9 @@ export const listCommand = buildCommand({ "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 contains it\n" + - "(case-insensitive), or matches it when it is a glob like 'acme-*'.\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" + @@ -384,7 +407,7 @@ export const listCommand = buildCommand({ { placeholder: "filter", brief: - "Only list organizations whose slug or name contains this text or matches this glob", + "Only list organizations whose slug or name fuzzy-matches this text or matches this glob", parse: String, optional: true, }, diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index a33e0773e..b6bf3d0e5 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -329,29 +329,50 @@ describe("org list filter", () => { ]; }); - test("plain text matches anywhere in the slug or name, ignoring case", async () => { - expect((await runList({}, "PROD")).map((e) => e.slug)).toEqual([ - "acme-prod", + 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", ]); - expect((await runList({}, "corp")).map((e) => e.slug)).toEqual(["globex"]); }); test("a glob must match the whole slug or name", async () => { - expect((await runList({}, "acme-*")).map((e) => e.slug)).toEqual([ - "acme-prod", - "acme-dev", - ]); - expect((await runList({}, "acme")).map((e) => e.slug)).toEqual([ - "acme-prod", - "acme-dev", - ]); - expect(await runList({}, "acme?")).toEqual([]); + 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 runList({ limit: 1 }, "acme")).map((e) => e.slug)).toEqual([ - "acme-prod", - ]); + 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 () => { From 029c323ea628b2b2177f8981b58db52b2e613f45 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:44:12 +0000 Subject: [PATCH 5/7] feat(cli): Split account selector in org list JSON `sentry org list --json` entries now carry `account.selectorParts` (`who`, `server`) next to `account.selector`, so consumers don't need to parse `:`. Co-Authored-By: byk --- apps/cli-docs/src/fragments/commands/org.md | 2 ++ packages/cli/src/commands/org/list.ts | 7 +++++++ packages/cli/src/lib/db/accounts.ts | 17 +++++++++++++++-- packages/cli/test/commands/org/list.test.ts | 1 + packages/cli/test/lib/db/accounts.test.ts | 5 +++++ 5 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index e562b780e..2479c8034 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -22,6 +22,8 @@ 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 `selector` and, +already split, as `selectorParts.who` and `selectorParts.server`. ``` $ sentry org list diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 2000c4f5d..2985b2adb 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -14,6 +14,7 @@ import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { type AccountSummary, accountSelectorOf, + accountSelectorParts, serverKey, toAccountSummary, } from "../../lib/db/accounts.js"; @@ -56,6 +57,11 @@ type OrgListAccount = { label: string; /** What `--account` accepts, e.g. `jane@acme.com:sentry.io`. */ selector: string; + /** + * `selector` already split: `who` (email, else username, else id) and + * `server` (`host[:port]`, absent when no host is recorded). + */ + selectorParts: { who: string; server?: string }; }; /** Optional account fields copied into {@link OrgListAccount} when set. */ @@ -170,6 +176,7 @@ function toOrgListAccount(account: AccountSummary): OrgListAccount { id: account.id, label: formatAccountLabel(account), selector: accountSelectorOf(account), + selectorParts: accountSelectorParts(account), }; for (const field of ORG_LIST_ACCOUNT_FIELDS) { const value = account[field]; diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index c733fc71d..eb89857ec 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -215,9 +215,22 @@ export function parseAccountServerSelector( * account id; the server part is omitted when no host is recorded. */ export function accountSelectorOf(account: AccountSummary): string { + const { who, server } = accountSelectorParts(account); + return server ? `${who}:${server}` : who; +} + +/** + * The two halves of {@link accountSelectorOf}: `who` (email, else username, + * else account id) and `server` (`host[:port]`, absent when no host is + * recorded). For machine-readable output, so consumers needn't split. + */ +export function accountSelectorParts(account: AccountSummary): { + who: string; + server?: string; +} { const who = account.email ?? account.username ?? account.id; - const host = serverOf(account.host); - return host ? `${who}:${host}` : who; + const server = serverOf(account.host); + return server ? { who, server } : { who }; } /** `host[:port]` of a stored account origin, lowercased. */ diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index b6bf3d0e5..3a025c1dd 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -171,6 +171,7 @@ describe("org list across stored accounts", () => { email: "jane@acme.com", label: "jane@acme.com on sentry.acme.com", selector: "jane@acme.com:sentry.acme.com", + selectorParts: { who: "jane@acme.com", server: "sentry.acme.com" }, }); expect(requests.sort()).toEqual( [`${SAAS} saas-token`, `${ACME} acme-token`].sort(), diff --git a/packages/cli/test/lib/db/accounts.test.ts b/packages/cli/test/lib/db/accounts.test.ts index 61482a611..338497368 100644 --- a/packages/cli/test/lib/db/accounts.test.ts +++ b/packages/cli/test/lib/db/accounts.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { accountSelectorOf, + accountSelectorParts, findAccounts, getAccountRow, getActiveAccountRow, @@ -299,6 +300,10 @@ describe("findAccounts", () => { expect(selfAccount && accountSelectorOf(selfAccount)).toBe( "jane@example.com:sentry.example.com:9000", ); + expect(selfAccount && accountSelectorParts(selfAccount)).toEqual({ + who: "jane@example.com", + server: "sentry.example.com:9000", + }); for (const selector of [ "jane@example.com:sentry.io", "jane@example.com:us.sentry.io", From 95c9528da81fc88521f9d6e9596f548ffb8a83ab Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:47:01 +0000 Subject: [PATCH 6/7] fix(cli): Keep the scheme in selectors for non-HTTPS accounts Selector parsing treats a bare host as HTTPS, so an account on `http://localhost:8000` showed as `jane@x.com:localhost:8000`, which then named a different server for `--account`, `auth login`, and the log-in-again hint. Non-HTTPS servers now keep their full origin (`jane@x.com:http://localhost:8000`). --- packages/cli/src/commands/org/list.ts | 4 +++- packages/cli/src/lib/db/accounts.ts | 15 +++++++++++--- packages/cli/test/commands/org/list.test.ts | 21 ++++++++++++++++++++ packages/cli/test/lib/db/accounts.test.ts | 22 +++++++++++++++++++++ 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 2985b2adb..2b41bb223 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -59,7 +59,9 @@ type OrgListAccount = { selector: string; /** * `selector` already split: `who` (email, else username, else id) and - * `server` (`host[:port]`, absent when no host is recorded). + * `server` (`host[:port]`, or the full origin such as + * `http://localhost:8000` for a non-HTTPS server; absent when no host is + * recorded). */ selectorParts: { who: string; server?: string }; }; diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index eb89857ec..5c748cf29 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -221,7 +221,8 @@ export function accountSelectorOf(account: AccountSummary): string { /** * The two halves of {@link accountSelectorOf}: `who` (email, else username, - * else account id) and `server` (`host[:port]`, absent when no host is + * else account id) and `server` (`host[:port]`, or the full origin for a + * non-HTTPS server; absent when no host is * recorded). For machine-readable output, so consumers needn't split. */ export function accountSelectorParts(account: AccountSummary): { @@ -233,10 +234,18 @@ export function accountSelectorParts(account: AccountSummary): { return server ? { who, server } : { who }; } -/** `host[:port]` of a stored account origin, lowercased. */ +/** + * The server part of a selector for a stored account origin: `host[:port]` + * for HTTPS, the full origin (`http://host[:port]`) otherwise, since + * selector parsing assumes HTTPS for a bare host. + */ function serverOf(host: string | undefined): string | undefined { const origin = normalizeHttpOrigin(host); - return origin ? new URL(origin).host : hostnameOf(host); + if (!origin) { + return hostnameOf(host); + } + const url = new URL(origin); + return url.protocol === "https:" ? url.host : origin; } /** diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index 3a025c1dd..7a50d5e4a 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -455,4 +455,25 @@ describe("org list --account", () => { ); 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 338497368..068a13b88 100644 --- a/packages/cli/test/lib/db/accounts.test.ts +++ b/packages/cli/test/lib/db/accounts.test.ts @@ -319,6 +319,28 @@ describe("findAccounts", () => { } 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 && accountSelectorParts(account)).toEqual({ + who: "jane@example.com", + server: "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", () => { From a6e6db9fed9e656b23c4a0d66afe76c3bb958930 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:20:31 +0000 Subject: [PATCH 7/7] refactor(cli): Share account selector parsing and list name filtering Address review feedback on the multi-account org list: - `auth login :` and `--account` now share one `splitAccountSelector`, which splits on the first `:` so the server part may carry a scheme or port. Login adds only an email check. - Move the glob/fuzzy name filter into `lib/list-filter.ts` (`filterByName`) so other list commands can reuse it. - Flatten the JSON account: `name` is the `:` selector, with `email`/`username` and `server` alongside, replacing `selector` and `selectorParts`. - Table columns map headers to `--fields`-style paths read with the shared `getNestedValue`, instead of per-column getters. --- apps/cli-docs/src/fragments/commands/org.md | 4 +- packages/cli/src/commands/auth/login.ts | 49 ++++---- packages/cli/src/commands/org/list.ts | 129 ++++++++------------ packages/cli/src/lib/db/accounts.ts | 61 ++++----- packages/cli/src/lib/formatters/json.ts | 2 +- packages/cli/src/lib/list-filter.ts | 59 +++++++++ packages/cli/test/commands/org/list.test.ts | 6 +- packages/cli/test/lib/db/accounts.test.ts | 41 +++++-- packages/cli/test/lib/list-filter.test.ts | 47 +++++++ 9 files changed, 244 insertions(+), 154 deletions(-) create mode 100644 packages/cli/src/lib/list-filter.ts create mode 100644 packages/cli/test/lib/list-filter.test.ts diff --git a/apps/cli-docs/src/fragments/commands/org.md b/apps/cli-docs/src/fragments/commands/org.md index 2479c8034..8e7de48c5 100644 --- a/apps/cli-docs/src/fragments/commands/org.md +++ b/apps/cli-docs/src/fragments/commands/org.md @@ -22,8 +22,8 @@ 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 `selector` and, -already split, as `selectorParts.who` and `selectorParts.server`. +With `--json`, each entry's `account` carries that notation as `name`, with its +parts alongside: `email` (or `username`) and `server`. ``` $ sentry org list diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index 05fc93bd1..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"; @@ -170,7 +174,8 @@ export function parseLoginEmail(raw: string): string { /** * Split a `sentry auth login` target into its email and, for - * `:`, the normalized server origin. + * `:`, the server's normalized origin. Uses the same + * {@link splitAccountSelector} as `--account`, plus an email check. * * @throws {ValidationError} when either part is invalid */ @@ -178,33 +183,27 @@ export function splitLoginTarget( target: string, raw = target, ): { email: string; url?: string } { - const at = target.indexOf("@"); - // The separator is the first `:` after the `@`; the server part may - // itself contain `:` (a scheme or port). - const colon = at > 0 ? target.indexOf(":", at) : -1; - const email = colon > 0 ? target.slice(0, colon) : target; - if ( - at <= 0 || - at === email.length - 1 || - email.indexOf("@", at + 1) !== -1 || - /\s/.test(email) - ) { + const parts = splitAccountSelector(target); + if (!(parts && isEmailAddress(parts.who))) { throw new ValidationError( - `Expected an email address (or :) to log in as, got '${raw}'.`, + `Expected an email address, or : with a valid server, to log in as; got '${raw}'.`, "email", ); } - if (colon < 0) { - return { email }; - } - const url = normalizeOrigin(normalizeUrl(target.slice(colon + 1))); - if (!url) { - throw new ValidationError( - `Expected : with a valid server, got '${raw}'.`, - "email", - ); - } - return { email, url }; + 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) + ); } /** diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 2b41bb223..7e66b133b 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -5,7 +5,6 @@ * when several are logged in. */ -import picomatch from "picomatch"; import type { SentryContext } from "../../context.js"; import { resolveAccountSelector } from "../../lib/account-selection.js"; import { listOrganizationsUncached } from "../../lib/api-client.js"; @@ -14,7 +13,7 @@ import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { type AccountSummary, accountSelectorOf, - accountSelectorParts, + accountServerOf, serverKey, toAccountSummary, } from "../../lib/db/accounts.js"; @@ -25,10 +24,11 @@ import { import { getAllOrgRegions } from "../../lib/db/regions.js"; import { ApiError, AuthError } from "../../lib/errors.js"; import { formatAccountLabel } from "../../lib/formatters/accounts.js"; -import { fuzzyMatch } from "../../lib/fuzzy.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, @@ -49,26 +49,25 @@ type ListFlags = { /** 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; - host?: string; email?: string; username?: string; - /** Human label, e.g. `Jane on sentry.io`. */ - label: string; - /** What `--account` accepts, e.g. `jane@acme.com:sentry.io`. */ - selector: string; /** - * `selector` already split: `who` (email, else username, else id) and - * `server` (`host[:port]`, or the full origin such as - * `http://localhost:8000` for a non-HTTPS server; absent when no host is - * recorded). + * The server part of `name`: `host[:port]`, or the full origin such as + * `http://localhost:8000` for a non-HTTPS server. */ - selectorParts: { who: string; server?: string }; + server?: string; + /** The server's full origin, e.g. `https://sentry.io`. */ + host?: string; + /** Human label, e.g. `Jane on sentry.io`. */ + label: string; }; -/** Optional account fields copied into {@link OrgListAccount} when set. */ -const ORG_LIST_ACCOUNT_FIELDS = ["host", "email", "username"] as const; - /** * An organization enriched with an optional region display name and the * stored account it was listed under. @@ -129,17 +128,25 @@ function getRegionDisplayName(regionUrl: string): string { } } -/** Table columns in display order: header → the entry's cell value. */ -const ORG_LIST_COLUMNS: Record< - string, - (e: OrgListEntry) => string | undefined -> = { - SLUG: (e) => e.slug, - REGION: (e) => e.region, - NAME: (e) => e.name, - ACCOUNT: (e) => e.account?.selector, +/** + * 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"]); @@ -157,13 +164,13 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { // REGION and ACCOUNT only appear when some entry has one. const columns: Column[] = Object.entries(ORG_LIST_COLUMNS) .filter( - ([header, value]) => + ([header, path]) => !OPTIONAL_ORG_LIST_COLUMNS.has(header) || - entries.some((e) => value(e) !== undefined), + entries.some((e) => cellOf(e, path) !== undefined), ) - .map(([header, value]) => ({ + .map(([header, path]) => ({ header, - value: (e) => escapeMarkdownCell(value(e) ?? ""), + value: (e) => escapeMarkdownCell(cellOf(e, path) ?? ""), })); const parts: string[] = []; @@ -174,16 +181,20 @@ function formatOrgListHuman(entries: OrgListEntry[]): string { } 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), - selector: accountSelectorOf(account), - selectorParts: accountSelectorParts(account), }; - for (const field of ORG_LIST_ACCOUNT_FIELDS) { - const value = account[field]; + for (const [field, value] of Object.entries(optional)) { if (value) { - entry[field] = value; + entry[field as keyof typeof optional] = value; } } return entry; @@ -275,51 +286,6 @@ function mergeAccountOrgs(results: AccountOrgs[]): { return { orgs, accountOf, regionOf }; } -/** Glob metacharacters: a filter with any of them is matched as a glob. */ -const GLOB_CHARS_RE = /[*?[\]{}]/; - -/** - * Orgs whose slug or name matches `filter`, case-insensitively. A glob - * (`acme-*`) must match the whole slug or name and keeps the list order. - * Plain text uses the shared {@link fuzzyMatch} search (exact, prefix, - * substring, then small typos), best matches first. - */ -function filterOrgs( - orgs: SentryOrganization[], - filter: string | undefined, -): SentryOrganization[] { - const needle = filter?.trim().toLowerCase(); - if (!needle) { - return orgs; - } - if (GLOB_CHARS_RE.test(needle)) { - const matches = picomatch(needle, { dot: true }); - return orgs.filter( - (org) => - matches(org.slug.toLowerCase()) || matches(org.name.toLowerCase()), - ); - } - const candidates = [...new Set(orgs.flatMap((org) => [org.slug, org.name]))]; - const rankOf = new Map( - fuzzyMatch(needle, candidates, { mode: "search" }).map((value, rank) => [ - value, - rank, - ]), - ); - // An org ranks by its better-matching slug or name; the sort is stable. - return orgs - .map((org) => ({ - org, - rank: Math.min( - rankOf.get(org.slug) ?? Number.POSITIVE_INFINITY, - rankOf.get(org.name) ?? Number.POSITIVE_INFINITY, - ), - })) - .filter(({ rank }) => rank !== Number.POSITIVE_INFINITY) - .sort((a, b) => a.rank - b.rank) - .map(({ org }) => org); -} - /** * The command that logs back in to `account`: `sentry auth login * :` when its email is known, else `--url `. @@ -446,7 +412,10 @@ export const listCommand = buildCommand({ ); const merged = mergeAccountOrgs(results); const { accountOf, regionOf } = merged; - const orgs = filterOrgs(merged.orgs, filter); + const orgs = filterByName(merged.orgs, filter, (org) => [ + org.slug, + org.name, + ]); const limitedOrgs = orgs.slice(0, flags.limit); // Show regions when one login's orgs span several (e.g. US and EU). diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 5c748cf29..19e427da2 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -190,22 +190,27 @@ export function listAccounts(): AccountSummary[] { } /** - * Split a `:` account selector (e.g. - * `jane@acme.com:sentry.acme.com`) into its identity and server origin. - * The server may be a bare host (with port) or a URL. `undefined` when the - * selector has no `:` or either side is empty or not a valid server. + * 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 parseAccountServerSelector( +export function splitAccountSelector( selector: string, -): { who: string; server: string } | undefined { +): { who: string; server?: string } | undefined { const trimmed = selector.trim(); const colon = trimmed.indexOf(":"); - if (colon <= 0) { - return; + if (colon < 0) { + return { who: trimmed }; } const who = trimmed.slice(0, colon); const server = normalizeHttpOrigin(normalizeUrl(trimmed.slice(colon + 1))); - return server ? { who, server } : undefined; + return who && server ? { who, server } : undefined; } /** @@ -215,31 +220,20 @@ export function parseAccountServerSelector( * account id; the server part is omitted when no host is recorded. */ export function accountSelectorOf(account: AccountSummary): string { - const { who, server } = accountSelectorParts(account); - return server ? `${who}:${server}` : who; -} - -/** - * The two halves of {@link accountSelectorOf}: `who` (email, else username, - * else account id) and `server` (`host[:port]`, or the full origin for a - * non-HTTPS server; absent when no host is - * recorded). For machine-readable output, so consumers needn't split. - */ -export function accountSelectorParts(account: AccountSummary): { - who: string; - server?: string; -} { const who = account.email ?? account.username ?? account.id; - const server = serverOf(account.host); - return server ? { who, server } : { who }; + const server = accountServerOf(account); + return server ? `${who}:${server}` : who; } /** - * The server part of a selector for a stored account origin: `host[:port]` - * for HTTPS, the full origin (`http://host[:port]`) otherwise, since - * selector parsing assumes HTTPS for a bare host. + * 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. */ -function serverOf(host: string | undefined): string | undefined { +export function accountServerOf(account: { + readonly host?: string; +}): string | undefined { + const host = account.host; const origin = normalizeHttpOrigin(host); if (!origin) { return hostnameOf(host); @@ -267,12 +261,11 @@ export function findAccounts(selector: string): AccountSummary[] { if (direct.length > 0) { return direct; } - const onServer = parseAccountServerSelector(needle); - if (onServer) { + const parts = splitAccountSelector(needle); + const server = parts?.server; + if (parts && server) { return accounts.filter( - (a) => - matchesIdentity(a, onServer.who) && - isAccountOnServer(a, onServer.server), + (a) => matchesIdentity(a, parts.who) && isAccountOnServer(a, server), ); } const at = needle.lastIndexOf("@"); 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/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index 7a50d5e4a..40b11d4cd 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -166,12 +166,12 @@ describe("org list across stored accounts", () => { ["acme-internal", "jane@acme.com"], ]); expect(entries[1]?.account).toEqual({ + name: "jane@acme.com:sentry.acme.com", id: expect.any(String), - host: ACME, email: "jane@acme.com", + server: "sentry.acme.com", + host: ACME, label: "jane@acme.com on sentry.acme.com", - selector: "jane@acme.com:sentry.acme.com", - selectorParts: { who: "jane@acme.com", server: "sentry.acme.com" }, }); expect(requests.sort()).toEqual( [`${SAAS} saas-token`, `${ACME} acme-token`].sort(), diff --git a/packages/cli/test/lib/db/accounts.test.ts b/packages/cli/test/lib/db/accounts.test.ts index 068a13b88..60e86f01f 100644 --- a/packages/cli/test/lib/db/accounts.test.ts +++ b/packages/cli/test/lib/db/accounts.test.ts @@ -9,7 +9,8 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { accountSelectorOf, - accountSelectorParts, + accountServerOf, + splitAccountSelector, findAccounts, getAccountRow, getActiveAccountRow, @@ -300,10 +301,9 @@ describe("findAccounts", () => { expect(selfAccount && accountSelectorOf(selfAccount)).toBe( "jane@example.com:sentry.example.com:9000", ); - expect(selfAccount && accountSelectorParts(selfAccount)).toEqual({ - who: "jane@example.com", - server: "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", @@ -333,10 +333,7 @@ describe("findAccounts", () => { const account = listAccounts().find((a) => a.id === local); const selector = account && accountSelectorOf(account); expect(selector).toBe("jane@example.com:http://localhost:8000"); - expect(account && accountSelectorParts(account)).toEqual({ - who: "jane@example.com", - server: "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([]); @@ -368,3 +365,29 @@ describe("serverKey", () => { 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); + }); +});