Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ import { spanRoute } from "./commands/span/index.js";
import { listCommand as spanListCommand } from "./commands/span/list.js";
import { teamRoute } from "./commands/team/index.js";
import { listCommand as teamListCommand } from "./commands/team/list.js";
import { tokenRoute } from "./commands/token/index.js";
import { listCommand as tokenListCommand } from "./commands/token/list.js";
import { traceRoute } from "./commands/trace/index.js";
import { listCommand as traceListCommand } from "./commands/trace/list.js";
import { trialRoute } from "./commands/trial/index.js";
Expand Down Expand Up @@ -82,6 +84,7 @@ const PLURAL_TO_SINGULAR: Record<string, string> = {
releases: "release",
repos: "repo",
teams: "team",
tokens: "token",
logs: "log",
monitors: "monitor",
replays: "replay",
Expand Down Expand Up @@ -109,6 +112,7 @@ export const routes = buildRouteMap({
release: releaseRoute,
repo: repoRoute,
team: teamRoute,
token: tokenRoute,
issue: issueRoute,
event: eventRoute,
events: eventListCommand,
Expand Down Expand Up @@ -136,6 +140,7 @@ export const routes = buildRouteMap({
releases: releaseListCommand,
repos: repoListCommand,
teams: teamListCommand,
tokens: tokenListCommand,
logs: logListCommand,
monitors: monitorListCommand,
spans: spanListCommand,
Expand All @@ -159,6 +164,7 @@ export const routes = buildRouteMap({
releases: true,
repos: true,
teams: true,
tokens: true,
logs: true,
monitors: true,
spans: true,
Expand Down
112 changes: 112 additions & 0 deletions src/commands/token/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* sentry token create
*
* Create a new org auth token. The full token value is printed to stdout
* exactly once — it cannot be retrieved again after creation.
*
* Org auth tokens are scoped to `org:ci` and are intended for CI pipelines,
* release management, and other automated workflows.
*/

import type { SentryContext } from "../../context.js";
import { createOrgAuthToken } from "../../lib/api-client.js";
import { buildCommand } from "../../lib/command.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { success } from "../../lib/formatters/colors.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { resolveOrg } from "../../lib/resolve-target.js";
import type { OrgAuthToken } from "../../types/index.js";

/** Result shape for output rendering. */
type TokenCreateResult = {
token: OrgAuthToken;
orgSlug: string;
};

function formatTokenCreated(result: TokenCreateResult): string {
const lines: string[] = [];
lines.push(
success(`Created token '${result.token.name}' in ${result.orgSlug}`)
);
lines.push("");
if (result.token.token) {
lines.push(`Token: ${result.token.token}`);
lines.push("");
lines.push("Save this token now — it will not be shown again.");
}
lines.push(`ID: ${result.token.id}`);
lines.push(`Scopes: ${result.token.scopes.join(", ")}`);
return lines.join("\n");
}

type CreateFlags = {
readonly name?: string;
readonly json: boolean;
readonly fields?: string[];
};

export const createCommand = buildCommand({
docs: {
brief: "Create an org auth token",
fullDescription:
"Create a new organization auth token with org:ci scope.\n\n" +
"The full token value is printed exactly once — save it immediately.\n" +
"Subsequent requests only show the last 4 characters.\n\n" +
"Examples:\n" +
" sentry token create my-org --name 'CI deploy token'\n" +
" sentry token create --name 'release-bot' # auto-detect org\n" +
" sentry token create my-org --name ci --json",
},
output: {
human: formatTokenCreated,
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org",
brief: "Organization slug",
parse: String,
optional: true,
},
],
},
flags: {
name: {
kind: "parsed",
parse: String,
brief: "Name for the new token",
optional: true,
},
},
},
async *func(this: SentryContext, flags: CreateFlags, orgArg?: string) {
const { cwd } = this;

const resolved = await resolveOrg({ org: orgArg, cwd });
if (!resolved) {
throw new ContextError(
"Organization",
"sentry token create <org> --name <name>",
[]
);
}
const orgSlug = resolved.org;

const name = flags.name;
if (!name) {
throw new ValidationError(
"Token name is required. Use --name to specify a name.",
"name"
);
}

const token = await createOrgAuthToken(orgSlug, name);

yield new CommandOutput({ token, orgSlug });
return {
hint: "Save the token value now — it cannot be retrieved later.",
Comment thread
sentry-warden[bot] marked this conversation as resolved.
};
},
});
179 changes: 179 additions & 0 deletions src/commands/token/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* sentry token delete
*
* Delete (deactivate) an org auth token by ID.
*
* Uses `buildDeleteCommand` for standard --yes/--force/--dry-run flags
* and non-interactive safety guards.
*/

import type { SentryContext } from "../../context.js";
import { deleteOrgAuthToken, listOrgAuthTokens } from "../../lib/api-client.js";
import { ContextError, ResolutionError } from "../../lib/errors.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import { logger } from "../../lib/logger.js";
import {
buildDeleteCommand,
confirmByTyping,
isConfirmationBypassed,
} from "../../lib/mutate-command.js";
import { resolveOrg } from "../../lib/resolve-target.js";

const log = logger.withTag("token.delete");

/** Result shape for output rendering. */
type TokenDeleteResult = {
tokenId: string;
tokenName: string;
orgSlug: string;
dryRun?: boolean;
};

function formatTokenDeleted(result: TokenDeleteResult): string {
if (result.dryRun) {
return `Would delete token '${result.tokenName}' (ID: ${result.tokenId}) from ${result.orgSlug}`;
}
return `Deleted token '${result.tokenName}' (ID: ${result.tokenId}) from ${result.orgSlug}`;
}

type DeleteFlags = {
readonly yes: boolean;
readonly force: boolean;
readonly "dry-run": boolean;
readonly json: boolean;
readonly fields?: string[];
};

/**
* Resolve a token by ID or name within an org's token list.
*
* Accepts either a numeric ID or a token name. When matching by name,
* requires an exact match (case-sensitive).
*/
async function resolveToken(
orgSlug: string,
tokenRef: string
): Promise<{ id: string; name: string }> {
const tokens = await listOrgAuthTokens(orgSlug);

const byId = tokens.find((t) => t.id === tokenRef);
if (byId) {
return { id: byId.id, name: byId.name };
}

const byName = tokens.filter((t) => t.name === tokenRef);
if (byName.length === 1 && byName[0]) {
return { id: byName[0].id, name: byName[0].name };
}
if (byName.length > 1) {
throw new ResolutionError(
`Token name '${tokenRef}'`,
`matches ${byName.length} tokens`,
"sentry token delete <org> <token-id>",
byName.map(
(t) => `ID ${t.id}: ${t.name} (…${t.tokenLastCharacters ?? ""})`
)
);
}

const hints =
tokens.length > 0
? tokens.slice(0, 5).map((t) => `${t.id}: ${t.name}`)
: ["No tokens found in this organization"];
throw new ResolutionError(
`Token '${tokenRef}'`,
`not found in ${orgSlug}`,
`sentry token list ${orgSlug}`,
hints
);
}

export const deleteCommand = buildDeleteCommand({
docs: {
brief: "Delete an org auth token",
fullDescription:
"Delete (deactivate) an organization auth token by ID or name.\n\n" +
"The token immediately stops working for API authentication.\n\n" +
"Examples:\n" +
" sentry token delete my-org 12345\n" +
" sentry token delete my-org 'CI deploy token'\n" +
" sentry token delete my-org 12345 --yes\n" +
" sentry token delete my-org 12345 --dry-run",
},
output: {
human: formatTokenDeleted,
jsonTransform: (result: TokenDeleteResult) => ({
deleted: !result.dryRun,
dryRun: result.dryRun ?? false,
tokenId: result.tokenId,
tokenName: result.tokenName,
org: result.orgSlug,
}),
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org",
brief: "Organization slug",
parse: String,
},
{
placeholder: "token-id",
brief: "Token ID or name",
parse: String,
},
],
},
},
async *func(
this: SentryContext,
flags: DeleteFlags,
orgArg: string,
tokenRef: string
) {
const { cwd } = this;

const resolved = await resolveOrg({ org: orgArg, cwd });
if (!resolved) {
throw new ContextError(
"Organization",
"sentry token delete <org> <token-id>",
[]
);
}
const orgSlug = resolved.org;

const token = await resolveToken(orgSlug, tokenRef);

if (flags["dry-run"]) {
yield new CommandOutput({
tokenId: token.id,
tokenName: token.name,
orgSlug,
dryRun: true,
});
return;
}

if (!isConfirmationBypassed(flags)) {
const confirmed = await confirmByTyping(
token.name,
`Type '${token.name}' to delete token (ID: ${token.id}):`
);
if (!confirmed) {
log.info("Cancelled.");
return;
}
}

await deleteOrgAuthToken(orgSlug, token.id);

yield new CommandOutput({
tokenId: token.id,
tokenName: token.name,
orgSlug,
});
},
});
21 changes: 21 additions & 0 deletions src/commands/token/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { buildRouteMap } from "../../lib/route-map.js";
import { createCommand } from "./create.js";
import { deleteCommand } from "./delete.js";
import { listCommand } from "./list.js";

export const tokenRoute = buildRouteMap({
routes: {
create: createCommand,
delete: deleteCommand,
list: listCommand,
},
defaultCommand: "list",
docs: {
brief: "Manage org auth tokens",
fullDescription:
"Create, list, and delete organization auth tokens.\n\n" +
"Org auth tokens are used for CI pipelines, release management,\n" +
"and other automated workflows. They are scoped to org:ci.\n\n" +
"Alias: `sentry tokens` → `sentry token list`",
},
});
Loading
Loading