Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
137 lines
4.8 KiB
JavaScript
137 lines
4.8 KiB
JavaScript
// Checks that a built package is actually runnable, rather than merely built.
|
|
//
|
|
// node scripts/verify-package.mjs [path/to/NexusTerm-*.AppImage]
|
|
//
|
|
// 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 EXPECTED_NATIVE = [
|
|
'node_modules/better-sqlite3/prebuilds/linux-x64.node',
|
|
'node_modules/node-pty/build/Release/pty.node',
|
|
]
|
|
|
|
/**
|
|
* Other-platform *binaries*, not everything with a platform in its name.
|
|
*
|
|
* better-sqlite3 also ships `lib/darwin-arm64.js` and friends — small loader
|
|
* shims that are part of the library on every platform. Flagging those would
|
|
* make this check cry wolf, and a check that cries wolf gets ignored.
|
|
*/
|
|
const FORBIDDEN = [
|
|
/win32-.*\.node$/,
|
|
/win32-.*\.dll$/,
|
|
/darwin-.*\.node$/,
|
|
/darwin-.*spawn-helper$/,
|
|
/linuxmusl-.*\.node$/,
|
|
/linux-arm64.*\.node$/,
|
|
]
|
|
|
|
function fail(message) {
|
|
console.error(`\n ✗ ${message}`)
|
|
process.exitCode = 1
|
|
}
|
|
|
|
function findDefaultAppImage() {
|
|
const dir = resolve('release')
|
|
if (!existsSync(dir)) return null
|
|
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 appImage = process.argv[2] ? resolve(process.argv[2]) : findDefaultAppImage()
|
|
if (!appImage || !existsSync(appImage)) {
|
|
fail('no AppImage found — build one first with `npx electron-builder --linux`')
|
|
process.exit(1)
|
|
}
|
|
|
|
console.log(` inspecting ${appImage}`)
|
|
|
|
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(appImage).isDirectory()) {
|
|
resources = join(appImage, 'resources')
|
|
} else {
|
|
// `--appimage-extract` writes ./squashfs-root relative to the working
|
|
// directory, so this has to run from inside the scratch dir.
|
|
execFileSync(appImage, ['--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.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 fail('ssh2 sshcrypto.node is missing')
|
|
|
|
const strays = files.filter((f) => FORBIDDEN.some((p) => p.test(f)))
|
|
if (strays.length === 0) {
|
|
console.log(' ✓ no other-platform binaries')
|
|
} else {
|
|
// A warning rather than a failure: these are dead weight, not a defect.
|
|
console.log(` ! ${strays.length} other-platform file(s) shipped:`)
|
|
for (const stray of strays.slice(0, 5)) {
|
|
console.log(` ${stray.slice(unpacked.length + 1)}`)
|
|
}
|
|
}
|
|
|
|
const size = Number(
|
|
execFileSync('du', ['-sb', unpacked], { encoding: 'utf8' }).split('\t')[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',
|
|
)
|