Ship your app
Third of four onboarding steps: pear touch, an upgrade link, electron-forge make, pear build, pear stage, and pear provision to publish your first version.
This is part 3 of 4 in the getting started path. You take the production-shaped app from part 2 and walk it through the first half of Pear's release pipeline:
- Minting a
pear://link - Building per-OS distributables
- Staging and provisioning the first version onto those links
Deploy over-the-air updates (part 4 of 4) continues from here and demonstrates the live OTA cycle on the provision link, plus a tour of multisig.
The full pipeline looks like this:
This part stops after step 6 — the first pear stage plus pear provision. You do not need cosigners, a Windows machine, or Apple signing credentials. The deeper guides cover the production-only material:
- Deploy a Pear desktop app — every command, every release line.
- Build desktop distributables — code-signing, notarization, MSIX publisher details.
- Release pipeline — the conceptual picture.
Full production-ready reference: hello-pear-electron. The complete version of this chat lives at holepunchto/hello-pear-electron — Holepunch's official Electron template, the same shape Keet and PearPass ship. Clone it any time to see the finished structure or to crib code. For a guided tour of the template, see Start from the hello-pear-electron template.
Before you start
You need:
- The working production-shaped app from part 2.
pear:- Install it with
npm i -g pearor run vianpx pear. - Every
pearcommand in this part also works asnpx pear .... This is useful if you want to run the commands from a different directory than the one you installedpearin.
- Install it with
pear build— assembles per-OS makes into a deployment directory. It ships with thepearCLI above, so there is nothing extra to install: run it aspear build(ornpx pear build).
Touch, set the upgrade link, and seed
pear touch mints a new pear:// link you own — one backed by a fresh Hypercore whose write key is stored locally on this machine. This becomes your stage link: the append-only core you sync builds into before each provision. (The link you set in part 2 was a public placeholder so the app would boot; you replace it now with your own.)
pear touch
# pear://<stage-link>Stage and provision only work against links you own — ones you minted on this machine with pear touch. If you stage to someone else's link (for example the public placeholder from part 2), pear stage --dry-run prints 🍐 Staging null with an empty diff and pear provision has nothing to sync. Always use the link pear touch just printed.
Point package.json#upgrade at the stage link for now — you will switch it to the provision link after the first provision:
npm pkg set upgrade=pear://<stage-link>pear seed keeps that core online so other peers can fetch updates from you.
Your output will be different.
pear seed pear://<stage-link>
--------------------------------------------------------
Pear Link: pear://<stage-link>
Drive Key: 73d02bee8bb356402592cafe0bd9c993202742223943a830332d237fae2173b7
Discovery Key: 127512b795163f8c13aa3e718ef9995d40d2ae7533e485c2c363a92adb8c8a9c
Content Key:
Firewalled: true
NAT Type: Consistent
Network: [ Peers: 0 ] [ ⬆ 0B - 0B/s ] [ ⬇ 0B - 0B/s]
-------------------------------------------------------- Leave pear seed running in its own terminal for the rest of the tutorial — without an active seeder, peers cannot download your build.
In production, run pear seed on at least one always-online machine (a small VPS works) so updates keep flowing while developer laptops sleep.
Bump the version
pear-runtime only swaps the application drive when the new build advertises a higher version. If you forget this step, peers see your stage and do nothing:
npm version [<newversion> | major | minor | patch | premajor | preminor | prepatch | prerelease]This rewrites package.json (1.0.0 → 1.0.1) and creates a git tag. From now on, every release iteration starts with npm version patch (or major, minor, patch, etc.).
Make distributables
A "distributable" is the platform-native installer — .app on macOS, .msix on Windows, .AppImage on Linux. Pear uses electron-forge with a single forge.config.js that configures makers for every platform, including Linux AppImage via pear-electron-forge-maker-appimage.
Install electron-forge and the makers you need
npm install --save-dev \
@electron-forge/cli@^7.11.1 \
@electron-forge/maker-dmg@^7.11.1 \
@electron-forge/maker-msix@^7.11.1 \
pear-electron-forge-maker-appimage@^2.0.0 \
pear-electron-forge-maker-flatpak@^0.0.4 \
pear-electron-forge-maker-snap@^0.0.10 \
electron-forge-plugin-universal-prebuilds@^1.0.0 \
electron-forge-plugin-prune-prebuilds@^1.0.0Two electron-forge plugins matter:
electron-forge-plugin-universal-prebuilds— bundles native prebuilds for every supported architecture.electron-forge-plugin-prune-prebuilds— trims the prebuilds you do not need for the current platform, keeping installers small.
Add scripts to package.json
Use one make entry point on every OS — Forge runs only the makers whose platforms match the host:
"scripts": {
"start": "electron-forge start -- --no-updates",
"package": "electron-forge package",
"make": "electron-forge make"
}Add forge.config.js
In your project's root, copy or diff against the canonical hello-pear-electron forge.config.js. That file configures:
- Packager —
build/icon, URL schemes frompackage.jsonname, optional macOS signing whenMAC_CODESIGN_IDENTITYis set (notarization viaKEYCHAIN_PROFILE). - Makers —
@electron-forge/maker-dmg(darwin),@electron-forge/maker-msix(win32), and Pear makers for Linux AppImage, Flatpak, and Snap. - Hooks —
preMakerewritesbuild/AppxManifest.xmlversion for MSIX;postMakemoves Windows.msixartifacts intoout/<AppName>-win32-<arch>/. - Plugins — universal-prebuilds and prune-prebuilds.
const fs = require('fs')
const path = require('path')
const pkg = require('./package.json')
const appName = pkg.productName ?? pkg.name
function getWindowsKitVersion () {
const programFiles = process.env['PROGRAMFILES(X86)'] || process.env.PROGRAMFILES
if (!programFiles) return undefined
const kitsDir = path.join(programFiles, 'Windows Kits')
try {
for (const kit of fs.readdirSync(kitsDir).sort().reverse()) {
const binDir = path.join(kitsDir, kit, 'bin')
if (!fs.existsSync(binDir)) continue
const version = fs
.readdirSync(binDir)
.filter((d) => /^\d+\.\d+\.\d+\.\d+$/.test(d))
.sort()
.pop()
if (version) return version
}
} catch {
return undefined
}
}
let packagerConfig = {
icon: 'build/icon',
protocols: [{ name: appName, schemes: [pkg.name] }],
derefSymlinks: true
}
if (process.env.MAC_CODESIGN_IDENTITY) {
packagerConfig = {
...packagerConfig,
osxSign: {
identity: process.env.MAC_CODESIGN_IDENTITY,
optionsForFile: () => ({
entitlements: path.join(__dirname, 'build', 'entitlements.mac.plist')
})
},
osxNotarize: {
tool: 'notarytool',
keychainProfile: process.env.KEYCHAIN_PROFILE
}
}
}
module.exports = {
packagerConfig,
makers: [
{
name: '@electron-forge/maker-dmg',
platforms: ['darwin'],
config: {}
},
{
name: '@electron-forge/maker-msix',
platforms: ['win32'],
config: {
appManifest: path.join(__dirname, 'build', 'AppxManifest.xml'),
windowsKitVersion: getWindowsKitVersion(),
...(process.env.WINDOWS_SIGN_HOOK
? {
windowsSignOptions: {
hookModulePath: process.env.WINDOWS_SIGN_HOOK
}
}
: {})
}
},
{
name: 'pear-electron-forge-maker-appimage',
platforms: ['linux'],
config: {
icons: [
{ file: 'build/icon/icon-16x16.png', size: 16 },
{ file: 'build/icon/icon-32x32.png', size: 32 },
{ file: 'build/icon/icon-64x64.png', size: 64 },
{ file: 'build/icon/icon-128x128.png', size: 128 },
{ file: 'build/icon/icon-256x256.png', size: 256 }
]
}
},
{
name: 'pear-electron-forge-maker-flatpak',
platforms: ['linux'],
config: {
appId: 'com.pears.BasicChat',
icon: `${packagerConfig.icon}.png`,
metainfo: 'build/metainfo.xml',
entrypoint: 'build/entrypoint.sh',
comment: 'A peer-to-peer chat example built with Pear and Electron',
categories: ['Network', 'InstantMessaging']
}
},
{
name: 'pear-electron-forge-maker-snap',
platforms: ['linux'],
config: {
snapcraftYamlPath: 'build/snapcraft.yaml',
summary: 'A peer-to-peer chat example built with Pear and Electron',
description:
'A peer-to-peer chat example demonstrating how to embed pear-runtime into an Electron desktop app.',
contact: 'hello@holepunchto.to',
license: 'Apache-2.0',
issues: 'https://github.com/holepunchto/examples-p2p-desktop/issues',
website: 'https://github.com/holepunchto/examples-p2p-desktop',
icon: `${packagerConfig.icon}.png`
}
}
],
hooks: {
readPackageJson: async (forgeConfig, packageJson) => {
if (process.env.UPGRADE_KEY) {
packageJson.upgrade = process.env.UPGRADE_KEY
}
return packageJson
},
preMake: async () => {
fs.rmSync(path.join(__dirname, 'out', 'make'), { recursive: true, force: true })
const manifest = path.join(__dirname, 'build', 'AppxManifest.xml')
const msixVersion = pkg.version.replace(/^(\d+\.\d+\.\d+)$/, '$1.0')
const xml = fs.readFileSync(manifest, 'utf-8')
fs.writeFileSync(manifest, xml.replace(/Version="[^"]*"/, `Version="${msixVersion}"`))
},
postMake: async (forgeConfig, results) => {
for (const result of results) {
if (result.platform !== 'win32') continue
for (const artifact of result.artifacts) {
if (!artifact.endsWith('.msix')) continue
const standardDir = path.join(__dirname, 'out', `${appName}-win32-${result.arch}`)
fs.mkdirSync(standardDir, { recursive: true })
const dest = path.join(standardDir, path.basename(artifact))
fs.renameSync(artifact, dest)
fs.mkdirSync(path.dirname(artifact), { recursive: true })
fs.copyFileSync(dest, artifact)
result.artifacts[result.artifacts.indexOf(artifact)] = dest
}
}
}
},
plugins: [
{
name: 'electron-forge-plugin-universal-prebuilds',
config: {}
},
{
name: 'electron-forge-plugin-prune-prebuilds',
config: {}
}
]
}Add the template build/ assets to your project
Createa a build/ folder in your project's root and copy the template's build/ assets (AppxManifest.xml, entitlements.mac.plist, icon set under build/icon/, and Linux Flatpak/Snap metadata).
Run the maker on your OS
npm run make builds distributables for the current host OS only. Run it on macOS, Linux, and Windows (or on CI runners per OS) when you need a multi-arch deployment directory.
Brand icons before you make:
build/icon.icns(macOS),build/icon.ico(Windows),build/icon.pngplus sized PNGs underbuild/icon/(Linux makers).
You can copy the set from hello-pear-electron's build/ tree.
npm run make # .app + .dmg on macOS; .AppImage (+ Flatpak/Snap) on Linux; .msix on WindowsThe output lands in out/PearChat-darwin-arm64/PearChat.app (or the matching path for your platform).
If the make fails with a NODE_MODULE_VERSION mismatch (for example after nvm use or upgrading Node between npm install and npm run make), run npm rebuild and try again. See Node ABI mismatch during make.
Code-signing, notarization, and MSIX publisher requirements are full topics on their own — production builds need them, but you can skip them for this dry run.
The full coverage is in Build desktop distributables. See Desktop release npm scripts for common npm entry points in sample repos.
Build the deployment directory
You should run pear build from outside the project folder (pear build and the project folder must not be parent/child — see stage size increases). Each --<platform-arch>-app flag points at one make's output. For example:
cd ..
pear build \
--package=./pear-chat/package.json \
--darwin-arm64-app ./pear-chat/out/PearChat-darwin-arm64/PearChat.app \
--target pear-chat-1.0.1The result is ./pear-chat-1.0.1/by-arch/darwin-arm64/app/... ready for the next step.
If you have makes from more than one platform — for example a Linux AppImage built on a colleague's machine — pass each one:
pear build \
--package=./pear-chat/package.json \
--darwin-arm64-app ./pear-chat/out/PearChat-darwin-arm64/PearChat.app \
--linux-x64-app ./pear-chat/out/PearChat-linux-x64/PearChat.AppImage \
--win32-x64-app ./pear-chat/out/PearChat-win32-x64/PearChat.msix \
--target pear-chat-1.0.1pear build assembles a Pear deployment directory from the per-platform makes. The layout must be as follows:
PearChat-1.0.1/
├─ package.json
└─ by-arch/
└─ <platform-arch>/
└─ app/Stage and provision the first version
Run these from the parent directory of your app (same place you ran pear build) unless noted. pear stage syncs the deployment directory into the Hypercore behind your stage link.
1. Dry-run the stage
Always run --dry-run first and read the file-by-file diff:
pear stage --dry-run pear://<stage-link> ./pear-chat-1.0.1Your keys and byte counts will differ; the shape looks like this:
🍐 Staging pear://<stage-link>
+ package.json 2.1 kB
+ electron/main.js 12.4 kB
+ workers/chat-room.js 8.7 kB
+ renderer/index.html 3.2 kB
+ by-arch/darwin-arm64/app/PearChat 48.2 MB
... more files ...
pear://0.<length>.<stage-key>How to read it:
+lines — files that would be written or updated. Scan for surprises before you run without--dry-run.- Last line — a versioned link (
pear://0.<length>.<stage-key>). The<length>is the Hypercore length after this stage; you need the full string forpear provisionin the next step. On a first stage,<length>is usually a few hundred to a few thousand — not0.
Look for:
- Files you expect to ship —
electron/,workers/,renderer/,package.json,node_modules/.... - No surprise additions — stray
.DS_Store, editor swap files, secrets, the deployment directory itself. - Sensible byte counts — if a file is suddenly 100 MB, something is wrong.
2. Stage for real
If the diff looks right, drop the --dry-run flag and run it for real:
pear stage pear://<stage-link> ./pear-chat-1.0.1The live output should match the dry-run diff. Copy the versioned link — pear://0.<length>.<stage-key>. It appears twice in the output; either copy works:
🍐 Staging pear-chat
[ pear://<stage-link> ]
pear://0.<length>.<stage-key>
Current: <length>
Release: 0 [UNRELEASED]
✔ Skipping warmup (no changes)
Staging complete!
^Latest: <length>
Release: 0 [UNRELEASED]
pear://0.1426.9mdt6h676phg7nuwp1urs7457nsz3juyeuqcbw4h7r1tqz8e84ayUse that full versioned string as the first argument to pear provision below (not the unversioned <stage-link> you passed to pear stage).
3. Mint the provision link
Peers should not poll the stage link directly — it keeps the full append-only history. pear provision block-syncs the staged snapshot onto a lean provision link that packaged apps poll instead. That target is a second link — mint it now with another pear touch (the first touch in step 1 was only for staging). Cwd does not matter; pear touch does not read your project:
pear touch
# pear://<provision-link> ← different key from <stage-link>pear touch prints the link on its own line — copy the whole pear://… string.
4. Provision onto the provision link
Dry-run first, then run for real. Fill the three arguments like this:
| Argument | What to paste |
|---|---|
<source-verlink> | The versioned link from pear stage — pear://0.<length>.<stage-key> |
<target-link> | The unversioned provision link from pear touch — pear://<provision-link> |
<production-verlink> | On first ship: pear://0.0.<provision-key> where <provision-key> is everything after pear:// in <provision-link> |
Paste all three links on one line — backslash continuations are easy to break if a line has trailing spaces:
pear provision --dry-run pear://0.<length>.<stage-key> pear://<provision-link> pear://0.0.<provision-key>
pear provision pear://0.<length>.<stage-key> pear://<provision-link> pear://0.0.<provision-key>Example with concrete keys (yours will differ):
pear provision --dry-run pear://0.1426.9mdt6h676phg7nuwp1urs7457nsz3juyeuqcbw4h7r1tqz8e84ay pear://o11z79iogfzx1ckthrhwoeyk7pyxxhy7par4edq5sy1dmwieutso pear://0.0.o11z79iogfzx1ckthrhwoeyk7pyxxhy7par4edq5sy1dmwieutsoProvision output is another file diff, then confirmation on the target link:
🍐 Provisioning pear://0.1079.qxenz5... → pear://q9sopzoq...
+ package.json 2.1 kB
... same files as stage ...
pear://q9sopzoqgas9usoiq7uzkkwngm5pzj4zo3n4esjwwbmw6offis8oThe diff should mirror what you just staged. The last line is the provision link peers will poll — set upgrade to that unversioned link, not the versioned stage link.
5. Switch upgrade to the provision link
From the app root (pear-chat/), point package.json#upgrade at the provision link and seed it — this is what shipped binaries poll:
npm pkg set upgrade=pear://<provision-link>
pear seed pear://<provision-link>6. Rebuild once
Rebuild so the packaged app embeds the new upgrade field (the provision link already holds v1.0.1 — no restage needed):
npm run make
cd ..
pear build \
--package=./pear-chat/package.json \
--darwin-arm64-app ./pear-chat/out/PearChat-darwin-arm64/PearChat.app \
--target pear-chat-1.0.1Your first version is now published on the provision link. Peers running a build with that upgrade field will see it on their next poll.
Keep both links: stage for pear stage on every iteration; provision for what apps install and poll. Part 4 repeats pear stage → pear provision for v1.0.2. The full operator reference is Deploy your application — provision.
What you've learned
You now have a stage link and a provision link with your first build published:
| Stage | What it is | Reversible? |
|---|---|---|
pear touch | Mints a new pear:// link | Yes — just abandon it |
Make + pear build | Per-OS distributable folded into a Deployment Directory | Yes — rebuild |
pear stage | Append-only sync into the stage Hypercore | History is permanent; updates are not |
pear provision | Block-sync onto the provision link peers poll | Yes — reprovision from a different stage length |
Every release iteration after this is the same pattern: npm version patch, npm run make (on each OS you ship), pear build, pear stage --dry-run, pear stage, pear provision. Part 4 puts that loop on a running app and shows the OTA cycle from both sides.
Where to go next
- Continue the path: Deploy over-the-air updates (part 4 of 4) — run the installed build, ship a second version, watch OTA fire end-to-end, and preview multisig.
- Automate it: Publish with GitHub Actions — skip the manual staging and let CI stage a stable
pear://link on every push. - Deploy a Pear desktop app — the canonical how-to with every command, every flag, and every recovery procedure.
- Build desktop distributables — code-signing, notarization, MSIX publisher details.
- Troubleshoot desktop releases — "the app did not update", lost write-access, stage size blowups.
- Release pipeline — the conceptual picture, deployment layers, and release lines.
- Release pipeline glossary — terminology.
hello-pear-electron— the upstream template every snippet in this getting started path is based on.