@@ -32,6 +32,8 @@ import type {
3232 DetectSentryPayload ,
3333 DirEntry ,
3434 FileExistsBatchPayload ,
35+ GlobPayload ,
36+ GrepPayload ,
3537 ListDirPayload ,
3638 LocalOpPayload ,
3739 LocalOpResult ,
@@ -313,6 +315,10 @@ export async function handleLocalOp(
313315 return await runCommands ( payload , options . dryRun ) ;
314316 case "apply-patchset" :
315317 return await applyPatchset ( payload , options . dryRun ) ;
318+ case "grep" :
319+ return await grep ( payload ) ;
320+ case "glob" :
321+ return await glob ( payload ) ;
316322 case "create-sentry-project" :
317323 return await createSentryProject ( payload , options ) ;
318324 case "detect-sentry" :
@@ -846,6 +852,185 @@ async function detectSentry(
846852 } ;
847853}
848854
855+ // ── Grep & Glob ─────────────────────────────────────────────────────
856+
857+ const MAX_GREP_RESULTS_PER_SEARCH = 100 ;
858+ const MAX_GREP_LINE_LENGTH = 2000 ;
859+ const MAX_GLOB_RESULTS = 100 ;
860+ const SKIP_DIRS = new Set ( [
861+ "node_modules" ,
862+ ".git" ,
863+ "__pycache__" ,
864+ ".venv" ,
865+ "venv" ,
866+ "dist" ,
867+ "build" ,
868+ ] ) ;
869+
870+ type GrepMatch = { path : string ; lineNum : number ; line : string } ;
871+
872+ /**
873+ * Recursively walk a directory, yielding relative file paths.
874+ * Skips common non-source directories and respects an optional glob filter.
875+ */
876+ async function * walkFiles (
877+ root : string ,
878+ base : string ,
879+ globPattern : string | undefined
880+ ) : AsyncGenerator < string > {
881+ let entries : fs . Dirent [ ] ;
882+ try {
883+ entries = await fs . promises . readdir ( base , { withFileTypes : true } ) ;
884+ } catch {
885+ return ;
886+ }
887+ for ( const entry of entries ) {
888+ const full = path . join ( base , entry . name ) ;
889+ const rel = path . relative ( root , full ) ;
890+ if (
891+ entry . isDirectory ( ) &&
892+ ! SKIP_DIRS . has ( entry . name ) &&
893+ ! entry . name . startsWith ( "." )
894+ ) {
895+ yield * walkFiles ( root , full , globPattern ) ;
896+ } else if (
897+ entry . isFile ( ) &&
898+ ( ! globPattern || matchGlob ( entry . name , globPattern ) )
899+ ) {
900+ yield rel ;
901+ }
902+ }
903+ }
904+
905+ /** Minimal glob matcher — supports `*` and `?` wildcards. */
906+ function matchGlob ( name : string , pattern : string ) : boolean {
907+ const re = pattern
908+ . replace ( / [ . + ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" )
909+ . replace ( / \* / g, ".*" )
910+ . replace ( / \? / g, "." ) ;
911+ return new RegExp ( `^${ re } $` ) . test ( name ) ;
912+ }
913+
914+ /**
915+ * Search files for a regex pattern. Reads files line-by-line and collects
916+ * matches up to `maxResults`.
917+ */
918+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: file-walking search with early exits
919+ async function grepSearch ( opts : {
920+ cwd : string ;
921+ pattern : string ;
922+ searchPath : string | undefined ;
923+ include : string | undefined ;
924+ maxResults : number ;
925+ } ) : Promise < { matches : GrepMatch [ ] ; truncated : boolean } > {
926+ const { cwd, pattern, searchPath, include, maxResults } = opts ;
927+ const target = searchPath ? safePath ( cwd , searchPath ) : cwd ;
928+ const regex = new RegExp ( pattern ) ;
929+ const matches : GrepMatch [ ] = [ ] ;
930+ let truncated = false ;
931+
932+ for await ( const rel of walkFiles ( cwd , target , include ) ) {
933+ if ( matches . length >= maxResults ) {
934+ truncated = true ;
935+ break ;
936+ }
937+ const absPath = path . join ( cwd , rel ) ;
938+ let content : string ;
939+ try {
940+ content = await fs . promises . readFile ( absPath , "utf-8" ) ;
941+ } catch {
942+ continue ;
943+ }
944+ const lines = content . split ( "\n" ) ;
945+ for ( let i = 0 ; i < lines . length ; i += 1 ) {
946+ const line = lines [ i ] ?? "" ;
947+ if ( regex . test ( line ) ) {
948+ let text = line ;
949+ if ( text . length > MAX_GREP_LINE_LENGTH ) {
950+ text = `${ text . substring ( 0 , MAX_GREP_LINE_LENGTH ) } …` ;
951+ }
952+ matches . push ( { path : rel , lineNum : i + 1 , line : text } ) ;
953+ if ( matches . length >= maxResults ) {
954+ truncated = true ;
955+ break ;
956+ }
957+ }
958+ }
959+ }
960+
961+ return { matches, truncated } ;
962+ }
963+
964+ async function grep ( payload : GrepPayload ) : Promise < LocalOpResult > {
965+ const { cwd, params } = payload ;
966+ const maxResults = params . maxResultsPerSearch ?? MAX_GREP_RESULTS_PER_SEARCH ;
967+
968+ const results = await Promise . all (
969+ params . searches . map ( async ( search ) => {
970+ const { matches, truncated } = await grepSearch ( {
971+ cwd,
972+ pattern : search . pattern ,
973+ searchPath : search . path ,
974+ include : search . include ,
975+ maxResults,
976+ } ) ;
977+ return {
978+ pattern : search . pattern ,
979+ matches,
980+ truncated,
981+ totalMatches : matches . length ,
982+ } ;
983+ } )
984+ ) ;
985+
986+ return { ok : true , data : { results } } ;
987+ }
988+
989+ async function globSearch (
990+ cwd : string ,
991+ pattern : string ,
992+ searchPath : string | undefined ,
993+ maxResults : number
994+ ) : Promise < { files : string [ ] ; truncated : boolean } > {
995+ const target = searchPath ? safePath ( cwd , searchPath ) : cwd ;
996+ const files : string [ ] = [ ] ;
997+ let truncated = false ;
998+
999+ for await ( const rel of walkFiles ( cwd , target , pattern ) ) {
1000+ files . push ( rel ) ;
1001+ if ( files . length >= maxResults + 1 ) {
1002+ truncated = true ;
1003+ break ;
1004+ }
1005+ }
1006+
1007+ if ( truncated ) {
1008+ files . length = maxResults ;
1009+ }
1010+ return { files, truncated } ;
1011+ }
1012+
1013+ async function glob ( payload : GlobPayload ) : Promise < LocalOpResult > {
1014+ const { cwd, params } = payload ;
1015+ const maxResults = params . maxResults ?? MAX_GLOB_RESULTS ;
1016+
1017+ const results = await Promise . all (
1018+ params . patterns . map ( async ( pattern ) => {
1019+ const { files, truncated } = await globSearch (
1020+ cwd ,
1021+ pattern ,
1022+ params . path ,
1023+ maxResults
1024+ ) ;
1025+ return { pattern, files, truncated } ;
1026+ } )
1027+ ) ;
1028+
1029+ return { ok : true , data : { results } } ;
1030+ }
1031+
1032+ // ── Sentry project + DSN ────────────────────────────────────────────
1033+
8491034async function createSentryProject (
8501035 payload : CreateSentryProjectPayload ,
8511036 options : WizardOptions
0 commit comments