Skip to content

Commit 36169c8

Browse files
committed
feat(ast-grep): add CLI path resolution and auto-download functionality
- Add automatic CLI binary path detection and resolution - Implement lazy binary download with caching - Add environment check utilities for CLI and NAPI availability - Improve error handling and fallback mechanisms - Export new utilities from index.ts
1 parent bf9f033 commit 36169c8

File tree

5 files changed

+417
-27
lines changed

5 files changed

+417
-27
lines changed

src/tools/ast-grep/cli.ts

Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { spawn } from "bun"
2-
import { SG_CLI_PATH } from "./constants"
2+
import { existsSync } from "fs"
3+
import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants"
4+
import { ensureAstGrepBinary } from "./downloader"
35
import type { CliMatch, CliLanguage } from "./types"
46

57
export interface RunOptions {
@@ -12,6 +14,65 @@ export interface RunOptions {
1214
updateAll?: boolean
1315
}
1416

17+
let resolvedCliPath: string | null = null
18+
let initPromise: Promise<string | null> | null = null
19+
20+
export async function getAstGrepPath(): Promise<string | null> {
21+
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
22+
return resolvedCliPath
23+
}
24+
25+
if (initPromise) {
26+
return initPromise
27+
}
28+
29+
initPromise = (async () => {
30+
const syncPath = findSgCliPathSync()
31+
if (syncPath && existsSync(syncPath)) {
32+
resolvedCliPath = syncPath
33+
setSgCliPath(syncPath)
34+
return syncPath
35+
}
36+
37+
const downloadedPath = await ensureAstGrepBinary()
38+
if (downloadedPath) {
39+
resolvedCliPath = downloadedPath
40+
setSgCliPath(downloadedPath)
41+
return downloadedPath
42+
}
43+
44+
return null
45+
})()
46+
47+
return initPromise
48+
}
49+
50+
export function startBackgroundInit(): void {
51+
if (!initPromise) {
52+
initPromise = getAstGrepPath()
53+
initPromise.catch(() => {})
54+
}
55+
}
56+
57+
interface SpawnResult {
58+
stdout: string
59+
stderr: string
60+
exitCode: number
61+
}
62+
63+
async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> {
64+
const proc = spawn([cliPath, ...args], {
65+
stdout: "pipe",
66+
stderr: "pipe",
67+
})
68+
69+
const stdout = await new Response(proc.stdout).text()
70+
const stderr = await new Response(proc.stderr).text()
71+
const exitCode = await proc.exited
72+
73+
return { stdout, stderr, exitCode }
74+
}
75+
1576
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
1677
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
1778

@@ -35,14 +96,45 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
3596
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
3697
args.push(...paths)
3798

38-
const proc = spawn([SG_CLI_PATH, ...args], {
39-
stdout: "pipe",
40-
stderr: "pipe",
41-
})
99+
let cliPath = getSgCliPath()
42100

43-
const stdout = await new Response(proc.stdout).text()
44-
const stderr = await new Response(proc.stderr).text()
45-
const exitCode = await proc.exited
101+
if (!existsSync(cliPath) && cliPath !== "sg") {
102+
const downloadedPath = await getAstGrepPath()
103+
if (downloadedPath) {
104+
cliPath = downloadedPath
105+
}
106+
}
107+
108+
let result: SpawnResult
109+
try {
110+
result = await spawnSg(cliPath, args)
111+
} catch (e) {
112+
const error = e as NodeJS.ErrnoException
113+
if (
114+
error.code === "ENOENT" ||
115+
error.message?.includes("ENOENT") ||
116+
error.message?.includes("not found")
117+
) {
118+
const downloadedPath = await ensureAstGrepBinary()
119+
if (downloadedPath) {
120+
resolvedCliPath = downloadedPath
121+
setSgCliPath(downloadedPath)
122+
result = await spawnSg(downloadedPath, args)
123+
} else {
124+
throw new Error(
125+
`ast-grep CLI binary not found.\n\n` +
126+
`Auto-download failed. Manual install options:\n` +
127+
` bun add -D @ast-grep/cli\n` +
128+
` cargo install ast-grep --locked\n` +
129+
` brew install ast-grep`
130+
)
131+
}
132+
} else {
133+
throw new Error(`Failed to spawn ast-grep: ${error.message}`)
134+
}
135+
}
136+
137+
const { stdout, stderr, exitCode } = result
46138

47139
if (exitCode !== 0 && stdout.trim() === "") {
48140
if (stderr.includes("No files found")) {
@@ -64,3 +156,13 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
64156
return []
65157
}
66158
}
159+
160+
export function isCliAvailable(): boolean {
161+
const path = findSgCliPathSync()
162+
return path !== null && existsSync(path)
163+
}
164+
165+
export async function ensureCliAvailable(): Promise<boolean> {
166+
const path = await getAstGrepPath()
167+
return path !== null && existsSync(path)
168+
}

src/tools/ast-grep/constants.ts

Lines changed: 139 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createRequire } from "module"
22
import { dirname, join } from "path"
33
import { existsSync } from "fs"
4+
import { getCachedBinaryPath } from "./downloader"
45

56
type Platform = "darwin" | "linux" | "win32" | "unsupported"
67

@@ -21,30 +22,30 @@ function getPlatformPackageName(): string | null {
2122
return platformMap[`${platform}-${arch}`] ?? null
2223
}
2324

