Skip to content
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"sentry-cli/no-inline-touch-cache": "error",
"sentry-cli/no-args-join-in-release": "error",
"sentry-cli/no-direct-target-resolution": "error",
"sentry-cli/require-target-resolution": "error",
"sentry-cli/no-generic-is-record": "error",
"sentry-cli/prefer-paginate-helper": "error",
"sentry-cli/no-namespace-import": "error",
Expand Down
32 changes: 31 additions & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,41 @@ const parsed = parseOrgProjectArg(targetArg);

Reference: `span/list.ts`, `trace/view.ts`, `event/view.ts`

**Target positionals:** A single `[<org>/][<project>]` positional uses a
parser from `src/lib/target-positional.ts`, which binds the resolver:
`orgOptionalTarget` (org, optional project), `projectBoundTarget` (one org and
project), or `orgProjectTarget(resolve)` for a command-specific resolver (see
`project view`). `func` takes the matching type (`OrgOptionalTarget`,
`ProjectBoundTarget`, `OrgProjectTarget<R>`), reads the parsed form as
`target.parsed` (e.g. `target.parsed.type`), and gets the org and project
with `await target.resolved`. It never calls `parseOrgProjectArg` or a resolver
itself, and an omitted target arrives as auto-detect. The
`sentry-cli/require-target-resolution` lint rule fails a function that takes
one of these types and never reads `.resolved`.

Resolvers need no command name: they read it from `this.commandPrefix`, which
Stricli's `forCommand` and the SDK invoker set (test mock contexts set it
too). In a `buildDeleteCommand` command, the target refuses to auto-detect, so
delete commands don't call `requireExplicitTarget` for it.

Both steps are lazy (`lazyPositional` in `src/lib/positional-parse.ts`): the
parse runs on the first `parsed` read and the resolver on the first
`resolved` read, both inside `func` after the auth guard. Invalid input still
throws the parser's `CliError` with its exit code. `target.raw` is what the
user typed; shared code that handles any positional uses
`rawPositional(value)`. `buildCommand` runs these parsers on
positionals that arrive as strings or are omitted, so the SDK invoke path and
tests that call `command.loader()` keep passing raw strings. Positionals whose
meaning depends on how many arguments were given, such as
`[<org>/<project>/]<trace-id>`, keep parsing in `func`.

**Shell completion:** Target positionals that take an org or project use a
completion-aware parser from `src/lib/completion-target.ts` instead of
`String`: `parseOrgProjectTarget` completes `org/`, `org/project`, and project
aliases; `parseOrgTarget` completes org slugs only. `LIST_TARGET_POSITIONAL`
and `LIST_ORG_TARGET_POSITIONAL` already use them.
and `LIST_ORG_TARGET_POSITIONAL` already use them. A parser tagged with
`withCompletionTarget`, such as the `target-positional.ts` parsers, completes
the same way.
`pnpm run generate:completions` collects these into the gitignored
`src/generated/completion-targets.json` that the `__complete` fast path reads.
`project create` keeps its dedicated `name:platform` completer in
Expand Down
61 changes: 55 additions & 6 deletions packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,53 @@ function rule(message, visitors) {
};
}

function containsIdentifier(node, identifier) {
function someNode(node, predicate) {
if (!node || typeof node !== "object") return false;
if (node.type === "Identifier" && node.name === identifier) return true;
if (predicate(node)) return true;
return Object.entries(node).some(
([key, value]) =>
key !== "parent" &&
(Array.isArray(value)
? value.some((child) => containsIdentifier(child, identifier))
: value &&
typeof value === "object" &&
containsIdentifier(value, identifier)),
? value.some((child) => someNode(child, predicate))
: value && typeof value === "object" && someNode(value, predicate)),
);
}

function containsIdentifier(node, identifier) {
return someNode(
node,
(n) => n.type === "Identifier" && n.name === identifier,
);
}

function readsProperty(node, object, property) {
return someNode(
node,
(n) =>
member(n, property) &&
n.object.type === "Identifier" &&
n.object.name === object,
);
}

/** Target positional types from `src/lib/target-positional.ts`. */
const BOUND_TARGET_TYPES = new Set([
"OrgOptionalTarget",
"OrgProjectTarget",
"ProjectBoundTarget",
]);

function boundTargetParams(fn) {
return fn.params.filter((param) => {
const type = param.typeAnnotation?.typeAnnotation;
return (
param.type === "Identifier" &&
type?.type === "TSTypeReference" &&
BOUND_TARGET_TYPES.has(type.typeName.name)
);
});
}

