|
| 1 | +/** |
| 2 | + * Self-heal for sqlite3's native binding. |
| 3 | + * |
| 4 | + * `@cursor/sdk` depends on `sqlite3` (a native addon). opencode installs |
| 5 | + * plugin packages with Bun, which does not run sqlite3's `install` lifecycle |
| 6 | + * script (`prebuild-install -r napi || node-gyp rebuild`), so the installed |
| 7 | + * tree has **no** `node_sqlite3.node` binary and the SDK crashes at import |
| 8 | + * with "Could not locate the bindings file". |
| 9 | + * |
| 10 | + * Before loading the SDK (in-process or via the Node sidecar) we check for a |
| 11 | + * binding and, when it is missing, run sqlite3's own `prebuild-install -r napi` |
| 12 | + * to fetch the prebuilt NAPI binary (ABI-portable across Node versions, also |
| 13 | + * loadable by Bun). Failures degrade to a clear warning; the SDK import then |
| 14 | + * surfaces its own error. |
| 15 | + */ |
| 16 | +import { execSync, spawn } from "node:child_process"; |
| 17 | +import { existsSync, readdirSync, statSync } from "node:fs"; |
| 18 | +import { createRequire } from "node:module"; |
| 19 | +import { dirname, join } from "node:path"; |
| 20 | + |
| 21 | +export type EnsureResult = "present" | "repaired" | "failed" | "not-found"; |
| 22 | + |
| 23 | +export interface EnsureOptions { |
| 24 | + /** Override the sqlite3 package directory (tests). */ |
| 25 | + sqliteDir?: string; |
| 26 | + /** Override the repair runner (tests). Returns true when the command succeeded. */ |
| 27 | + run?: (sqliteDir: string) => Promise<boolean>; |
| 28 | + /** Override the warning sink (tests). */ |
| 29 | + log?: (message: string) => void; |
| 30 | +} |
| 31 | + |
| 32 | +/** Directories (relative to the sqlite3 package root) that may hold the binding. */ |
| 33 | +const BINDING_ROOTS = ["build", "lib/binding", "compiled"]; |
| 34 | + |
| 35 | +function hasNodeFile(dir: string, depth: number): boolean { |
| 36 | + if (depth < 0) return false; |
| 37 | + let entries: string[]; |
| 38 | + try { |
| 39 | + entries = readdirSync(dir); |
| 40 | + } catch { |
| 41 | + return false; |
| 42 | + } |
| 43 | + for (const entry of entries) { |
| 44 | + const path = join(dir, entry); |
| 45 | + if (entry.endsWith(".node")) { |
| 46 | + try { |
| 47 | + if (statSync(path).isFile()) return true; |
| 48 | + } catch { |
| 49 | + // ignore unreadable entries |
| 50 | + } |
| 51 | + continue; |
| 52 | + } |
| 53 | + try { |
| 54 | + if (statSync(path).isDirectory() && hasNodeFile(path, depth - 1)) return true; |
| 55 | + } catch { |
| 56 | + // ignore unreadable entries |
| 57 | + } |
| 58 | + } |
| 59 | + return false; |
| 60 | +} |
| 61 | + |
| 62 | +/** True when the sqlite3 package dir contains a compiled `.node` binding. */ |
| 63 | +export function hasSqliteBinding(sqliteDir: string): boolean { |
| 64 | + return BINDING_ROOTS.some((root) => hasNodeFile(join(sqliteDir, root), 3)); |
| 65 | +} |
| 66 | + |
| 67 | +/** |
| 68 | + * Locate the sqlite3 package directory that `@cursor/sdk` will load, walking |
| 69 | + * the same resolution chain (our module -> @cursor/sdk -> sqlite3). |
| 70 | + */ |
| 71 | +export function resolveSqliteDir(): string | undefined { |
| 72 | + const req = createRequire(import.meta.url); |
| 73 | + try { |
| 74 | + const sdkPkg = req.resolve("@cursor/sdk/package.json"); |
| 75 | + return dirname(createRequire(sdkPkg).resolve("sqlite3/package.json")); |
| 76 | + } catch { |
| 77 | + // fall through: try resolving sqlite3 directly (hoisted installs) |
| 78 | + } |
| 79 | + try { |
| 80 | + return dirname(req.resolve("sqlite3/package.json")); |
| 81 | + } catch { |
| 82 | + return undefined; |
| 83 | + } |
| 84 | +} |
| 85 | + |
| 86 | +function detectNodeExecutable(): string { |
| 87 | + const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"; |
| 88 | + if (!isBun) return process.execPath; |
| 89 | + // Under Bun prefer a real Node (matches the sidecar runtime); prebuild-install |
| 90 | + // itself is plain JS, so Bun works as a last resort. |
| 91 | + try { |
| 92 | + const out = execSync(process.platform === "win32" ? "where node" : "command -v node", { |
| 93 | + encoding: "utf8", |
| 94 | + stdio: ["ignore", "pipe", "ignore"], |
| 95 | + }).trim(); |
| 96 | + return out.split("\n")[0] || process.execPath; |
| 97 | + } catch { |
| 98 | + return process.execPath; |
| 99 | + } |
| 100 | +} |
| 101 | + |
| 102 | +/** Default repair: run sqlite3's own `prebuild-install -r napi` in its package dir. */ |
| 103 | +async function runPrebuildInstall(sqliteDir: string): Promise<boolean> { |
| 104 | + let bin: string; |
| 105 | + try { |
| 106 | + const req = createRequire(join(sqliteDir, "package.json")); |
| 107 | + const pkgPath = req.resolve("prebuild-install/package.json"); |
| 108 | + const pkg = (await import(pkgPath, { with: { type: "json" } })) as { |
| 109 | + default: { bin?: string | Record<string, string> }; |
| 110 | + }; |
| 111 | + const binField = pkg.default.bin; |
| 112 | + const rel = typeof binField === "string" ? binField : binField?.["prebuild-install"]; |
| 113 | + if (!rel) return false; |
| 114 | + bin = join(dirname(pkgPath), rel); |
| 115 | + } catch { |
| 116 | + return false; |
| 117 | + } |
| 118 | + if (!existsSync(bin)) return false; |
| 119 | + |
| 120 | + return new Promise<boolean>((resolve) => { |
| 121 | + const child = spawn(detectNodeExecutable(), [bin, "-r", "napi"], { |
| 122 | + cwd: sqliteDir, |
| 123 | + stdio: ["ignore", "ignore", "pipe"], |
| 124 | + }); |
| 125 | + let stderr = ""; |
| 126 | + child.stderr?.on("data", (chunk: Buffer) => { |
| 127 | + stderr += chunk.toString(); |
| 128 | + }); |
| 129 | + child.on("error", () => resolve(false)); |
| 130 | + child.on("exit", (code) => { |
| 131 | + if (code !== 0 && stderr && process.env["OPENCODE_CURSOR_DEBUG"]) { |
| 132 | + console.error(`[opencode-cursor] prebuild-install stderr: ${stderr.trim()}`); |
| 133 | + } |
| 134 | + resolve(code === 0); |
| 135 | + }); |
| 136 | + }); |
| 137 | +} |
| 138 | + |
| 139 | +let cached: Promise<EnsureResult> | undefined; |
| 140 | + |
| 141 | +/** |
| 142 | + * Ensure the sqlite3 native binding exists, repairing it once per process if |
| 143 | + * needed. Never throws; "failed"/"not-found" outcomes warn and let the SDK |
| 144 | + * import surface its own error. |
| 145 | + */ |
| 146 | +export function ensureSqliteBinding(options: EnsureOptions = {}): Promise<EnsureResult> { |
| 147 | + cached ??= (async () => { |
| 148 | + const log = options.log ?? ((message: string) => console.error(message)); |
| 149 | + const sqliteDir = options.sqliteDir ?? resolveSqliteDir(); |
| 150 | + if (!sqliteDir || !existsSync(join(sqliteDir, "package.json"))) { |
| 151 | + return "not-found"; |
| 152 | + } |
| 153 | + if (hasSqliteBinding(sqliteDir)) return "present"; |
| 154 | + |
| 155 | + const run = options.run ?? runPrebuildInstall; |
| 156 | + const ok = await run(sqliteDir).catch(() => false); |
| 157 | + if (ok && hasSqliteBinding(sqliteDir)) return "repaired"; |
| 158 | + |
| 159 | + log( |
| 160 | + `[opencode-cursor] sqlite3 native binding is missing in ${sqliteDir} and automatic ` + |
| 161 | + `repair failed. @cursor/sdk will not load. Fix manually with: ` + |
| 162 | + `cd ${sqliteDir} && npx prebuild-install -r napi (or: npm rebuild sqlite3)`, |
| 163 | + ); |
| 164 | + return "failed"; |
| 165 | + })(); |
| 166 | + return cached; |
| 167 | +} |
| 168 | + |
| 169 | +/** Test hook. */ |
| 170 | +export function resetNativeBinding(): void { |
| 171 | + cached = undefined; |
| 172 | +} |
0 commit comments