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
This commit is contained in:
YeonGyu-Kim
2025-12-05 20:01:35 +09:00
parent bf9f033635
commit 36169c83fb
5 changed files with 417 additions and 27 deletions

View File

@@ -1,5 +1,7 @@
import { spawn } from "bun" import { spawn } from "bun"
import { SG_CLI_PATH } from "./constants" import { existsSync } from "fs"
import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants"
import { ensureAstGrepBinary } from "./downloader"
import type { CliMatch, CliLanguage } from "./types" import type { CliMatch, CliLanguage } from "./types"
export interface RunOptions { export interface RunOptions {
@@ -12,6 +14,65 @@ export interface RunOptions {
updateAll?: boolean updateAll?: boolean
} }
let resolvedCliPath: string | null = null
let initPromise: Promise<string | null> | null = null
export async function getAstGrepPath(): Promise<string | null> {
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
return resolvedCliPath
}
if (initPromise) {
return initPromise
}
initPromise = (async () => {
const syncPath = findSgCliPathSync()
if (syncPath && existsSync(syncPath)) {
resolvedCliPath = syncPath
setSgCliPath(syncPath)
return syncPath
}
const downloadedPath = await ensureAstGrepBinary()
if (downloadedPath) {
resolvedCliPath = downloadedPath
setSgCliPath(downloadedPath)
return downloadedPath
}
return null
})()
return initPromise
}
export function startBackgroundInit(): void {
if (!initPromise) {
initPromise = getAstGrepPath()
initPromise.catch(() => {})
}
}
interface SpawnResult {
stdout: string
stderr: string
exitCode: number
}
async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> {
const proc = spawn([cliPath, ...args], {
stdout: "pipe",
stderr: "pipe",
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
return { stdout, stderr, exitCode }
}
export async function runSg(options: RunOptions): Promise<CliMatch[]> { export async function runSg(options: RunOptions): Promise<CliMatch[]> {
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"] const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
@@ -35,14 +96,45 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."] const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
args.push(...paths) args.push(...paths)
const proc = spawn([SG_CLI_PATH, ...args], { let cliPath = getSgCliPath()
stdout: "pipe",
stderr: "pipe",
})
const stdout = await new Response(proc.stdout).text() if (!existsSync(cliPath) && cliPath !== "sg") {
const stderr = await new Response(proc.stderr).text() const downloadedPath = await getAstGrepPath()
const exitCode = await proc.exited if (downloadedPath) {
cliPath = downloadedPath
}
}
let result: SpawnResult
try {
result = await spawnSg(cliPath, args)
} catch (e) {
const error = e as NodeJS.ErrnoException
if (
error.code === "ENOENT" ||
error.message?.includes("ENOENT") ||
error.message?.includes("not found")
) {
const downloadedPath = await ensureAstGrepBinary()
if (downloadedPath) {
resolvedCliPath = downloadedPath
setSgCliPath(downloadedPath)
result = await spawnSg(downloadedPath, args)
} else {
throw new Error(
`ast-grep CLI binary not found.\n\n` +
`Auto-download failed. Manual install options:\n` +
` bun add -D @ast-grep/cli\n` +
` cargo install ast-grep --locked\n` +
` brew install ast-grep`
)
}
} else {
throw new Error(`Failed to spawn ast-grep: ${error.message}`)
}
}
const { stdout, stderr, exitCode } = result
if (exitCode !== 0 && stdout.trim() === "") { if (exitCode !== 0 && stdout.trim() === "") {
if (stderr.includes("No files found")) { if (stderr.includes("No files found")) {
@@ -64,3 +156,13 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
return [] return []
} }
} }
export function isCliAvailable(): boolean {
const path = findSgCliPathSync()
return path !== null && existsSync(path)
}
export async function ensureCliAvailable(): Promise<boolean> {
const path = await getAstGrepPath()
return path !== null && existsSync(path)
}

View File

@@ -1,6 +1,7 @@
import { createRequire } from "module" import { createRequire } from "module"
import { dirname, join } from "path" import { dirname, join } from "path"
import { existsSync } from "fs" import { existsSync } from "fs"
import { getCachedBinaryPath } from "./downloader"
type Platform = "darwin" | "linux" | "win32" | "unsupported" type Platform = "darwin" | "linux" | "win32" | "unsupported"
@@ -21,30 +22,30 @@ function getPlatformPackageName(): string | null {
return platformMap[`${platform}-${arch}`] ?? null return platformMap[`${platform}-${arch}`] ?? null
} }
function findSgCliPath(): string { export function findSgCliPathSync(): string | null {
// 1. Try to find from @ast-grep/cli package (installed via npm) const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
try { try {
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const cliPkgPath = require.resolve("@ast-grep/cli/package.json") const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
const cliDir = dirname(cliPkgPath) const cliDir = dirname(cliPkgPath)
const sgPath = join(cliDir, process.platform === "win32" ? "sg.exe" : "sg") const sgPath = join(cliDir, binaryName)
if (existsSync(sgPath)) { if (existsSync(sgPath)) {
return sgPath return sgPath
} }
} catch { } catch {
// @ast-grep/cli not installed, try platform-specific package // @ast-grep/cli not installed
} }
// 2. Try platform-specific package directly
const platformPkg = getPlatformPackageName() const platformPkg = getPlatformPackageName()
if (platformPkg) { if (platformPkg) {
try { try {
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const pkgPath = require.resolve(`${platformPkg}/package.json`) const pkgPath = require.resolve(`${platformPkg}/package.json`)
const pkgDir = dirname(pkgPath) const pkgDir = dirname(pkgPath)
const binaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep" const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
const binaryPath = join(pkgDir, binaryName) const binaryPath = join(pkgDir, astGrepName)
if (existsSync(binaryPath)) { if (existsSync(binaryPath)) {
return binaryPath return binaryPath
@@ -54,12 +55,44 @@ function findSgCliPath(): string {
} }
} }
// 3. Fallback to system PATH if (process.platform === "darwin") {
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
for (const path of homebrewPaths) {
if (existsSync(path)) {
return path
}
}
}
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
return cachedPath
}
return null
}
let resolvedCliPath: string | null = null
export function getSgCliPath(): string {
if (resolvedCliPath !== null) {
return resolvedCliPath
}
const syncPath = findSgCliPathSync()
if (syncPath) {
resolvedCliPath = syncPath
return syncPath
}
return "sg" return "sg"
} }
// ast-grep CLI path (auto-detected from node_modules or system PATH) export function setSgCliPath(path: string): void {
export const SG_CLI_PATH = findSgCliPath() resolvedCliPath = path
}
export const SG_CLI_PATH = getSgCliPath()
// CLI supported languages (25 total) // CLI supported languages (25 total)
export const CLI_LANGUAGES = [ export const CLI_LANGUAGES = [
@@ -121,3 +154,99 @@ export const LANG_EXTENSIONS: Record<string, string[]> = {
tsx: [".tsx"], tsx: [".tsx"],
yaml: [".yml", ".yaml"], yaml: [".yml", ".yaml"],
} }
export interface EnvironmentCheckResult {
cli: {
available: boolean
path: string
error?: string
}
napi: {
available: boolean
error?: string
}
}
/**
* Check if ast-grep CLI and NAPI are available.
* Call this at startup to provide early feedback about missing dependencies.
*/
export function checkEnvironment(): EnvironmentCheckResult {
const result: EnvironmentCheckResult = {
cli: {
available: false,
path: SG_CLI_PATH,
},
napi: {
available: false,
},
}
// Check CLI availability
if (existsSync(SG_CLI_PATH)) {
result.cli.available = true
} else if (SG_CLI_PATH === "sg") {
// Fallback path - try which/where to find in PATH
try {
const { spawnSync } = require("child_process")
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
encoding: "utf-8",
timeout: 5000,
})
result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
if (!result.cli.available) {
result.cli.error = "sg binary not found in PATH"
}
} catch {
result.cli.error = "Failed to check sg availability"
}
} else {
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
}
// Check NAPI availability
try {
require("@ast-grep/napi")
result.napi.available = true
} catch (e) {
result.napi.available = false
result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}`
}
return result
}
/**
* Format environment check result as user-friendly message.
*/
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
const lines: string[] = ["ast-grep Environment Status:", ""]
// CLI status
if (result.cli.available) {
lines.push(`✓ CLI: Available (${result.cli.path})`)
} else {
lines.push(`✗ CLI: Not available`)
if (result.cli.error) {
lines.push(` Error: ${result.cli.error}`)
}
lines.push(` Install: bun add -D @ast-grep/cli`)
}
// NAPI status
if (result.napi.available) {
lines.push(`✓ NAPI: Available`)
} else {
lines.push(`✗ NAPI: Not available`)
if (result.napi.error) {
lines.push(` Error: ${result.napi.error}`)
}
lines.push(` Install: bun add -D @ast-grep/napi`)
}
lines.push("")
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
return lines.join("\n")
}

View File

@@ -0,0 +1,151 @@
import { spawn } from "bun"
import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { createRequire } from "module"
const REPO = "ast-grep/ast-grep"
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
// This is only used as fallback when @ast-grep/cli package.json cannot be read
const DEFAULT_VERSION = "0.40.0"
function getAstGrepVersion(): string {
try {
const require = createRequire(import.meta.url)
const pkg = require("@ast-grep/cli/package.json")
return pkg.version
} catch {
return DEFAULT_VERSION
}
}
interface PlatformInfo {
arch: string
os: string
}
const PLATFORM_MAP: Record<string, PlatformInfo> = {
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
}
export function getCacheDir(): string {
if (process.platform === "win32") {
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
const base = localAppData || join(homedir(), "AppData", "Local")
return join(base, "oh-my-opencode", "bin")
}
const xdgCache = process.env.XDG_CACHE_HOME
const base = xdgCache || join(homedir(), ".cache")
return join(base, "oh-my-opencode", "bin")
}
export function getBinaryName(): string {
return process.platform === "win32" ? "sg.exe" : "sg"
}
export function getCachedBinaryPath(): string | null {
const binaryPath = join(getCacheDir(), getBinaryName())
return existsSync(binaryPath) ? binaryPath : null
}
async function extractZip(archivePath: string, destDir: string): Promise<void> {
const proc =
process.platform === "win32"
? spawn(
[
"powershell",
"-command",
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
],
{ stdout: "pipe", stderr: "pipe" }
)
: spawn(["unzip", "-o", archivePath, "-d", destDir], { stdout: "pipe", stderr: "pipe" })
const exitCode = await proc.exited
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text()
const toolHint =
process.platform === "win32"
? "Ensure PowerShell is available on your system."
: "Please install 'unzip' (e.g., apt install unzip, brew install unzip)."
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}\n\n${toolHint}`)
}
}
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
const platformKey = `${process.platform}-${process.arch}`
const platformInfo = PLATFORM_MAP[platformKey]
if (!platformInfo) {
console.error(`[oh-my-opencode] Unsupported platform for ast-grep: ${platformKey}`)
return null
}
const cacheDir = getCacheDir()
const binaryName = getBinaryName()
const binaryPath = join(cacheDir, binaryName)
if (existsSync(binaryPath)) {
return binaryPath
}
const { arch, os } = platformInfo
const assetName = `app-${arch}-${os}.zip`
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
console.log(`[oh-my-opencode] Downloading ast-grep binary...`)
try {
if (!existsSync(cacheDir)) {
mkdirSync(cacheDir, { recursive: true })
}
const response = await fetch(downloadUrl, { redirect: "follow" })
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const archivePath = join(cacheDir, assetName)
const arrayBuffer = await response.arrayBuffer()
await Bun.write(archivePath, arrayBuffer)
await extractZip(archivePath, cacheDir)
if (existsSync(archivePath)) {
unlinkSync(archivePath)
}
if (process.platform !== "win32" && existsSync(binaryPath)) {
chmodSync(binaryPath, 0o755)
}
console.log(`[oh-my-opencode] ast-grep binary ready.`)
return binaryPath
} catch (err) {
console.error(
`[oh-my-opencode] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
)
return null
}
}
export async function ensureAstGrepBinary(): Promise<string | null> {
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
return cachedPath
}
const version = getAstGrepVersion()
return downloadAstGrep(version)
}

View File

@@ -1,14 +1,12 @@
import { import { ast_grep_search, ast_grep_replace } from "./tools"
ast_grep_search,
ast_grep_replace,
} from "./tools"
export const builtinTools = { export const builtinTools = {
ast_grep_search, ast_grep_search,
ast_grep_replace, ast_grep_replace,
} }
export { export { ast_grep_search, ast_grep_replace }
ast_grep_search, export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
ast_grep_replace, export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
} export { checkEnvironment, formatEnvironmentCheck } from "./constants"
export type { EnvironmentCheckResult } from "./constants"

View File

@@ -1,4 +1,5 @@
import { parse, Lang } from "@ast-grep/napi" import { parse, Lang } from "@ast-grep/napi"
import { NAPI_LANGUAGES } from "./constants"
import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types" import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types"
const LANG_MAP: Record<NapiLanguage, Lang> = { const LANG_MAP: Record<NapiLanguage, Lang> = {
@@ -10,7 +11,16 @@ const LANG_MAP: Record<NapiLanguage, Lang> = {
} }
export function parseCode(code: string, lang: NapiLanguage) { export function parseCode(code: string, lang: NapiLanguage) {
return parse(LANG_MAP[lang], code) const parseLang = LANG_MAP[lang]
if (!parseLang) {
const supportedLangs = NAPI_LANGUAGES.join(", ")
throw new Error(
`Unsupported language for NAPI: "${lang}"\n` +
`Supported languages: ${supportedLangs}\n\n` +
`Use ast_grep_search for other languages (25 supported via CLI).`
)
}
return parse(parseLang, code)
} }
export function findPattern(root: ReturnType<typeof parseCode>, pattern: string) { export function findPattern(root: ReturnType<typeof parseCode>, pattern: string) {