3ddc757b15
Root cause: bun build --target bun inlines top-level
var { spawn } = globalThis.Bun;
for every file that contains 'import { spawn } from "bun"'. On Node/Electron
where globalThis.Bun is undefined, this crashes with
Cannot destructure property 'spawn' of 'globalThis.Bun' as it is undefined.
26 source files had this import; the bundled output had 25 top-level destructures.
Fix:
- Add src/shared/bun-spawn-shim.ts: a thin wrapper that
- delegates to Bun.spawn/spawnSync when globalThis.Bun is present (real Bun)
- falls back to static ESM imports of node:child_process otherwise
- uses static 'import { spawn } from "node:child_process"' so Bun bundler
does NOT emit any globalThis.Bun destructures for this module
- Replace all 26 'from "bun"' spawn/spawnSync imports with relative paths to shim
- Replace 4 direct Bun.spawn() call sites with shim's spawn()
- Remove src/electron-compat.ts and script/prepend-electron-shim.ts (no longer needed)
- Update src/electron-compat.test.ts to assert 0 top-level globalThis.Bun destructures
Verification: grep -c '} = globalThis.Bun;' dist/index.js → 0 (was 25)
All 5921 tests pass (1 pre-existing timeout failure unrelated to this change).
Fixes #3797
270 lines
7.5 KiB
TypeScript
270 lines
7.5 KiB
TypeScript
import { spawn } from "../../shared/bun-spawn-shim"
|
|
import { createRequire } from "module"
|
|
import { dirname, join } from "path"
|
|
import { existsSync } from "fs"
|
|
import * as fs from "fs"
|
|
import { tmpdir } from "os"
|
|
import { getCachedBinaryPath, ensureCommentCheckerBinary } from "./downloader"
|
|
|
|
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:cli] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
|
fs.appendFileSync(DEBUG_FILE, msg)
|
|
}
|
|
}
|
|
|
|
function getBinaryName(): string {
|
|
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
|
}
|
|
|
|
function findCommentCheckerPathSync(): string | null {
|
|
const binaryName = getBinaryName()
|
|
|
|
// Check cached binary first (safest path - no module resolution needed)
|
|
const cachedPath = getCachedBinaryPath()
|
|
if (cachedPath) {
|
|
debugLog("found binary in cache:", cachedPath)
|
|
return cachedPath
|
|
}
|
|
|
|
// Guard against undefined import.meta.url (can happen on Windows during plugin loading)
|
|
if (!import.meta.url) {
|
|
debugLog("import.meta.url is undefined, skipping package resolution")
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const require = createRequire(import.meta.url)
|
|
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
|
const cliDir = dirname(cliPkgPath)
|
|
const binaryPath = join(cliDir, "bin", binaryName)
|
|
|
|
if (existsSync(binaryPath)) {
|
|
debugLog("found binary in main package:", binaryPath)
|
|
return binaryPath
|
|
}
|
|
} catch (err) {
|
|
debugLog("main package not installed or resolution failed:", err)
|
|
}
|
|
|
|
debugLog("no binary found in known locations")
|
|
return null
|
|
}
|
|
|
|
// Cached resolved path
|
|
let resolvedCliPath: string | null = null
|
|
let initPromise: Promise<string | null> | null = null
|
|
|
|
/**
|
|
* Asynchronously get comment-checker binary path.
|
|
* Will trigger lazy download if binary not found.
|
|
*/
|
|
export async function getCommentCheckerPath(): Promise<string | null> {
|
|
// Return cached path if already resolved
|
|
if (resolvedCliPath !== null) {
|
|
return resolvedCliPath
|
|
}
|
|
|
|
// Return existing promise if initialization is in progress
|
|
if (initPromise) {
|
|
return initPromise
|
|
}
|
|
|
|
initPromise = (async () => {
|
|
// First try sync path resolution
|
|
const syncPath = findCommentCheckerPathSync()
|
|
if (syncPath && existsSync(syncPath)) {
|
|
resolvedCliPath = syncPath
|
|
debugLog("using sync-resolved path:", syncPath)
|
|
return syncPath
|
|
}
|
|
|
|
// Lazy download if not found
|
|
debugLog("triggering lazy download...")
|
|
const downloadedPath = await ensureCommentCheckerBinary()
|
|
if (downloadedPath) {
|
|
resolvedCliPath = downloadedPath
|
|
debugLog("using downloaded path:", downloadedPath)
|
|
return downloadedPath
|
|
}
|
|
|
|
debugLog("no binary available")
|
|
return null
|
|
})()
|
|
|
|
return initPromise
|
|
}
|
|
|
|
/**
|
|
* Synchronously get comment-checker path (no download).
|
|
* Returns cached path or searches known locations.
|
|
*/
|
|
export function getCommentCheckerPathSync(): string | null {
|
|
return resolvedCliPath ?? findCommentCheckerPathSync()
|
|
}
|
|
|
|
/**
|
|
* Start background initialization.
|
|
* Call this early to trigger download while other init happens.
|
|
*/
|
|
export function startBackgroundInit(): void {
|
|
if (!initPromise) {
|
|
initPromise = getCommentCheckerPath()
|
|
initPromise.then(path => {
|
|
debugLog("background init complete:", path || "no binary")
|
|
}).catch(err => {
|
|
debugLog("background init error:", err)
|
|
})
|
|
}
|
|
}
|
|
|
|
export interface HookInput {
|
|
session_id: string
|
|
tool_name: string
|
|
transcript_path: string
|
|
cwd: string
|
|
hook_event_name: string
|
|
tool_input: {
|
|
file_path?: string
|
|
content?: string
|
|
old_string?: string
|
|
new_string?: string
|
|
edits?: Array<{ old_string: string; new_string: string }>
|
|
}
|
|
tool_response?: unknown
|
|
}
|
|
|
|
export interface CheckResult {
|
|
hasComments: boolean
|
|
message: string
|
|
}
|
|
|
|
/**
|
|
* Run comment-checker CLI with given input.
|
|
* @param input Hook input to check
|
|
* @param cliPath Optional explicit path to CLI binary
|
|
* @param customPrompt Optional custom prompt to replace default warning message
|
|
*/
|
|
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> {
|
|
const binaryPath = cliPath ?? resolvedCliPath ?? getCommentCheckerPathSync()
|
|
|
|
if (!binaryPath) {
|
|
debugLog("comment-checker binary not found")
|
|
return { hasComments: false, message: "" }
|
|
}
|
|
|
|
if (!existsSync(binaryPath)) {
|
|
debugLog("comment-checker binary does not exist:", binaryPath)
|
|
return { hasComments: false, message: "" }
|
|
}
|
|
|
|
const jsonInput = JSON.stringify(input)
|
|
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
|
|
|
|
let didTimeout = false
|
|
|
|
try {
|
|
const args = [binaryPath, "check"]
|
|
if (customPrompt) {
|
|
args.push("--prompt", customPrompt)
|
|
}
|
|
|
|
const proc = spawn(args, {
|
|
stdin: "pipe",
|
|
stdout: "pipe",
|
|
stderr: "pipe",
|
|
})
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
const timeoutPromise = new Promise<"timeout">(resolve => {
|
|
timeoutId = setTimeout(async () => {
|
|
didTimeout = true
|
|
debugLog("comment-checker timed out after 30s; sending SIGTERM")
|
|
try {
|
|
proc.kill("SIGTERM")
|
|
} catch (err) {
|
|
debugLog("failed to SIGTERM:", err)
|
|
}
|
|
const graceTimer = setTimeout(() => {
|
|
try {
|
|
proc.kill("SIGKILL")
|
|
debugLog("sent SIGKILL after grace period")
|
|
} catch {
|
|
}
|
|
}, 1000)
|
|
try {
|
|
await proc.exited
|
|
} catch {
|
|
}
|
|
clearTimeout(graceTimer)
|
|
resolve("timeout")
|
|
}, 30_000)
|
|
})
|
|
|
|
try {
|
|
// Write JSON to stdin
|
|
proc.stdin.write(jsonInput)
|
|
proc.stdin.end()
|
|
|
|
const stdoutPromise = new Response(proc.stdout).text()
|
|
const stderrPromise = new Response(proc.stderr).text()
|
|
const exitCodePromise = proc.exited
|
|
|
|
const raceResult = await Promise.race([
|
|
Promise.all([stdoutPromise, stderrPromise, exitCodePromise] as const),
|
|
timeoutPromise,
|
|
])
|
|
|
|
if (raceResult === "timeout") {
|
|
return { hasComments: false, message: "" }
|
|
}
|
|
|
|
const [stdout, stderr, exitCode] = raceResult
|
|
|
|
debugLog("exit code:", exitCode, "stdout length:", stdout.length, "stderr length:", stderr.length)
|
|
|
|
if (exitCode === 0) {
|
|
return { hasComments: false, message: "" }
|
|
}
|
|
|
|
if (exitCode === 2) {
|
|
// Comments detected - message is in stderr
|
|
return { hasComments: true, message: stderr }
|
|
}
|
|
|
|
// Error case
|
|
debugLog("unexpected exit code:", exitCode, "stderr:", stderr)
|
|
return { hasComments: false, message: "" }
|
|
} finally {
|
|
if (timeoutId !== null) {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (didTimeout) {
|
|
return { hasComments: false, message: "" }
|
|
}
|
|
debugLog("failed to run comment-checker:", err)
|
|
return { hasComments: false, message: "" }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if CLI is available (sync check, no download).
|
|
*/
|
|
export function isCliAvailable(): boolean {
|
|
const path = getCommentCheckerPathSync()
|
|
return path !== null && existsSync(path)
|
|
}
|
|
|
|
/**
|
|
* Check if CLI will be available (async, may trigger download).
|
|
*/
|
|
export async function ensureCliAvailable(): Promise<boolean> {
|
|
const path = await getCommentCheckerPath()
|
|
return path !== null && existsSync(path)
|
|
}
|