#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
const SUPPORTED_TARGETS = [
{ label: 'macOS Apple Silicon', platform: 'macos', arch: 'arm64' },
{ label: 'macOS Intel', platform: 'macos', arch: 'x64' },
{ label: 'Windows x64', platform: 'windows', arch: 'x64' },
{ label: 'Linux x64', platform: 'linux', arch: 'x64' }
]
const args = parseArgs(process.argv.slice(2))
const tag = normalizeTag(args.tag || `v${pkg.version}`)
const version = versionFromTag(tag)
const repo = args.repo || process.env.GH_REPO || 'bigdestiny2/pearbrowser-desktop'
const trustMode = normalizeTrustMode(args.trustMode || 'package-proof')
const format = normalizeFormat(args.format || 'snippet')
let release
try {
release = args.fixture ? loadFixture(args.fixture) : loadReleaseFromGithub(repo, tag)
} catch (err) {
fail(err && err.message ? err.message : String(err))
}
const report = buildInstallReport(release, { repo, tag, version, trustMode })
if (args.json) printJson(report)
else if (format === 'guide') printInstallGuide(report)
else printMarkdown(report)
if (!report.ok) process.exit(1)
function parseArgs (argv) {
const parsed = {
tag: '',
repo: '',
fixture: '',
trustMode: '',
format: '',
json: false
}
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
if (arg === '--tag') parsed.tag = requireValue(argv, ++i, arg)
else if (arg === '--repo') parsed.repo = requireValue(argv, ++i, arg)
else if (arg === '--fixture') parsed.fixture = requireValue(argv, ++i, arg)
else if (arg === '--trust-mode') parsed.trustMode = requireValue(argv, ++i, arg)
else if (arg === '--format') parsed.format = requireValue(argv, ++i, arg)
else if (arg === '--json') parsed.json = true
else if (arg === '-h' || arg === '--help') usage(0)
else usage(2, `unknown argument: ${arg}`)
}
return parsed
}
function requireValue (argv, index, flag) {
const value = argv[index] || ''
if (!value || value.startsWith('--')) usage(2, `${flag} requires a value`)
return value
}
function usage (code, message = '') {
if (message) console.error(`error: ${message}`)
console.error('usage: node scripts/generate-native-install-snippet.mjs [--tag v0.5.0] [--repo owner/repo] [--trust-mode package-proof|public-trust] [--format snippet|guide] [--json]')
console.error(' node scripts/generate-native-install-snippet.mjs --fixture release.json [--tag v0.5.0] [--trust-mode package-proof|public-trust] [--format snippet|guide] [--json]')
process.exit(code)
}
function fail (message) {
if (args.json) {
console.log(JSON.stringify({
ok: false,
repo,
tag,
version,
trustMode,
format,
targets: [],
errors: [message]
}, null, 2))
} else {
console.error(`Native install snippet generation failed: ${message}`)
}
process.exit(1)
}
function normalizeTag (tag) {
const normalized = String(tag || '').replace(/^refs\/tags\//, '')
if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(normalized)) {
usage(2, `release tag must look like vX.Y.Z, got ${tag}`)
}
return normalized
}
function versionFromTag (tag) {
return normalizeTag(tag).slice(1)
}
function normalizeTrustMode (value) {
const mode = String(value || '').toLowerCase()
if (mode === 'package-proof' || mode === 'public-trust') return mode
usage(2, `unsupported trust mode: ${value}`)
}
function normalizeFormat (value) {
const selected = String(value || '').toLowerCase()
if (selected === 'snippet' || selected === 'guide') return selected
usage(2, `unsupported format: ${value}`)
}
function loadFixture (path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
function loadReleaseFromGithub (repo, tag) {
try {
const out = execFileSync('gh', [
'release',
'view',
tag,
'--repo',
repo,
'--json',
'tagName,isDraft,isPrerelease,assets'
], { encoding: 'utf8' })
return JSON.parse(out)
} catch (err) {
const stderr = err?.stderr ? String(err.stderr).trim() : ''
throw new Error(stderr || `could not load GitHub release ${repo}@${tag}`)
}
}
function buildInstallReport (release, options) {
const errors = []
const targets = []
const tagName = release?.tagName || release?.tag_name || ''
if (tagName && tagName !== options.tag) {
errors.push(`release tagName ${tagName} does not match expected ${options.tag}`)
}
for (const target of SUPPORTED_TARGETS) {
try {
const resolved = resolveReleaseAsset(release, { ...options, ...target })
targets.push({
label: target.label,
platform: target.platform,
arch: target.arch,
asset: resolved.asset,
checksum: resolved.checksum,
install: installNoteFor(target.platform, resolved.asset.name)
})
} catch (err) {
errors.push(`${target.platform}/${target.arch}: ${err && err.message ? err.message : String(err)}`)
}
}
return {
ok: errors.length === 0,
repo: options.repo,
tag: options.tag,
version: options.version,
trustMode: options.trustMode,
format,
release: {
tagName,
isDraft: !!release?.isDraft,
isPrerelease: !!release?.isPrerelease,
url: githubReleasePageUrl(options.repo, options.tag)
},
targets,
errors
}
}
function resolveReleaseAsset (release, options) {
const assets = normalizeAssets(release?.assets)
if (assets.length === 0) throw new Error('release has no attached assets')
const prefix = `PearBrowser-${options.version}-${options.platform}-${options.arch}`
const candidates = assets
.filter((asset) => asset.name.startsWith(prefix))
.filter((asset) => !asset.name.endsWith('.sha256'))
.filter((asset) => isPrimaryArtifact(options.platform, asset.name))
.sort((a, b) => {
return artifactRank(options.platform, a.name) - artifactRank(options.platform, b.name) ||
a.name.length - b.name.length ||
a.name.localeCompare(b.name)
})
if (candidates.length === 0) {
throw new Error(`no native artifact found for ${options.tag}`)
}
const asset = candidates[0]
if (asset.size <= 0) throw new Error(`${asset.name} is empty or missing a size`)
const checksumName = `${asset.name}.sha256`
const checksumAsset = assets.find((candidate) => candidate.name === checksumName)
if (!checksumAsset) throw new Error(`missing SHA-256 sidecar for ${asset.name}`)
if (checksumAsset.size <= 0) throw new Error(`${checksumName} is empty or missing a size`)
return {
asset: {
name: asset.name,
size: asset.size,
url: asset.url || githubReleaseAssetUrl(options.repo, options.tag, asset.name)
},
checksum: {
name: checksumName,
size: checksumAsset.size,
url: checksumAsset.url || githubReleaseAssetUrl(options.repo, options.tag, checksumName)
}
}
}
function normalizeAssets (assets) {
if (!Array.isArray(assets)) return []
return assets
.map((asset) => ({
name: String(asset?.name || '').trim(),
size: Number(asset?.size ?? asset?.sizeInBytes ?? asset?.size_bytes ?? 0),
url: String(asset?.url || asset?.browserDownloadUrl || asset?.browser_download_url || '').trim()
}))
.filter((asset) => asset.name)
}
function isPrimaryArtifact (platform, name) {
if (platform === 'macos') return /\.(?:dmg|pkg|app\.zip|zip)$/i.test(name)
if (platform === 'windows') return /\.(?:exe|msix|msi|zip)$/i.test(name)
if (platform === 'linux') return /\.(?:AppImage|deb|rpm|snap|tar\.gz|tgz|tar\.xz|zip)$/i.test(name)
return false
}
function artifactRank (platform, name) {
const order = {
macos: [/\.dmg$/i, /\.pkg$/i, /\.app\.zip$/i, /\.zip$/i],
windows: [/\.exe$/i, /\.msix$/i, /\.msi$/i, /\.zip$/i],
linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i, /\.snap$/i, /\.tar\.gz$/i, /\.tgz$/i, /\.tar\.xz$/i, /\.zip$/i]
}[platform] || []
const index = order.findIndex((pattern) => pattern.test(name))
return index === -1 ? order.length : index
}
function installNoteFor (platform, name) {
if (platform === 'macos' && /\.dmg$/i.test(name)) {
return 'Open the DMG and drag PearBrowser.app to /Applications.'
}
if (platform === 'macos') {
return 'Unzip the archive, move PearBrowser.app to /Applications, then open it from Finder. Package-proof builds may require the macOS Privacy & Security "Open Anyway" flow after checksum verification.'
}
if (platform === 'windows' && /\.msix$/i.test(name)) {
return 'Open the MSIX package and follow the Windows installer prompts.'
}
if (platform === 'windows') {
return 'Run the installer and follow the Windows prompts.'
}
if (platform === 'linux' && /\.(?:AppImage)$/i.test(name)) {
return `Run chmod +x ${name}, then launch ./${name}.`
}
return 'Download the package and follow the platform installer prompts.'
}
function githubReleasePageUrl (repo, tag) {
return `https://github.com/${repo}/releases/tag/${encodeURIComponent(tag)}`
}
function githubReleaseAssetUrl (repo, tag, name) {
return `https://github.com/${repo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`
}
function markdownLink (label, url) {
return `[${label}](${url})`
}
function printJson (report) {
console.log(JSON.stringify(report, null, 2))
}
function printMarkdown (report) {
if (!report.ok) {
console.error(`Native install snippet generation failed for ${report.tag}:`)
for (const error of report.errors) console.error(`- ${error}`)
return
}
console.log('## Native Installers')
console.log()
console.log(`Download the package for your machine from the ${markdownLink(`${report.tag} GitHub release`, report.release.url)}, then verify it with the matching SHA-256 sidecar.`)
if (report.release.isDraft) console.log()
if (report.release.isDraft) console.log('Warning: this release is still a draft.')
if (report.release.isPrerelease) console.log()
if (report.release.isPrerelease) console.log('Warning: this release is marked prerelease.')
console.log()
console.log('| Machine | Package | SHA-256 sidecar |')
console.log('| --- | --- | --- |')
for (const target of report.targets) {
console.log(`| ${target.label} | ${markdownLink(target.asset.name, target.asset.url)} | ${markdownLink(target.checksum.name, target.checksum.url)} |`)
}
console.log()
console.log('### Install Notes')
for (const note of unique(report.targets.map((target) => target.install))) {
console.log(`- ${note}`)
}
console.log()
console.log('### Trust Note')
if (report.trustMode === 'public-trust') {
console.log('These assets are expected to be signed/notarized where the OS supports it. Keep the checksum sidecars in the release notes for independent byte verification.')
} else {
console.log('These are package-proof assets. macOS or Windows may show OS trust prompts until the public-trust signing and notarization lane is complete.')
}
}
function printInstallGuide (report) {
if (!report.ok) {
console.error(`Native install guide generation failed for ${report.tag}:`)
for (const error of report.errors) console.error(`- ${error}`)
return
}
const macos = report.targets.filter((target) => target.platform === 'macos')
const windows = report.targets.find((target) => target.platform === 'windows')
const linux = report.targets.find((target) => target.platform === 'linux')
const macosExample = macos[0]
console.log('# Install Native Packages')
console.log()
console.log(`Current release: \`${report.tag}\`.`)
console.log()
if (report.trustMode === 'public-trust') {
console.log('These are public-trust desktop builds. macOS and Windows packages are expected to be signed where the OS supports it, and every package keeps a matching SHA-256 sidecar for independent byte verification.')
} else {
console.log('These are package-proof desktop builds. Linux uses checksums only. macOS is ad-hoc signed but not notarized, and Windows packages are unsigned until the public-trust signing credentials are configured. Treat macOS/Windows OS trust prompts as expected for this release lane, not as the final public-trust experience. On macOS, Gatekeeper may say Apple could not verify that PearBrowser is free of malware until a signed and notarized DMG is published.')
}
console.log()
console.log('## Choose A Package')
console.log()
console.log(`Download directly from the ${markdownLink(`${report.tag} GitHub release`, report.release.url)}.`)
console.log()
console.log('| Machine | Recommended package | Checksum sidecar |')
console.log('| --- | --- | --- |')
for (const target of report.targets) {
console.log(`| ${target.label} | ${markdownLink(target.asset.name, target.asset.url)} | ${markdownLink(target.checksum.name, target.checksum.url)} |`)
}
console.log()
console.log('The Windows `.msix` and extra Linux AppImage artifact may remain attached for package validation, but the resolver selects the `.exe` and normalized `.AppImage` as the user-facing defaults.')
if (report.trustMode !== 'public-trust') {
console.log()
console.log('For the future public-trust macOS lane, the resolver will prefer notarized `.dmg` assets over `.app.zip` once those assets are attached by the signed native release workflow.')
}
console.log()
console.log('From a source checkout, ask the resolver for the current machine:')
console.log()
console.log('```sh')
console.log(`npm run resolve:native-release -- --tag ${report.tag} --repo ${report.repo}`)
console.log('```')
console.log()
console.log('Or specify a target:')
console.log()
console.log('```sh')
console.log(`npm run resolve:native-release -- --tag ${report.tag} --repo ${report.repo} --platform macos --arch x64`)
console.log('```')
console.log()
console.log('Release operators can verify every recommended package download and checksum sidecar in one pass:')
console.log()
console.log('```sh')
console.log(`npm run verify:native-downloads -- --tag ${report.tag} --repo ${report.repo} --all`)
console.log('```')
console.log()
console.log('Release operators can regenerate this guide from the same resolver rules:')
console.log()
console.log('```sh')
console.log(`npm run -s generate:native-install-guide -- --tag ${report.tag} --repo ${report.repo}`)
console.log('```')
console.log()
console.log('## Verify The Download')
console.log()
console.log('macOS and Linux:')
console.log()
console.log('```sh')
console.log(`shasum -a 256 -c ${macosExample?.checksum?.name || 'PearBrowser-<version>-macos-arm64.app.zip.sha256'}`)
console.log('```')
console.log()
console.log('Use the matching filename for your package. A passing check prints `OK`.')
console.log()
if (windows) {
console.log('Windows PowerShell:')
console.log()
console.log('```powershell')
console.log(`$package = "${windows.asset.name}"`)
console.log('$expected = (Get-Content "$($package).sha256").Split(" ")[0].ToLowerInvariant()')
console.log('$actual = (Get-FileHash $package -Algorithm SHA256).Hash.ToLowerInvariant()')
console.log('if ($actual -ne $expected) { throw "SHA-256 mismatch for $package" }')
console.log('```')
console.log()
}
console.log('## Install')
console.log()
console.log('macOS:')
console.log()
if (macos.some((target) => /\.dmg$/i.test(target.asset.name))) {
console.log('1. Open the DMG.')
console.log('2. Drag `PearBrowser.app` to `/Applications`.')
console.log('3. Open it from Finder.')
} else {
console.log('1. Unzip the `.app.zip`.')
console.log('2. Move `PearBrowser.app` to `/Applications`.')
console.log('3. Verify the SHA-256 sidecar for the downloaded archive before first launch.')
console.log('4. Open it from Finder. For package-proof builds, macOS may show an unidentified developer or malware-verification warning because the app is not notarized.')
console.log('5. If the first launch is blocked, use Control-click `PearBrowser.app` -> Open -> Open, or open System Settings -> Privacy & Security and choose Open Anyway for PearBrowser. Continue only if you intentionally trust this package and its checksum.')
}
console.log()
console.log('Windows:')
console.log()
console.log(`1. Run \`${windows?.asset?.name || 'PearBrowser-<version>-windows-x64.exe'}\`.`)
if (report.trustMode === 'public-trust') {
console.log('2. Follow the Windows installer prompts.')
} else {
console.log('2. For package-proof builds, Windows SmartScreen may warn because the installer is not yet Authenticode-signed. Continue only if you intentionally trust this package and its checksum.')
}
console.log()
console.log('Linux:')
console.log()
console.log('```sh')
console.log(`chmod +x ${linux?.asset?.name || 'PearBrowser-<version>-linux-x64.AppImage'}`)
console.log(`./${linux?.asset?.name || 'PearBrowser-<version>-linux-x64.AppImage'}`)
console.log('```')
}
function unique (values) {
return [...new Set(values)]
}