Skip to content
69 changes: 69 additions & 0 deletions src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,75 @@ export async function findProjectsBySlug(
return searchResults.filter((r): r is ProjectWithOrg => r !== null);
}

/**
* Escape special regex characters in a string.
* Uses native RegExp.escape if available (Node.js 23.6+, Bun), otherwise polyfills.
*/
const escapeRegex: (str: string) => string =
typeof RegExp.escape === "function"
? RegExp.escape
: (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

/**
* Check if two strings match with word-boundary semantics (bidirectional).
*
* Returns true if either:
* - `a` appears in `b` at a word boundary
* - `b` appears in `a` at a word boundary
*
* @example
* matchesWordBoundary("cli", "cli-website") // true: "cli" in "cli-website"
* matchesWordBoundary("sentry-docs", "docs") // true: "docs" in "sentry-docs"
* matchesWordBoundary("cli", "eclipse") // false: no word boundary
*
* @internal Exported for testing
*/
export function matchesWordBoundary(a: string, b: string): boolean {
const aInB = new RegExp(`\\b${escapeRegex(a)}\\b`, "i");
const bInA = new RegExp(`\\b${escapeRegex(b)}\\b`, "i");
return aInB.test(b) || bInA.test(a);
}

/**
* Find projects matching a pattern with bidirectional word-boundary matching.
* Used for directory name inference when DSN detection fails.
*
* Uses `\b` regex word boundary, which matches:
* - Start/end of string
* - Between word char (`\w`) and non-word char (like "-")
*
* Matching is bidirectional:
* - Directory name in project slug: dir "cli" matches project "cli-website"
* - Project slug in directory name: project "docs" matches dir "sentry-docs"
*
* @param pattern - Directory name to match against project slugs
* @returns Array of matching projects with their org context
*/
export async function findProjectsByPattern(
pattern: string
): Promise<ProjectWithOrg[]> {
const orgs = await listOrganizations();

const searchResults = await Promise.all(
orgs.map(async (org) => {
try {
const projects = await listProjects(org.slug);
return projects
.filter((p) => matchesWordBoundary(pattern, p.slug))
.map((p) => ({ ...p, orgSlug: org.slug }));
} catch (error) {
if (error instanceof AuthError) {
throw error;
}
// Skip orgs where user lacks access (permission errors, etc.)
return [];
}
})
);

return searchResults.flat();
}

/**
* Find a project by DSN public key.
*
Expand Down
15 changes: 15 additions & 0 deletions src/lib/db/dsn-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ function rowToCachedDsnEntry(row: DsnCacheRow): CachedDsnEntry {
};
}

// Parse allResolved from all_dsns_json for inferred sources
if (row.source === "inferred" && row.all_dsns_json) {
try {
entry.allResolved = JSON.parse(
row.all_dsns_json
) as CachedDsnEntry["allResolved"];
} catch {
// Ignore parse errors, allResolved will be undefined
}
}

return entry;
}

Expand Down Expand Up @@ -140,6 +151,10 @@ export async function setCachedDsn(
resolved_org_name: entry.resolved?.orgName ?? null,
resolved_project_slug: entry.resolved?.projectSlug ?? null,
resolved_project_name: entry.resolved?.projectName ?? null,
// Store allResolved in all_dsns_json for inferred sources
all_dsns_json: entry.allResolved
? JSON.stringify(entry.allResolved)
: null,
cached_at: now,
last_accessed: now,
},
Expand Down
8 changes: 6 additions & 2 deletions src/lib/dsn/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import { z } from "zod";
* - env_file: .env file
* - config: Language-specific config file (e.g., sentry.properties)
* - code: Source code patterns (e.g., Sentry.init)
* - inferred: Inferred from directory name matching project slugs
*/
export type DsnSource = "env" | "env_file" | "config" | "code";
export type DsnSource = "env" | "env_file" | "config" | "code" | "inferred";

/**
* Parsed DSN components
Expand Down Expand Up @@ -80,6 +81,8 @@ export type CachedDsnEntry = {
sourcePath?: string;
/** Resolved project info (avoids API call on cache hit) */
resolved?: ResolvedProjectInfo;
/** All resolved targets (for inferred source with multiple matches) */
allResolved?: ResolvedProjectInfo[];
/** Timestamp when this entry was cached */
cachedAt: number;
};
Expand All @@ -97,9 +100,10 @@ export const CachedDsnEntrySchema = z.object({
dsn: z.string(),
projectId: z.string(),
orgId: z.string().optional(),
source: z.enum(["env", "env_file", "config", "code"]),
source: z.enum(["env", "env_file", "config", "code", "inferred"]),
sourcePath: z.string().optional(),
resolved: ResolvedProjectInfoSchema.optional(),
allResolved: z.array(ResolvedProjectInfoSchema).optional(),
cachedAt: z.number(),
});

Expand Down
168 changes: 164 additions & 4 deletions src/lib/resolve-target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,17 @@
* 1. Explicit CLI flags
* 2. Config defaults
* 3. DSN auto-detection (source code, .env files, environment variables)
* 4. Directory name inference (matches project slugs with word boundaries)
*/

import { findProjectByDsnKey, getProject } from "./api-client.js";
import { basename } from "node:path";
import {
findProjectByDsnKey,
findProjectsByPattern,
getProject,
} from "./api-client.js";
import { getDefaultOrganization, getDefaultProject } from "./db/defaults.js";
import { getCachedDsn, setCachedDsn } from "./db/dsn-cache.js";
import {
getCachedProject,
getCachedProjectByDsnKey,
Expand All @@ -22,6 +29,7 @@ import type { DetectedDsn } from "./dsn/index.js";
import {
detectAllDsns,
detectDsn,
findProjectRoot,
formatMultipleProjectsFooter,
getDsnSourceDescription,
} from "./dsn/index.js";
Expand Down Expand Up @@ -323,6 +331,134 @@ async function resolveDsnToTarget(
}
}

