import { spawn } from "bun" import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs" import { join } from "path" import { homedir, tmpdir } from "os" import { createRequire } from "module" import { extractZip } from "../../shared" const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1" const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log") function debugLog(...args: unknown[]) { if (DEBUG) { const msg = `[${new Date().toISOString()}] [comment-checker:downloader] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n` appendFileSync(DEBUG_FILE, msg) } } const REPO = "code-yeongyu/go-claude-code-comment-checker" interface PlatformInfo { os: string arch: string ext: "tar.gz" | "zip" } const PLATFORM_MAP: Record = { "darwin-arm64": { os: "darwin", arch: "arm64", ext: "tar.gz" }, "darwin-x64": { os: "darwin", arch: "amd64", ext: "tar.gz" }, "linux-arm64": { os: "linux", arch: "arm64", ext: "tar.gz" }, "linux-x64": { os: "linux", arch: "amd64", ext: "tar.gz" }, "win32-x64": { os: "windows", arch: "amd64", ext: "zip" }, } /** * Get the cache directory for oh-my-opencode binaries. * On Windows: Uses %LOCALAPPDATA% or %APPDATA% (Windows conventions) * On Unix: Follows XDG Base Directory Specification */ 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") } /** * Get the binary name based on platform. */ export function getBinaryName(): string { return process.platform === "win32" ? "comment-checker.exe" : "comment-checker" } /** * Get the cached binary path if it exists. */ export function getCachedBinaryPath(): string | null { const binaryPath = join(getCacheDir(), getBinaryName()) return existsSync(binaryPath) ? binaryPath : null } /** * Get the version from the installed @code-yeongyu/comment-checker package. */ function getPackageVersion(): string { try { const require = createRequire(import.meta.url) const pkg = require("@code-yeongyu/comment-checker/package.json") return pkg.version } catch { // Fallback to hardcoded version if package not found return "0.4.1" } } /** * Extract tar.gz archive using system tar command. */ async function extractTarGz(archivePath: string, destDir: string): Promise { debugLog("Extracting tar.gz:", archivePath, "to", destDir) const proc = spawn(["tar", "-xzf", archivePath, "-C", destDir], { stdout: "pipe", stderr: "pipe", }) const exitCode = await proc.exited if (exitCode !== 0) { const stderr = await new Response(proc.stderr).text() throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`) } } /** * Download the comment-checker binary from GitHub Releases. * Returns the path to the downloaded binary, or null on failure. */ export async function downloadCommentChecker(): Promise { const platformKey = `${process.platform}-${process.arch}` const platformInfo = PLATFORM_MAP[platformKey] if (!platformInfo) { debugLog(`Unsupported platform: ${platformKey}`) return null } const cacheDir = getCacheDir() const binaryName = getBinaryName() const binaryPath = join(cacheDir, binaryName) // Already exists in cache if (existsSync(binaryPath)) { debugLog("Binary already cached at:", binaryPath) return binaryPath } const version = getPackageVersion() const { os, arch, ext } = platformInfo const assetName = `comment-checker_v${version}_${os}_${arch}.${ext}` const downloadUrl = `https://github.com/${REPO}/releases/download/v${version}/${assetName}` debugLog(`Downloading from: ${downloadUrl}`) console.log(`[oh-my-opencode] Downloading comment-checker binary...`) try { // Ensure cache directory exists if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }) } // Download with fetch() - Bun handles redirects automatically 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) debugLog(`Downloaded archive to: ${archivePath}`) // Extract based on file type if (ext === "tar.gz") { await extractTarGz(archivePath, cacheDir) } else { await extractZip(archivePath, cacheDir) } // Clean up archive if (existsSync(archivePath)) { unlinkSync(archivePath) } // Set execute permission on Unix if (process.platform !== "win32" && existsSync(binaryPath)) { chmodSync(binaryPath, 0o755) } debugLog(`Successfully downloaded binary to: ${binaryPath}`) console.log(`[oh-my-opencode] comment-checker binary ready.`) return binaryPath } catch (err) { debugLog(`Failed to download: ${err}`) console.error(`[oh-my-opencode] Failed to download comment-checker: ${err instanceof Error ? err.message : err}`) console.error(`[oh-my-opencode] Comment checking disabled.`) return null } } /** * Ensure the comment-checker binary is available. * First checks cache, then downloads if needed. * Returns the binary path or null if unavailable. */ export async function ensureCommentCheckerBinary(): Promise { // Check cache first const cachedPath = getCachedBinaryPath() if (cachedPath) { debugLog("Using cached binary:", cachedPath) return cachedPath } // Download if not cached return downloadCommentChecker() }