// scripts/ci/verify-native-abi.mjs // 패키징된 Electron 앱의 네이티브 모이 "Electron ABI"로 빌드됐는지 검증한다. // // 배경(실측 사고): 설치본에 Node ABI(131)로 빌드된 better_sqlite3.node가 들어가 // 앱이 시작하자마자 "NODE_MODULE_VERSION 131 ... requires 130"으로 죽었다. // 원인은 패키징에서 네이티브 재빌드를 건너뛴 것(npmRebuild=false)이었고, 조용히 지나갔다. // // 검증 방법: 호스트 Node로 모듈을 로드해 본다. // - 로드 성공 → 호스트 Node ABI로 빌드된 것 = Electron용이 아님 → 실패 // - NODE_MODULE_VERSION 불일치로 거부 → 다른 런타임(Electron)용 = 통과 // - 파일 없음 → 실패 // // 사용: // node scripts/ci/verify-native-abi.mjs # release//win-unpacked 자동 탐색 // node scripts/ci/verify-native-abi.mjs --dir import { spawnSync } from 'node:child_process' import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..') const dirFlagIndex = process.argv.indexOf('--dir') let packagedDir = dirFlagIndex >= 0 ? process.argv[dirFlagIndex + 1] : null if (!packagedDir) { const version = JSON.parse( readFileSync(join(root, 'release', 'product-version.json'), 'utf8'), ).version const releaseDir = join(root, 'apps', 'desktop', 'release', version) if (existsSync(releaseDir)) { const candidates = readdirSync(releaseDir).filter((name) => /unpacked$/.test(name) || name === 'win-unpacked' || name === 'mac-arm64', ) if (candidates.length > 0) { packagedDir = join(releaseDir, candidates[0]) } } } if (!packagedDir || !existsSync(packagedDir)) { console.error( '패키징 산출물 디렉토리를 찾을 수 없습니다. --dir로 지정하세요 (예: apps/desktop/release/1.3.3/win-unpacked).', ) process.exit(1) } const unpackedRoot = join(packagedDir, 'resources', 'app.asar.unpacked', 'node_modules') /** Electron ABI(V8 내부 API)에 의존해 재빌드가 반드시 필요한 모듈 */ const REQUIRED_ELECTRON_ABI = [ { name: 'better-sqlite3', binary: join('better-sqlite3', 'build', 'Release', 'better_sqlite3.node'), }, ] /** N-API 기반이라 타임 무관 — 존재만 확인 */ const NAPI_MODULES = [ { name: 'uiohook-napi', binary: join('uiohook-napi', 'build', 'Release', 'uiohook_napi.node') }, ] const failures = [] const notes = [] for (const module of REQUIRED_ELECTRON_ABI) { const binaryPath = join(unpackedRoot, module.binary) if (!existsSync(binaryPath)) { failures.push(`${module.name}: 패키징된 네이티브 바이너리가 없습니다 → ${binaryPath}`) continue } // 호스트 Node로 로드 시도: 성공하면 Electron ABI가 아니다. const probe = spawnSync( process.execPath, ['-e', `require(${JSON.stringify(binaryPath.replace(/\\/g, '\\\\'))})`], { encoding: 'utf8' }, ) const output = `${probe.stdout ?? ''}${probe.stderr ?? ''}` if (probe.status === 0) { failures.push( [ `${module.name}: 호스트 Node에서 로드됩니다 = Electron ABI가 아니다.`, ' 패키징 전에 Electron용으로 재빌드해야 합니다 (electron-builder npmRebuild=true,', ' 또는 `npx @electron/rebuild -v `).', ].join('\n'), ) continue } if (/NODE_MODULE_VERSION/.test(output)) { notes.push(`${module.name}: Electron ABI 확인 (${output.split('\n')[0].slice(0, 80)})`) } else { failures.push(`${module.name}: 알 수 없는 오류로 로드 실패 → ${output.split('\n')[0]}`) } } for (const module of NAPI_MODULES) { const binaryPath = join(unpackedRoot, module.binary) if (!existsSync(binaryPath)) { notes.push(`${module.name}: 바이너리 없음(선택) — ${binaryPath}`) } else { notes.push(`${module.name}: 존재 확인 (N-API)`) } } for (const note of notes) console.log(`[native-abi] ${note}`) if (failures.length > 0) { console.error('[native-abi] 검증 실패:') for (const failure of failures) console.error(` - ${failure}`) process.exit(1) } console.log('[native-abi] GREEN — 패키징된 네이티브 모듈이 Electron에서 실행 가능한 ABI입니다.')