// Checks that a built package is actually runnable, rather than merely built. // // node scripts/verify-package.mjs [path/to/AppImage-or-unpacked-directory] // // Native modules cannot be loaded from inside an asar archive, so they have to // be unpacked beside it — and when that goes wrong the app still builds, still // installs, and still starts. It fails later, on a user's machine, the first // time a terminal is opened. Nothing in `npm test` can catch that; this can. // // Runs the AppImage's own extractor rather than mounting it, so it works // without FUSE — which is exactly the situation in most CI containers. import { execFileSync } from 'node:child_process' import { existsSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' const arch = process.arch const EXPECTED_NATIVE = process.platform === 'win32' ? [ `node_modules/better-sqlite3/prebuilds/win32-${arch}.node`, `node_modules/node-pty/prebuilds/win32-${arch}/pty.node`, `node_modules/node-pty/prebuilds/win32-${arch}/conpty.node`, `node_modules/node-pty/prebuilds/win32-${arch}/winpty-agent.exe`, ] : [ `node_modules/better-sqlite3/prebuilds/linux-${arch}.node`, 'node_modules/node-pty/build/Release/pty.node', ] function fail(message) { console.error(`\n ✗ ${message}`) process.exitCode = 1 } function findDefaultPackage() { const dir = resolve('release') if (!existsSync(dir)) return null const unpacked = join(dir, 'win-unpacked') if (process.platform === 'win32' && existsSync(unpacked)) return unpacked const name = readdirSync(dir).find((f) => f.endsWith('.AppImage')) return name ? join(dir, name) : null } function walk(dir, out = []) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = join(dir, entry.name) if (entry.isDirectory()) walk(full, out) else out.push(full) } return out } const packagePath = process.argv[2] ? resolve(process.argv[2]) : findDefaultPackage() if (!packagePath || !existsSync(packagePath)) { fail('no unpacked package found — build one first with `npm run dist` or `npm run dist:win`') process.exit(1) } console.log(` inspecting ${packagePath}`) const workdir = mkdtempSync(join(tmpdir(), 'nexusterm-verify-')) try { // Either a packaged AppImage or an already-unpacked `--dir` build. The dir // form matters because `electron-builder --dir` is what you iterate against — // it skips the squashfs and compression steps, which dominate a full build. let resources if (statSync(packagePath).isDirectory()) { resources = join(packagePath, 'resources') } else { // `--appimage-extract` writes ./squashfs-root relative to the working // directory, so this has to run from inside the scratch dir. execFileSync(packagePath, ['--appimage-extract'], { cwd: workdir, stdio: 'ignore', timeout: 180_000, }) resources = join(workdir, 'squashfs-root', 'resources') } const unpacked = join(resources, 'app.asar.unpacked') if (!existsSync(unpacked)) { fail('app.asar.unpacked is missing — every native module is trapped inside the archive') } else { const files = walk(unpacked) for (const expected of EXPECTED_NATIVE) { const found = files.some((f) => f.replaceAll('\\', '/').endsWith(expected)) if (found) console.log(` ✓ ${expected}`) else fail(`${expected} is not unpacked — this module will fail to load at runtime`) } // ssh2's native crypto binding. Worth asserting separately because the // library reads as pure JavaScript, and because a failed load falls back to // a JS implementation silently — so nothing would ever report it. const sshCrypto = files.filter((f) => f.endsWith('sshcrypto.node')) if (sshCrypto.length > 0) console.log(` ✓ ssh2 sshcrypto.node (${sshCrypto.length})`) else if (process.platform === 'win32') { console.log(' ! ssh2 sshcrypto.node is absent; ssh2 will use its JavaScript fallback') } else fail('ssh2 sshcrypto.node is missing') const size = files.reduce((total, file) => total + statSync(file).size, 0) console.log(` · unpacked: ${(size / 1024 / 1024).toFixed(1)} MB`) const asar = join(resources, 'app.asar') if (existsSync(asar)) { console.log(` · app.asar: ${(statSync(asar).size / 1024 / 1024).toFixed(1)} MB`) } } } finally { rmSync(workdir, { recursive: true, force: true }) } console.log( process.exitCode ? '\n package verification FAILED\n' : '\n package verification passed\n', )