Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions apps/cli-docs/src/content/docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ The Sentry CLI includes several features designed to streamline your workflow, e

When the CLI asks you to choose an organization or project, type to filter the loaded options by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears the filter before cancelling; Ctrl+C cancels immediately. The same controls work in the organization, team, and monorepo app selectors in `sentry init`.

Selectors tolerate typos in unfinished prefixes: `frone` can find `frontend-web`. Queries with one or two characters match literal text only, so `ap` finds `api` but `zz` does not find `ui`.

## DSN Auto-Detection

The CLI automatically detects your Sentry project from your codebase, eliminating the need to specify the target for every command. DSN detection is one part of the [resolution priority chain](./configuration/#resolution-priority) — it runs after checking for explicit arguments, environment variables, and `.sentryclirc` config files.
Expand Down
2 changes: 1 addition & 1 deletion apps/cli-docs/src/fragments/commands/init.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

**Authentication:** Interactive runs start the OAuth login flow automatically when credentials are missing or the stored session has expired. Non-interactive and CI runs must authenticate before running `sentry init`.

In organization, team, and monorepo app selectors, type to filter the loaded choices by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears an active filter; with no filter, Esc cancels. Ctrl+C always cancels.
In organization, team, and monorepo app selectors, type to filter the loaded choices by name or slug. The filter appears as `/text` below the list. Matching ignores case and tolerates small typos, including in unfinished prefixes such as `frone` for `frontend-web`. Queries with one or two characters match literal text only. Use arrow keys to navigate, Enter to select, and Backspace to edit. Esc clears an active filter; with no filter, Esc cancels. Ctrl+C always cancels.

## Examples

Expand Down
20 changes: 13 additions & 7 deletions packages/cli/src/lib/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,10 +233,12 @@ export function completeProjectCreateSpec(partial: string): Completion[] {
const platformPartial = partial.slice(colonIdx + 1);
const candidates =
platformPartial === "" ? COMMON_PLATFORMS : VALID_PLATFORMS;
return fuzzyMatch(platformPartial, candidates).map((platform) => ({
value: `${specPrefix}${platform}`,
description: "Platform",
}));
return fuzzyMatch(platformPartial, candidates, { mode: "search" }).map(
(platform) => ({
value: `${specPrefix}${platform}`,
description: "Platform",
}),
);
}

if (partial.includes("/")) {
Expand All @@ -263,7 +265,9 @@ export function completeOrgSlugs(partial: string, suffix = ""): Completion[] {
}

const nameMap = new Map(orgs.map((o) => [o.slug, o.name]));
const matched = fuzzyMatch(partial, Array.from(nameMap.keys()));
const matched = fuzzyMatch(partial, Array.from(nameMap.keys()), {
mode: "search",
});

return matched.map((slug) => ({
value: `${slug}${suffix}`,
Expand Down Expand Up @@ -343,7 +347,9 @@ export function completeProjectSlugs(
}

const nameMap = new Map(projects.map((p) => [p.projectSlug, p.projectName]));
const matched = fuzzyMatch(projectPartial, Array.from(nameMap.keys()));
const matched = fuzzyMatch(projectPartial, Array.from(nameMap.keys()), {
mode: "search",
});

return matched.map((slug) => ({
value: `${orgSlug}/${slug}`,
Expand Down Expand Up @@ -385,7 +391,7 @@ export function completeAliases(partial: string): Completion[] {
}

const keys = Object.keys(aliases);
const matched = fuzzyMatch(partial, keys);
const matched = fuzzyMatch(partial, keys, { mode: "search" });

return matched.map((alias) => {
const entry = aliases[alias];
Expand Down
44 changes: 39 additions & 5 deletions packages/cli/src/lib/fuzzy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@
* @returns The minimum number of single-character edits (insert, delete, replace)
*/
export function levenshtein(a: string, b: string): number {
return editDistance(a, b);
}

/**
* Return the full-string edit distance, allowing a smaller distance to a
* non-empty prefix of `b` when it is within `prefixThreshold`.
*
* Omit `prefixThreshold` to compare only the complete strings.
*/
function editDistance(a: string, b: string, prefixThreshold?: number): number {
const m = a.length;
const n = b.length;
const dp = new Map<number, number>();
Expand All @@ -40,7 +50,18 @@ export function levenshtein(a: string, b: string): number {
dp.set(key(i, j), cost);
}
}
return dp.get(key(m, n)) ?? 0;
let distance = dp.get(key(m, n)) ?? 0;
if (prefixThreshold !== undefined) {
const firstLength = Math.max(1, m - prefixThreshold);
const lastLength = Math.min(n, m + prefixThreshold);
for (let length = firstLength; length <= lastLength; length++) {
const prefixDistance = dp.get(key(m, length)) ?? Number.POSITIVE_INFINITY;
if (prefixDistance <= prefixThreshold) {
distance = Math.min(distance, prefixDistance);
}
}
}
return distance;
}

/** Score tiers for fuzzy matching, ordered by relevance. */
Expand All @@ -54,6 +75,8 @@ const FUZZY_BASE_SCORE = 3;
export type FuzzyMatchOptions = {
/** Maximum number of results to return (default: unlimited). */
maxResults?: number;
/** Search unfinished input; omit to preserve submitted-name matching. */
mode?: "search";
};

/**
Expand All @@ -65,10 +88,16 @@ export type FuzzyMatchOptions = {
* 3. **Contains match** → score 2 (partial appears as substring)
* 4. **Fuzzy match** → score 3 + Levenshtein distance (capped by threshold)
*
* Threshold: `max(2, floor(partial.length / 3))`. This means `"tnry"`
* (length 4, threshold 2) matches `"sentry"` (distance 2). Candidates
* Threshold: `max(2, floor(partial.length / 3))`. This means `"isseu"`
* (length 5, threshold 2) matches `"issue"` (distance 2). Candidates
* beyond the threshold are excluded entirely.
*
* Search mode requires literal matches for one- and two-character queries.
* Longer queries also match candidate prefixes within `floor(length / 3)`
* edits, without counting the unfinished suffix. Full-name typo tolerance
* stays unchanged. Use this mode for selectors and incomplete completions,
* not automatic resolution of submitted names.
*
* When `partial` is empty, all candidates are returned (sorted alphabetically).
*
* @param partial - The partial input to match against
Expand All @@ -88,6 +117,7 @@ export function fuzzyMatch(

const lowerPartial = partial.toLowerCase();
const threshold = Math.max(2, Math.floor(partial.length / 3));
const search = opts?.mode === "search";
const scored: { candidate: string; score: number }[] = [];

for (const candidate of candidates) {
Expand All @@ -99,8 +129,12 @@ export function fuzzyMatch(
scored.push({ candidate, score: PREFIX_SCORE });
} else if (lowerCandidate.includes(lowerPartial)) {
scored.push({ candidate, score: CONTAINS_SCORE });
} else {
const dist = levenshtein(lowerPartial, lowerCandidate);
} else if (!search || partial.length >= 3) {
const dist = editDistance(
lowerPartial,
lowerCandidate,
search ? Math.floor(partial.length / 3) : undefined,
);
if (dist <= threshold) {
scored.push({ candidate, score: FUZZY_BASE_SCORE + dist });
}
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/init/ui/select-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function filterSelectOptions<T extends SelectOption<string>>(
if (option.hint) {
candidates.push(option.hint);
}
return fuzzyMatch(query, candidates).length > 0;
return fuzzyMatch(query, candidates, { mode: "search" }).length > 0;
});
}

Expand Down
52 changes: 48 additions & 4 deletions packages/cli/test/lib/complete.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ describe("completeProjectCreateSpec", () => {
]);
});

test("tolerates a typo in an unfinished platform name", () => {
expect(completeProjectCreateSpec("example:javascipt-r")).toContainEqual({
value: "example:javascript-react",
description: "Platform",
});
});

test("does not complete existing project names before the colon", () => {
expect(completeProjectCreateSpec("acme/new-project")).toEqual([]);
});
Expand Down Expand Up @@ -214,6 +221,14 @@ describe("completeOrgSlugs", () => {
const result = completeOrgSlugs("senry");
expect(result.some((c) => c.value === "sentry")).toBe(true);
});

test("tolerates a typo in an unfinished organization name", async () => {
await seedOrgs([{ slug: "frontend-web", name: "Frontend web" }]);

expect(completeOrgSlugs("frone")).toEqual([
{ value: "frontend-web", description: "Frontend web" },
]);
});
});

describe("completeOrgSlashProject", () => {
Expand Down Expand Up @@ -273,6 +288,21 @@ describe("completeOrgSlashProject", () => {
expect(result).toEqual([]);
});

test("does not treat the organization before a slash as unfinished", async () => {
await seedOrgs([{ slug: "frontend-web", name: "Frontend web" }]);
await seedProjects([
{
orgId: "1",
projectId: "10",
orgSlug: "frontend-web",
projectSlug: "backend",
projectName: "Backend",
},
]);

expect(completeOrgSlashProject("frone/")).toEqual([]);
});

test("with slash and partial project filters", async () => {
await seedOrgs([{ slug: "my-org", name: "My Org" }]);
await seedProjects([
Expand Down Expand Up @@ -327,6 +357,22 @@ describe("completeProjectSlugs", () => {
expect(result[0].value).toBe("my-org/web");
expect(result[0].description).toBe("Web App");
});

test("tolerates a typo in an unfinished project name", async () => {
await seedProjects([
{
orgId: "1",
projectId: "10",
orgSlug: "my-org",
projectSlug: "frontend-web",
projectName: "Frontend web",
},
]);

expect(completeProjectSlugs("frone", "my-org")).toEqual([
{ value: "my-org/frontend-web", description: "Frontend web" },
]);
});
});

describe("completeAliases", () => {
Expand All @@ -352,7 +398,7 @@ describe("completeAliases", () => {
expect(aCompletion!.description).toBe("my-org/frontend");
});

test("filters aliases by prefix (exact match ranks first)", async () => {
test("filters short aliases literally with the exact match first", async () => {
setProjectAliases(
{
a: { orgSlug: "org", projectSlug: "proj-a" },
Expand All @@ -363,8 +409,6 @@ describe("completeAliases", () => {
);

const result = completeAliases("a");
// "a" is exact match, "abc" is prefix match, "b" is fuzzy match
expect(result[0].value).toBe("a"); // exact match first
expect(result.some((c) => c.value === "abc")).toBe(true); // prefix match
expect(result.map((c) => c.value)).toEqual(["a", "abc"]);
});
});
38 changes: 38 additions & 0 deletions packages/cli/test/lib/fuzzy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,44 @@ describe("fuzzyMatch: unit tests", () => {
});
});

describe("fuzzyMatch: search mode", () => {
test.each(["frone", "froont", "frnt"])(
"matches an unfinished prefix with an edit: %s",
(query) => {
expect(fuzzyMatch(query, ["frontend-web"], { mode: "search" })).toEqual([
"frontend-web",
]);
},
);

test("preserves full-name typos while allowing incomplete typo prefixes", () => {
expect(fuzzyMatch("isseu", ["issue"], { mode: "search" })).toEqual([
"issue",
]);
expect(fuzzyMatch("frone", ["frontend-web"])).toEqual([]);
expect(fuzzyMatch("frone", ["frontend-web"], { mode: "search" })).toEqual([
"frontend-web",
]);
});

test("ranks literal matches before typo prefixes and respects the result limit", () => {
const candidates = ["frnntend-web", "team-frontend", "frontend", "front"];
const expected = ["front", "frontend", "team-frontend", "frnntend-web"];
expect(fuzzyMatch("FRONT", candidates, { mode: "search" })).toEqual(
expected,
);
expect(
fuzzyMatch("FRONT", candidates, { mode: "search", maxResults: 2 }),
).toEqual(expected.slice(0, 2));
});

test("keeps typo prefixes anchored to the beginning of a candidate", () => {
expect(
fuzzyMatch("frone", ["team-frontend-web"], { mode: "search" }),
).toEqual([]);
});
});

describe("property: fuzzyMatch", () => {
test("exact candidate is always in the result", () => {
const candidatesArb = array(shortStringArb, {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/test/lib/init/ui/ink-app.snapshot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ describe("Ink App snapshot", () => {
exitOnCtrlC: false,
});
try {
stdin.push("frontz");
stdin.push("frozz");
// Observe the empty state before later input can coalesce its render.
await vi.waitFor(() => {
expect(stripAnsi(output.latestFrame())).toContain("No matches");
Expand All @@ -1183,7 +1183,7 @@ describe("Ink App snapshot", () => {
stdin.push("\u007f\r");
await vi.waitFor(() => {
expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend");
expect(stripAnsi(output.latestFrame())).toContain("/front 1 of 1");
expect(stripAnsi(output.latestFrame())).toContain("/froz 1 of 1");
});
} finally {
instance.unmount();
Expand Down Expand Up @@ -1272,9 +1272,9 @@ describe("Ink App snapshot", () => {
});

const output = await renderApp(store, 80, {
input: ["\u001b[200~frontz\u001b[201~\u007f\r"],
input: ["\u001b[200~frozz\u001b[201~\u007f\r"],
});
expect(stripAnsi(output.latestFrame())).toContain("/front 1 of 2");
expect(stripAnsi(output.latestFrame())).toContain("/froz 1 of 2");
expect(resolve).toHaveBeenCalledExactlyOnceWith("frontend");
});

Expand Down
20 changes: 20 additions & 0 deletions packages/cli/test/lib/init/ui/select-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,26 @@ describe("filterSelectOptions", () => {
).toEqual([]);
});

test("short queries only match literal parts of the options", () => {
const candidates = ["UI", "Go", "DB", "pizza"].map((value) => ({
value,
label: value,
}));

expect(filterSelectOptions(candidates, "zz")).toEqual([candidates[3]]);
});

test("keeps a matching option visible while typing a prefix with a typo", () => {
const candidates = [
{ value: "frontend-web", label: "Frontend web" },
{ value: "backend", label: "Backend" },
];

for (const query of ["fron", "frone", "fronen", "fronend", "fronend-w"]) {
expect(filterSelectOptions(candidates, query)).toEqual([candidates[0]]);
}
});

test("an empty filter restores every original option", () => {
fcAssert(
property(
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/test/lib/resolve-target.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -905,6 +905,30 @@ describe("tryFuzzyProjectRecovery", () => {
}
});

test("keeps short submitted project names ambiguous", async () => {
setOrgRegion("test-org", DEFAULT_SENTRY_URL);
globalThis.fetch = mockListProjects({
"test-org": ["go-api", "db"],
});

const result = await tryFuzzyProjectRecovery("go", [{ slug: "test-org" }]);

expect(result.kind).toBe("suggestions");
});

test("does not recover a submitted project name from a typo in its prefix", async () => {
setOrgRegion("test-org", DEFAULT_SENTRY_URL);
globalThis.fetch = mockListProjects({
"test-org": ["frontend-web"],
});

const result = await tryFuzzyProjectRecovery("frone", [
{ slug: "test-org" },
]);

expect(result.kind).toBe("none");
});

test("returns 'none' when no similar projects exist", async () => {
setOrgRegion("test-org", DEFAULT_SENTRY_URL);
globalThis.fetch = mockListProjects({
Expand Down
Loading