Skip to content
Merged
14 changes: 14 additions & 0 deletions plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ SENTRY_URL=https://sentry.example.com sentry auth login --token YOUR_TOKEN

Log out of Sentry

**Flags:**
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

**Examples:**

```bash
Expand Down Expand Up @@ -91,6 +95,8 @@ View authentication status
**Flags:**
- `--show-token - Show the stored token (masked by default)`
- `-f, --fresh - Bypass cache and fetch fresh data`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

**Examples:**

Expand Down Expand Up @@ -453,12 +459,18 @@ CLI-related commands

Send feedback about the CLI

**Flags:**
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

#### `sentry cli fix`

Diagnose and repair CLI database issues

**Flags:**
- `--dry-run - Show what would be fixed without making changes`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

#### `sentry cli setup`

Expand All @@ -481,6 +493,8 @@ Update the Sentry CLI to the latest version
- `--check - Check for updates without installing`
- `--force - Force upgrade even if already on the latest version`
- `--method <value> - Installation method to use (curl, brew, npm, pnpm, bun, yarn)`
- `--json - Output as JSON`
- `--fields <value> - Comma-separated fields to include in JSON output (dot.notation supported)`

### Repo

Expand Down
37 changes: 27 additions & 10 deletions src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,36 +13,53 @@ import {
isEnvTokenActive,
} from "../../lib/db/auth.js";
import { getDbPath } from "../../lib/db/index.js";
import { logger } from "../../lib/logger.js";
import { AuthError } from "../../lib/errors.js";
import { formatLogoutResult } from "../../lib/formatters/human.js";

const log = logger.withTag("auth.logout");
/** Structured result of the logout operation */
export type LogoutResult = {
/** Whether logout actually cleared credentials */
loggedOut: boolean;
/** Informational message when no action was taken */
message?: string;
/** Path where credentials were stored (when loggedOut is true) */
configPath?: string;
};

export const logoutCommand = buildCommand({
docs: {
brief: "Log out of Sentry",
fullDescription:
"Remove stored authentication credentials from the configuration file.",
},
output: { json: true, human: formatLogoutResult },
parameters: {
flags: {},
},
async func(this: SentryContext): Promise<void> {
async func(this: SentryContext): Promise<{ data: LogoutResult }> {
if (!(await isAuthenticated())) {
log.warn("Not currently authenticated.");
return;
return {
data: { loggedOut: false, message: "Not currently authenticated." },
};
}

if (isEnvTokenActive()) {
const envVar = getActiveEnvVarName();
log.warn(
`Authentication is provided via ${envVar} environment variable.\n` +
throw new AuthError(
"invalid",
`Authentication is provided via ${envVar} environment variable. ` +
`Unset ${envVar} to log out.`
);
return;
}

const configPath = getDbPath();
await clearAuth();
log.success("Logged out successfully.");
log.info(`Credentials removed from: ${getDbPath()}`);

return {
data: {
loggedOut: true,
configPath,
},
};
},
});
187 changes: 97 additions & 90 deletions src/commands/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,116 +21,118 @@ import {
import { getDbPath } from "../../lib/db/index.js";
import { getUserInfo } from "../../lib/db/user.js";
import { AuthError, stringifyUnknown } from "../../lib/errors.js";
import {
formatExpiration,
formatUserIdentity,
maskToken,
} from "../../lib/formatters/human.js";
import { formatAuthStatus, maskToken } from "../../lib/formatters/human.js";
import {
applyFreshFlag,
FRESH_ALIASES,
FRESH_FLAG,
} from "../../lib/list-command.js";
import { logger } from "../../lib/logger.js";

const log = logger.withTag("auth.status");

type StatusFlags = {
readonly "show-token": boolean;
readonly json: boolean;
readonly fresh: boolean;
readonly fields?: string[];
};

/**
* Log user identity information if available.
*/
function logUserInfo(): void {
const user = getUserInfo();
if (!user) {
return;
}
log.info(`User: ${formatUserIdentity(user)}`);
}

/** Check if the auth source is an environment variable */
function isEnvSource(source: AuthSource): boolean {
return source.startsWith(ENV_SOURCE_PREFIX);
}

/** Extract the env var name from an env-based AuthSource (e.g. "env:SENTRY_AUTH_TOKEN" → "SENTRY_AUTH_TOKEN") */
function envVarName(source: AuthSource): string {
return source.slice(ENV_SOURCE_PREFIX.length);
}
/**
* Structured data representing the full auth status.
* Serves as both the JSON output shape and input to the human formatter.
*/
export type AuthStatusData = {
/** Whether the user is currently authenticated */
authenticated: boolean;
/** Auth source: "oauth" or "env:SENTRY_AUTH_TOKEN" etc. */
source: string;
/** Path to the SQLite config database (only for non-env tokens) */
configPath?: string;
/** User identity from cached user info */
user?: { name?: string; email?: string; username?: string };
/** Token display and metadata */
token?: {
/** Masked or full token string depending on --show-token */
display: string;
/** Expiration timestamp (ms since epoch), if available */
expiresAt?: number;
/** Whether auto-refresh via refresh token is enabled */
refreshEnabled: boolean;
};
/** Default org/project settings */
defaults?: {
organization?: string;
project?: string;
};
/** Credential verification results */
verification?: {
/** Whether the API call succeeded */
success: boolean;
/** Organizations accessible with the current token */
organizations?: Array<{ name: string; slug: string }>;
/** Error message if verification failed */
error?: string;
};
};

/**
* Log token information.
* Collect token information into the data structure.
*/
function logTokenInfo(auth: AuthConfig | undefined, showToken: boolean): void {
function collectTokenInfo(
auth: AuthConfig | undefined,
showToken: boolean
): AuthStatusData["token"] | undefined {
if (!auth?.token) {
return;
}

const tokenDisplay = showToken ? auth.token : maskToken(auth.token);
log.info(`Token: ${tokenDisplay}`);

// Env var tokens have no expiry or refresh — skip those sections
if (isEnvSource(auth.source)) {
return;
}

if (auth.expiresAt) {
log.info(`Expires: ${formatExpiration(auth.expiresAt)}`);
}
const display = showToken ? auth.token : maskToken(auth.token);
const fromEnv = isEnvSource(auth.source);

// Show refresh token status
if (auth.refreshToken) {
log.info("Auto-refresh: enabled");
} else {
log.info("Auto-refresh: disabled (no refresh token)");
}
return {
display,
// Env var tokens have no expiry or refresh
expiresAt: fromEnv ? undefined : auth.expiresAt,
refreshEnabled: fromEnv ? false : Boolean(auth.refreshToken),
};
}

/**
* Log default org/project settings if configured.
* Collect default org/project into the data structure.
*/
async function logDefaults(): Promise<void> {
const defaultOrg = await getDefaultOrganization();
const defaultProject = await getDefaultProject();
async function collectDefaults(): Promise<AuthStatusData["defaults"]> {
const org = await getDefaultOrganization();
const project = await getDefaultProject();

if (!(defaultOrg || defaultProject)) {
if (!(org || project)) {
return;
}

log.info("Defaults:");
if (defaultOrg) {
log.info(` Organization: ${defaultOrg}`);
}
if (defaultProject) {
log.info(` Project: ${defaultProject}`);
}
return {
organization: org ?? undefined,
project: project ?? undefined,
};
}

/**
* Verify credentials by fetching organizations.
* Captures success/failure into data rather than throwing.
*/
async function verifyCredentials(): Promise<void> {
log.info("Verifying credentials...");

async function verifyCredentials(): Promise<AuthStatusData["verification"]> {
try {
const orgs = await listOrganizations();
log.success(
`Access verified. You have access to ${orgs.length} organization(s):`
);

const maxDisplay = 5;
for (const org of orgs.slice(0, maxDisplay)) {
log.info(` - ${org.name} (${org.slug})`);
}
if (orgs.length > maxDisplay) {
log.info(` ... and ${orgs.length - maxDisplay} more`);
}
return {
success: true,
organizations: orgs.map((o) => ({ name: o.name, slug: o.slug })),
};
} catch (err) {
const message = stringifyUnknown(err);
log.error(`Could not verify credentials: ${message}`);
return {
success: false,
error: stringifyUnknown(err),
};
}
}

Expand All @@ -141,6 +143,7 @@ export const statusCommand = buildCommand({
"Display information about your current authentication status, " +
"including whether you're logged in and your default organization/project settings.",
},
output: { json: true, human: formatAuthStatus },
parameters: {
flags: {
"show-token": {
Expand All @@ -152,17 +155,12 @@ export const statusCommand = buildCommand({
},
aliases: FRESH_ALIASES,
},
async func(this: SentryContext, flags: StatusFlags): Promise<void> {
async func(this: SentryContext, flags: StatusFlags) {
applyFreshFlag(flags);

const auth = await getAuthConfig();
const auth = getAuthConfig();
const authenticated = await isAuthenticated();
const fromEnv = auth && isEnvSource(auth.source);

// Show config path only for stored (OAuth) tokens — irrelevant for env vars
if (!fromEnv) {
log.info(`Config: ${getDbPath()}`);
}
const fromEnv = auth ? isEnvSource(auth.source) : false;

if (!authenticated) {
// Skip auto-login - user explicitly ran status to check auth state
Expand All @@ -171,17 +169,26 @@ export const statusCommand = buildCommand({
});
}

if (fromEnv) {
log.success(
`Authenticated via ${envVarName(auth.source)} environment variable`
);
} else {
log.success("Authenticated");
}
logUserInfo();

logTokenInfo(auth, flags["show-token"]);
await logDefaults();
await verifyCredentials();
// Build the user info
const userInfo = getUserInfo();
const user = userInfo
? {
name: userInfo.name,
email: userInfo.email,
username: userInfo.username,
}
: undefined;

const data: AuthStatusData = {
authenticated: true,
source: auth?.source ?? "oauth",
configPath: fromEnv ? undefined : getDbPath(),
user,
token: collectTokenInfo(auth, flags["show-token"]),
defaults: await collectDefaults(),
verification: await verifyCredentials(),
};

return { data };
},
});
Loading
Loading