diff --git a/package.json b/package.json index 69cbf61..190e235 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/package.json", "name": "whalincode", "private": true, "version": "1.0.0", @@ -6,10 +7,16 @@ "packages/*" ], "scripts": { + "start": "bun run --cwd packages/server start", "dev:cli": "bun run --watch packages/cli/src/index.tsx", "dev:server": "bun run --hot packages/server/src/index.ts", "format": "prettier . --write", - "format:check": "prettier . --check" + "format:check": "prettier . --check", + "typecheck": "bun run typecheck:cli && bun run typecheck:server && bun run typecheck:database && bun run typecheck:shared", + "typecheck:cli": "tsc --noEmit -p packages/cli/tsconfig.json", + "typecheck:server": "tsc --noEmit -p packages/server/tsconfig.json", + "typecheck:database": "tsc --noEmit -p packages/database/tsconfig.json", + "typecheck:shared": "tsc --noEmit -p packages/shared/tsconfig.json" }, "devDependencies": { "@types/node": "^25.9.1", diff --git a/packages/server/package.json b/packages/server/package.json index eb530b3..2a3b1b8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts" }, "scripts": { + "start": "bun run src/index.ts", "dev": "bun run --hot src/index.ts", "build": "bun build src/index.ts --outdir dist --target bun", "postinstall": "bun run --cwd ../database db:generate" diff --git a/packages/server/src/tools/bash.ts b/packages/server/src/tools/bash.ts index 73cbb0e..d404dfc 100644 --- a/packages/server/src/tools/bash.ts +++ b/packages/server/src/tools/bash.ts @@ -1,13 +1,34 @@ +import { platform } from 'os'; import { tool } from 'ai'; import { z } from 'zod'; const MAX_OUTPUT = 20_000; const DEFAULT_TIMEOUT = 30_000; +/** + * Resolve the shell invocation for the current platform so commands run + * consistently across Windows, macOS and Linux. + * + * - On Windows we prefer PowerShell (available by default on modern Windows) + * and fall back to cmd.exe when it isn't present. + * - On Unix-like systems we use the user's $SHELL, falling back to /bin/sh + * which is guaranteed to exist. + */ +function resolveShell(command: string): string[] { + if (platform() === 'win32') { + const comspec = process.env.ComSpec ?? 'cmd.exe'; + // /d skips AutoRun, /s + /c preserves quoting, /c runs and exits. + return [comspec, '/d', '/s', '/c', command]; + } + + const shell = process.env.SHELL ?? '/bin/sh'; + return [shell, '-c', command]; +} + export function createBashTool(cwd: string) { return tool({ description: - 'Execute a shell command in the project. Use this for running tests, builds, git operations, package installs, and any other shell commands.', + 'Execute a shell command in the project. Use this for running tests, builds, git operations, package installs, and any other shell commands. Runs on the platform-native shell (PowerShell/cmd on Windows, sh on Unix).', inputSchema: z.object({ command: z.string().describe('The shell command to execute'), timeout: z @@ -18,8 +39,10 @@ export function createBashTool(cwd: string) { ), }), execute: async ({ command, timeout }) => { + let timedOut = false; + try { - const proc = Bun.spawn(['bash', '-c', command], { + const proc = Bun.spawn(resolveShell(command), { cwd, stdout: 'pipe', stderr: 'pipe', @@ -27,9 +50,12 @@ export function createBashTool(cwd: string) { }); const timer = setTimeout(() => { + timedOut = true; proc.kill(); }, timeout); + const response = new Response(proc.stdout); + const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), @@ -43,6 +69,15 @@ export function createBashTool(cwd: string) { ? s.slice(0, MAX_OUTPUT) + `\n... (truncated, ${s.length} total chars)` : s; + if (timedOut) { + return { + stdout: truncate(stdout), + stderr: truncate(stderr), + exitCode, + error: `Command timed out after ${timeout}ms and was terminated.`, + }; + } + return { stdout: truncate(stdout), stderr: truncate(stderr), diff --git a/packages/server/src/tools/grep.ts b/packages/server/src/tools/grep.ts index dfb71ed..4c17281 100644 --- a/packages/server/src/tools/grep.ts +++ b/packages/server/src/tools/grep.ts @@ -1,9 +1,111 @@ -import { resolve, relative } from 'path'; +import { resolve, relative, join } from 'path'; +import { readdir } from 'fs/promises'; import { tool } from 'ai'; import { z } from 'zod'; const MAX_MATCHES = 50; +const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '.DS_Store']); + +const BINARY_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.bmp', + '.ico', + '.webp', + '.svg', + '.woff', + '.woff2', + '.ttf', + '.eot', + '.otf', + '.zip', + '.tar', + '.gz', + '.bz2', + '.7z', + '.rar', + '.exe', + '.dll', + '.so', + '.dylib', + '.bin', + '.pdf', + '.doc', + '.docx', + '.xls', + '.xlsx', + '.mp3', + '.mp4', + '.avi', + '.mov', + '.wav', + '.lock', +]); + +function isBinaryPath(filePath: string): boolean { + const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase(); + return BINARY_EXTENSIONS.has(ext); +} + +function isHiddenPath(filePath: string): boolean { + return filePath + .split(/[\\/]/) + .some((segment) => segment.startsWith('.') && segment !== '.' && segment !== '..'); +} + +/** + * Recursively walk a directory, pruning skip/hidden directories during + * traversal so we never descend into node_modules (etc.). Returns file paths + * relative to `root`. An optional glob matcher filters files by pattern. + */ +async function walkFiles( + root: string, + matcher: Bun.Glob | null, + dir = root, + results: string[] = [], +): Promise { + let entries; + + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return results; + } + + for (const entry of entries) { + const name = entry.name; + + if (entry.isDirectory()) { + // Prune excluded and hidden directories up front so we never + // traverse into them (e.g. node_modules, .git). + if (SKIP_DIRS.has(name) || name.startsWith('.')) { + continue; + } + await walkFiles(root, matcher, join(dir, name), results); + continue; + } + + if (!entry.isFile()) { + continue; + } + + const relPath = relative(root, join(dir, name)); + + if (matcher && !matcher.match(relPath)) { + continue; + } + + if (!isHiddenPath(relPath) && !isBinaryPath(relPath)) { + results.push(relPath); + } + } + + return results; +} + export function createGrepTool(cwd: string) { return tool({ description: @@ -29,69 +131,71 @@ export function createGrepTool(cwd: string) { } try { - const args = [ - '-rn', - '--color=never', - '--exclude-dir=node_modules', - '--exclude-dir=git', - '-E', - ]; - - if (include) { - args.push(`--include=${include}`); - } + const regex = new RegExp(pattern); - args.push(pattern, resolved); + // Scan all files in the target directory, pruning + // node_modules and hidden directories during traversal. + const matcher = include ? new Bun.Glob(include) : null; + const filePaths = await walkFiles(resolved, matcher); - const proc = Bun.spawn(['grep', ...args], { - stdout: 'pipe', - stderr: 'pipe', - cwd, - }); + filePaths.sort(); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); + const matches: { file: string; line: number; content: string }[] = []; + let truncated = false; + let scanedMatches = 0; - await proc.exited; + for (const filePath of filePaths) { + if (truncated) break; - // grep exits with 1 when no matches found - not an error - if (proc.exitCode !== 0 && proc.exitCode !== 1) { - return { error: `grep failed: ${stderr.trim()}` }; - } + const absolutePath = join(resolved, filePath); + let text: string; - if (!stdout.trim()) { - return { matches: [], message: 'No matches found' }; - } + try { + const file = Bun.file(absolutePath); + text = await file.text(); + } catch { + // Skip files that can't be read + continue; + } - const lines = stdout.trim().split('\n'); - const matches: { file: string; line: number; content: string }[] = []; - let truncated = false; + const lines = text.split('\n'); - for (const line of lines) { - if (matches.length >= MAX_MATCHES) { - truncated = true; - break; - } + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i]!)) { + scanedMatches++; - // grep output format; /absolute/path:linenum:content - const match = line.match(/^(.+?):(\d+):(.*)$/); - if (match) { - matches.push({ - file: relative(cwd, match[1]!), - line: parseInt(match[2]!, 10), - content: match[3]!, - }); + if (matches.length < MAX_MATCHES) { + matches.push({ + file: relative(cwd, absolutePath), + line: i + 1, + content: lines[i]!.trimEnd(), + }); + } else { + truncated = true; + break; + } + } } } + if (matches.length === 0) { + return { matches: [], message: 'No matches found' }; + } + return { matches, - ...(truncated ? { truncated: true, totalMatches: lines.length } : {}), + ...(truncated ? { truncated: true, scanedMatches } : {}), }; } catch (error) { const message = error instanceof Error ? error.message : String(error); + + // Check if it's an invalid regex + if (error instanceof SyntaxError) { + return { error: `Invalid regex pattern: ${message}` }; + } + return { - error: `Failed to execute command: ${message}`, + error: `Failed to search files: ${message}`, }; } },