24-
function findSgCliPath(): string {
25-
// 1. Try to find from @ast-grep/cli package (installed via npm)
25+
export function findSgCliPathSync(): string | null {
26+
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
27+
2628
try {
2729
const require = createRequire(import.meta.url)
2830
const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
2931
const cliDir = dirname(cliPkgPath)
30-
const sgPath = join(cliDir, process.platform === "win32" ? "sg.exe" : "sg")
32+
const sgPath = join(cliDir, binaryName)
3133

3234
if (existsSync(sgPath)) {
3335
return sgPath
3436
}
3537
} catch {
36-
// @ast-grep/cli not installed, try platform-specific package
38+
// @ast-grep/cli not installed
3739
}
3840

39-
// 2. Try platform-specific package directly
4041
const platformPkg = getPlatformPackageName()
4142
if (platformPkg) {
4243
try {
4344
const require = createRequire(import.meta.url)
4445
const pkgPath = require.resolve(`${platformPkg}/package.json`)
4546
const pkgDir = dirname(pkgPath)
46-
const binaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
47-
const binaryPath = join(pkgDir, binaryName)
47+
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
48+
const binaryPath = join(pkgDir, astGrepName)
4849

4950
if (existsSync(binaryPath)) {
5051
return binaryPath
@@ -54,12 +55,44 @@ function findSgCliPath(): string {
5455
}
5556
}
5657

57-
// 3. Fallback to system PATH
58+
if (process.platform === "darwin") {
59+
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
60+
for (const path of homebrewPaths) {
61+
if (existsSync(path)) {
62+
return path
63+
}
64+
}
65+
}
66+
67+
const cachedPath = getCachedBinaryPath()
68+
if (cachedPath) {
69+
return cachedPath
70+
}
71+
72+
return null
73+
}
74+
75+
let resolvedCliPath: string | null = null
76+
77+
export function getSgCliPath(): string {
78+
if (resolvedCliPath !== null) {
79+
return resolvedCliPath
80+
}
81+
82+
const syncPath = findSgCliPathSync()
83+
if (syncPath) {
84+
resolvedCliPath = syncPath
85+
return syncPath
86+
}
87+
5888
return "sg"
5989
}
6090

61-
// ast-grep CLI path (auto-detected from node_modules or system PATH)
62-
export const SG_CLI_PATH = findSgCliPath()
91+
export function setSgCliPath(path: string): void {
92+
resolvedCliPath = path
93+
}
94+
95+
export const SG_CLI_PATH = getSgCliPath()
6396

6497
// CLI supported languages (25 total)
6598
export const CLI_LANGUAGES = [
@@ -121,3 +154,99 @@ export const LANG_EXTENSIONS: Record<string, string[]> = {
121154
tsx: [".tsx"],
122155
yaml: [".yml", ".yaml"],
123156
}
157+
158+
export interface EnvironmentCheckResult {
159+
cli: {
160+
available: boolean
161+
path: string
162+
error?: string
163+
}
164+
napi: {
165+
available: boolean
166+
error?: string
167+
}
168+
}
169+
170+
/**
171+
* Check if ast-grep CLI and NAPI are available.
172+
* Call this at startup to provide early feedback about missing dependencies.
173+
*/
174+
export function checkEnvironment(): EnvironmentCheckResult {
175+
const result: EnvironmentCheckResult = {
176+
cli: {
177+
available: false,
178+
path: SG_CLI_PATH,
179+
},
180+
napi: {
181+
available: false,
182+
},
183+
}
184+
185+
// Check CLI availability
186+
if (existsSync(SG_CLI_PATH)) {
187+
result.cli.available = true
188+
} else if (SG_CLI_PATH === "sg") {
189+
// Fallback path - try which/where to find in PATH
190+
try {
191+
const { spawnSync } = require("child_process")
192+
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
193+
encoding: "utf-8",
194+
timeout: 5000,
195+
})
196+
result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
197+
if (!result.cli.available) {
198+
result.cli.error = "sg binary not found in PATH"
199+
}
200+
} catch {
201+
result.cli.error = "Failed to check sg availability"
202+
}
203+
} else {
204+
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
205+
}
206+
207+
// Check NAPI availability
208+
try {
209+
require("@ast-grep/napi")
210+
result.napi.available = true
211+
} catch (e) {
212+
result.napi.available = false
213+
result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}`
214+
}
215+
216+
return result
217+
}
218+
219+
/**
220+
* Format environment check result as user-friendly message.
221+
*/
222+
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
223+
const lines: string[] = ["ast-grep Environment Status:", ""]
224+
225+
// CLI status
226+
if (result.cli.available) {
227+
lines.push(`✓ CLI: Available (${result.cli.path})`)
228+
} else {
229+
lines.push(`✗ CLI: Not available`)
230+
if (result.cli.error) {
231+
lines.push(` Error: ${result.cli.error}`)
232+
}
233+
lines.push(` Install: bun add -D @ast-grep/cli`)
234+
}
235+
236+
// NAPI status
237+
if (result.napi.available) {
238+
lines.push(`✓ NAPI: Available`)
239+
} else {
240+
lines.push(`✗ NAPI: Not available`)
241+
if (result.napi.error) {
242+
lines.push(` Error: ${result.napi.error}`)
243+
}
244+
lines.push(` Install: bun add -D @ast-grep/napi`)
245+
}
246+
247+
lines.push("")
248+
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
249+
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
250+
251+
return lines.join("\n")
252+
}

0 commit comments

Comments
 (0)