function silentBody(block, parameter) {
if (block?.type !== "BlockStatement" || block.body.length > 1) return false;
if (block.body.length === 0) return true;
Expand Down Expand Up @@ -203,6 +236,22 @@ const rules = {
}
: {},
),
"require-target-resolution": rule(
"Await `<target>.resolved`: it runs the target's resolver and destructive-command guard.",
(_context, file, report) => {
if (!file.includes("/src/commands/")) return {};
const check = (node) => {
for (const param of boundTargetParams(node)) {
if (!readsProperty(node.body, param.name, "resolved")) report(param);
}
};
return {
FunctionDeclaration: check,
FunctionExpression: check,
ArrowFunctionExpression: check,
};
},
),
"no-generic-is-record": rule(
"Use a shape-specific guard instead of isRecord().",
(_context, file, report) =>
Expand Down
21 changes: 11 additions & 10 deletions packages/cli/src/commands/feedback/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
listFeedback,
} from "../../lib/api-client.js";
import { validateLimit } from "../../lib/arg-parsing.js";
import { parseOrgProjectTarget } from "../../lib/completion-target.js";
import {
advancePaginationState,
buildPaginationContextKey,
Expand All @@ -35,10 +34,13 @@ import { jsonTransformListResult } from "../../lib/org-list.js";
import { withProgress } from "../../lib/polling.js";
import {
type ResolvedOrgOptionalTarget,
resolveOrgOptionalFromArg,
toNumericId,
} from "../../lib/resolve-target.js";
import { sanitizeQuery } from "../../lib/search-query.js";
import {
type OrgOptionalTarget,
orgOptionalTarget,
} from "../../lib/target-positional.js";
import {
appendPeriodHint,
PERIOD_BRIEF,
Expand All @@ -55,7 +57,6 @@ import {
const DEFAULT_PERIOD = "14d";
const DEFAULT_STATUS: FeedbackStatus = "unresolved";
const PAGINATION_KEY = "feedback-list";
const COMMAND_NAME = "feedback list";

type ListFlags = {
readonly status: FeedbackStatus;
Expand Down Expand Up @@ -159,7 +160,7 @@ export const listCommand = buildListCommand("feedback", {
{
placeholder: "org/project",
brief: "<org>/, <org>/<project>, or <project> (search)",
parse: parseOrgProjectTarget,
parse: orgOptionalTarget,
optional: true,
},
],
Expand Down Expand Up @@ -196,12 +197,12 @@ export const listCommand = buildListCommand("feedback", {
q: "query",
},
},
async *func(this: SentryContext, flags: ListFlags, target?: string) {
const resolved = await resolveOrgOptionalFromArg(
target,
this.cwd,
COMMAND_NAME,
);
async *func(
this: SentryContext,
flags: ListFlags,
target: OrgOptionalTarget,
) {
const resolved = await target.resolved;
const projectId = await projectIdFor(resolved);
const contextKey = buildPaginationContextKey(
"feedback",
Expand Down
34 changes: 13 additions & 21 deletions packages/cli/src/commands/project/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* Safety measures:
* - Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run`
* flags and enforces the non-interactive guard before `func()` runs
* - No auto-detect mode: `requireExplicitTarget` blocks accidental deletion
* - No auto-detect mode: `buildDeleteCommand` marks the target destructive, so
* `target.resolved` rejects an omitted target
* - Type-out confirmation via `confirmByTyping` (unless --yes/--force)
*/

Expand All @@ -24,8 +25,6 @@ import {
getOrganization,
getProject,
} from "../../lib/api-client.js";
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
import { parseOrgProjectTarget } from "../../lib/completion-target.js";
import { getCachedOrgRole } from "../../lib/db/regions.js";
import { ApiError } from "../../lib/errors.js";
import {
Expand All @@ -38,16 +37,15 @@ import {
buildDeleteCommand,
confirmByTyping,
isConfirmationBypassed,
requireExplicitTarget,
} from "../../lib/mutate-command.js";
import { resolveProjectBoundTarget } from "../../lib/resolve-target.js";
import { buildProjectUrl } from "../../lib/sentry-web-urls.js";
import {
type ProjectBoundTarget,
projectBoundTarget,
} from "../../lib/target-positional.js";

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

/** Command name used in error messages and resolution hints */
const COMMAND_NAME = "project delete";

/**
* Build an actionable 403 error by checking the user's org role.
*
Expand Down Expand Up @@ -169,23 +167,17 @@ export const deleteCommand = buildDeleteCommand({
{
placeholder: "org/project",
brief: "<org>/<project> or <project> (search across orgs)",
parse: parseOrgProjectTarget,
parse: projectBoundTarget,
},
],
},
},
async *func(this: SentryContext, flags: DeleteFlags, target: string) {
const { cwd } = this;

// Block auto-detect for safety — destructive commands require explicit targets
const parsed = parseOrgProjectArg(target);
requireExplicitTarget(
parsed,
"Project target",
`sentry ${COMMAND_NAME} <org>/<project>`,
);

const resolved = await resolveProjectBoundTarget(parsed, cwd, COMMAND_NAME);
async *func(
this: SentryContext,
flags: DeleteFlags,
target: ProjectBoundTarget,
) {
const resolved = await target.resolved;
Comment thread
sentry-junior[bot] marked this conversation as resolved.
const { org: orgSlug, project: projectSlug } = resolved;

// Use already-fetched project data from project-search, or fetch for
Expand Down
Loading
Loading