/** Minimum directory name length for inference (avoids matching too broadly) */
const MIN_DIR_NAME_LENGTH = 2;

/**
* Check if a directory name is valid for project inference.
* Rejects empty strings, hidden directories, and names that are too short.
*
* @internal Exported for testing
*/
export function isValidDirNameForInference(dirName: string): boolean {
if (!dirName || dirName.length < MIN_DIR_NAME_LENGTH) {
return false;
}
// Reject hidden directories (starting with .) - includes ".", "..", ".git", ".env"
if (dirName.startsWith(".")) {
return false;
}
return true;
}

/**
* Infer project(s) from directory name when DSN detection fails.
* Uses word-boundary matching (`\b`) against all accessible projects.
*
* Caches results in dsn_cache with source: "inferred" for performance.
* Cache is invalidated when directory mtime changes or after 24h TTL.
*
* @param cwd - Current working directory
* @returns Resolved targets, or empty if no matches found
*/
async function inferFromDirectoryName(cwd: string): Promise<ResolvedTargets> {
const { projectRoot } = await findProjectRoot(cwd);
const dirName = basename(projectRoot);

// Skip inference for invalid directory names
if (!isValidDirNameForInference(dirName)) {
return { targets: [] };
}

// Check cache first (reuse DSN cache with source: "inferred")
const cached = await getCachedDsn(projectRoot);
if (cached?.source === "inferred") {
const detectedFrom = `directory name "${dirName}"`;

// Return all cached targets if available
if (cached.allResolved && cached.allResolved.length > 0) {
const targets = cached.allResolved.map((r) => ({
org: r.orgSlug,
project: r.projectSlug,
orgDisplay: r.orgName,
projectDisplay: r.projectName,
detectedFrom,
}));
return {
targets,
footer:
targets.length > 1
? `Found ${targets.length} projects matching directory "${dirName}"`
: undefined,
};
}

// Fallback to single resolved target (legacy cache entries)
if (cached.resolved) {
return {
targets: [
{
org: cached.resolved.orgSlug,
project: cached.resolved.projectSlug,
orgDisplay: cached.resolved.orgName,
projectDisplay: cached.resolved.projectName,
detectedFrom,
},
],
};
}
}

// Search for matching projects using word-boundary matching
let matches: Awaited<ReturnType<typeof findProjectsByPattern>>;
try {
matches = await findProjectsByPattern(dirName);
} catch {
// If not authenticated or API fails, skip inference silently
return { targets: [] };
}

if (matches.length === 0) {
return { targets: [] };
}

// Cache all matches for faster subsequent lookups
const [primary] = matches;
if (primary) {
const allResolved = matches.map((m) => ({
orgSlug: m.orgSlug,
orgName: m.organization?.name ?? m.orgSlug,
projectSlug: m.slug,
projectName: m.name,
}));

await setCachedDsn(projectRoot, {
dsn: "", // No DSN for inferred
projectId: primary.id,
source: "inferred",
resolved: allResolved[0], // Primary for backwards compatibility
allResolved,
});
}

const detectedFrom = `directory name "${dirName}"`;
const targets: ResolvedTarget[] = matches.map((m) => ({
org: m.orgSlug,
project: m.slug,
orgDisplay: m.organization?.name ?? m.orgSlug,
projectDisplay: m.name,
detectedFrom,
}));

return {
targets,
footer:
matches.length > 1
? `Found ${matches.length} projects matching directory "${dirName}"`
: undefined,
};
}

/**
* Resolve all targets for monorepo-aware commands.
*
Expand All @@ -333,6 +469,7 @@ async function resolveDsnToTarget(
* 1. CLI flags (--org and --project) - returns single target
* 2. Config defaults - returns single target
* 3. DSN auto-detection - may return multiple targets
* 4. Directory name inference - matches project slugs with word boundaries
*
* @param options - Resolution options with flags and cwd
* @returns All resolved targets and optional footer message
Expand Down Expand Up @@ -385,7 +522,8 @@ export async function resolveAllTargets(
const detection = await detectAllDsns(cwd);

if (detection.all.length === 0) {
return { targets: [] };
// 4. Fallback: infer from directory name
return inferFromDirectoryName(cwd);
}

// Resolve all DSNs in parallel
Expand Down Expand Up @@ -438,6 +576,7 @@ export async function resolveAllTargets(
* 1. CLI flags (--org and --project) - both must be provided together
* 2. Config defaults
* 3. DSN auto-detection
* 4. Directory name inference - matches project slugs with word boundaries
*
* @param options - Resolution options with flags and cwd
* @returns Resolved target, or null if resolution failed
Expand Down Expand Up @@ -480,10 +619,31 @@ export async function resolveOrgAndProject(

// 3. DSN auto-detection
try {
return await resolveFromDsn(cwd);
const dsnResult = await resolveFromDsn(cwd);
if (dsnResult) {
return dsnResult;
}
} catch {
return null;
// Fall through to directory inference
}

// 4. Fallback: infer from directory name
const inferred = await inferFromDirectoryName(cwd);
if (inferred.targets.length > 0) {
const [first] = inferred.targets;
if (first) {
// If multiple matches, note it in detectedFrom
return {
...first,
detectedFrom:
inferred.targets.length > 1
? `${first.detectedFrom} (1 of ${inferred.targets.length} matches)`
: first.detectedFrom,
};
}
}

return null;
}

/**
Expand Down
Loading
Loading