P2P Docs

Start a terminal app from the hello-pear-bare template

An alternative getting started path for terminal apps: clone the hello-pear-bare boilerplate and learn how a standalone Bare process wires pear-runtime over-the-air updates, Corestore, and Hyperswarm—then build it into a cross-platform binary.

This is the terminal counterpart to Start from the hello-pear-electron template. Instead of a desktop window, you start from a finished command-line boilerplate and learn how a Bare app wires peer-to-peer updates and replication through a worker—then ship it as a standalone binary.

holepunchto/hello-pear-bare is Holepunch's official boilerplate for peer-to-peer terminal applications. Where the Electron template embeds pear-runtime into Electron, this template embeds it into Bare, the zero-core embeddable JavaScript runtime. The application and runtime compile into a single standalone executable per OS and architecture with no peer dependencies—no Node.js, and no Bare or Pear CLI required on the user's machine.

With this shape you can build CLIs, REPLs, TUIs, long-running services, or transport hooks—anything headless—and ship it with peer-to-peer over-the-air (OTA) updates. The Pear CLI v3 is built on this same architecture: a Bare standalone executable that updates itself peer-to-peer through pear-runtime.

New to peer-to-peer? Peer-to-peer, demystified explains how peers find each other and connect directly—no servers—and How the stack fits together maps the pieces you'll wire together.

Take this path when you want to distribute a terminal app as a single executable and update it over the air. If your program is a short-lived command that runs and exits, follow the hello-pear-daemon template. If you want to build a desktop app instead, follow the hello-pear-electron template.

Need the pear CLI? Install it from install.pears.com, or prefix any command below with npx. See Install & upgrade for details.

New to the CLI? Run pear --menu (Pear 3.2.0+) to browse every command from a filterable list and fill in its flags as a form, instead of memorizing them. See Browse commands with the interactive menu.

Clone and run

1. Clone the repository

Clone the repository and install dependencies with the following commands:

git clone https://github.com/holepunchto/hello-pear-bare
cd hello-pear-bare
npm install

The template ships with a placeholder upgrade link in package.json. Until you replace it, startup fails with INVALID_URL. Create a real link with pear touch:

pear touch

This prints a link, for example: pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o.

Set the upgrade field in package.json to the link you just created:

"upgrade": "pear://qxenz5wmspmryjc13m9yzsqj1conqotn8fb4ocbufwtz9mtbqq5o"

4. Run the app

npm start runs bare bin.mjs --no-updates: the process starts in development mode with over-the-air updates disabled, so a live release never swaps the binary while you work.

npm start

To exercise the updater locally, opt back in:

npm start -- --updates

Map the template

PathWhat it isDo you edit it?
bin.mjsThe entrypoint—parses CLI flags, resolves the storage path, constructs the App, and logs its updater events.Yes—your startup and CLI.
app.jsThe App class (a ready-resource). Spawns the Bare worker with PearRuntime.run, wraps its IPC in a FramedStream, and turns updater messages into events.Sometimes—app lifecycle.
workers/main.jsThe Bare worker that owns the peer-to-peer code and the pear-runtime updater. Its source is inlined into your repo.Yes—this is your backend.
package.jsonApp metadata, scripts (including inline-worker), the upgrade link, the worker's runtime dependencies, and the per-platform make:* build targets.Yes—branding and release link.
scripts/make.jsThe npm run make builder—runs bare-build for the host OS and architecture (or the target in HOST), and signs the binary when signing credentials are set.Rarely.
test/index.jsThe brittle test entry, run by npm test.Yes—your tests.

Like the Electron template, the peer-to-peer logic lives in a Bare worker; bin.mjs and app.js are the host that spawns it and drives updates. There's no renderer or preload bridge—your "frontend" is the terminal.

workers/main.js is an inlined copy of the hello-pear-worker package's source, and the packages it needs at runtime are listed in the template's own package.json. That file is where your peer-to-peer code goes—open Corestore cores, join Hyperswarm topics, and run your protocol.

The cloned workers/main.js starts with a comment recording the worker's source URL and version. While that comment is there, npm run inline-worker (inline-worker-module run against the installed hello-pear-worker) overwrites the file with the installed version of the worker, installs the worker's runtime dependencies, and merges its imports map into package.json. Delete the comment before you edit the file: without it the command refuses to overwrite workers/main.js and exits with an error, so your changes are safe.

How it's wired

The template is three layers: bin.mjs (entry + CLI), app.js (the host that runs the worker and updater), and the Bare worker that holds your peer-to-peer code.

bin.mjs is the Bare entry process. It parses two flags with paparam—--storage and --no-updates—then constructs the App:

bin.mjs
const cmd = command(
  appName,
  summary(pkg.description),
  flag('--storage <dir>', 'custom storage directory'),
  flag('--no-updates', 'disable OTA updates for this run')
)

It resolves a storage directory, then hands the runtime configuration to a new App(...). In development (launched with bare) storage is a temporary directory; a packaged binary uses the persistent per-app directory from bare-storage, and --storage overrides both—see Storage and distribution:

bin.mjs
const updates = cmd.flags.updates
const storage = cmd.flags.storage || (isDev ? null : path.join(persistent(), appName))
const dir = storage || path.join(os.tmpdir(), 'pear', appName)

console.log(`${appName} v${pkg.version}`)
console.log(`Updates: ${updates === false ? 'disabled' : 'enabled'}`)

const app = new App({
  dir,
  app: isDev ? null : os.execPath(),
  updates,
  version: pkg.version,
  upgrade: pkg.upgrade,
  name: isWindows ? appName + '.exe' : appName
})

App (in app.js) is a ready-resource. When it opens, it spawns the Bare worker with PearRuntime.run—passing the runtime config as arguments—and wraps the worker's IPC pipe in a FramedStream:

app.js
  _open() {
    this.IPC = PearRuntime.run(require.resolve('./workers/main.js'), [
      String(this.updates),
      this.version,
      this.upgrade,
      this.name,
      this.dir,
      this.app || ''
    ])
    this.pipe = new FramedStream(this.IPC)

    this.pipe.on('data', (data) => this._onmessage(data))
    this.pipe.on('error', (err) => this.emit('error', err))
    this.IPC.on('error', (err) => this.emit('error', err))
    this.IPC.on('exit', (code) => {
      if (code === 0 || this.closing !== null || this.closed) return
      this.emit('error', new Error(`Updates worker exited with code ${code}`))
    })
  }

The worker owns the peer-to-peer code and the pear-runtime updater. It messages the host, and App turns those into updating, updated, and update-applied events—which bin.mjs logs—applying each downloaded release automatically. Any other message the worker writes, such as its startup greeting, arrives as a generic message event that bin.mjs prints. SIGHUP, SIGINT, SIGQUIT, and SIGTERM handlers call app.exit() to close cleanly. For the model behind these updates, see Pear OTA:

bin.mjs
app.on('message', (message) => console.log(message))
app.on('updating', () => console.log('[updater] getting new update'))
app.on('updating-delta', (delta) => console.log('[updater]', delta))
app.on('updated', () => console.log('[updater] update complete... applying'))
app.on('update-applied', () =>
  console.log('[updater] applied update, restart to run latest version')
)
app.on('error', (err) => console.error('[app:error]', err))

process.on('SIGHUP', () => app.exit(129))
process.on('SIGINT', () => app.exit(130))
process.on('SIGQUIT', () => app.exit(131))
process.on('SIGTERM', () => app.exit(143))

bin.mjs also logs updating-delta events, which carry download progress, but this template's worker never forwards that progress, so that handler never fires.

PearRuntime.run replicate update drive bin.mjs (entry) app.js (App) workers/main.js (Bare worker) pear-runtime updater Corestore Hyperswarm seeding peers

Short-lived CLIs: hello-pear-daemon

hello-pear-bare keeps a long-lived process: the CLI stays up while its worker runs. If your tool is a short-lived command (like git) that shouldn't block on an update check, start from the sibling boilerplate holepunchto/hello-pear-daemon instead. It uses the same upgrade field, per-platform build targets, and pear install/pear seed release flow. What differs is how updates run: pear-runtime lives in a detached bare-daemon process that the foreground command spawns and returns from immediately, so the command exits while the daemon updates in the background.

RepositoryShapeUse it for
hello-pear-bare (this page)pear-runtime inside a Bare worker thread, with the worker source inlinedLong-lived programs (services, REPLs, TUIs) and reusable backends.
hello-pear-daemonpear-runtime inside a detached bare-daemon updaterShort-lived CLI invocations that shouldn't block on an update check.

See Start a short-lived CLI from the hello-pear-daemon template for the walkthrough.

Build a standalone binary

npm run make compiles bin.mjs and its dependencies into a single standalone executable with no peer dependencies—users don't need Node.js, Bare, or the Pear CLI installed. scripts/make.js detects your OS and architecture and writes the binary to out/make, named after productName || name from package.json (hello-pear-bare, plus .exe on Windows). The template supports macOS, Linux, and Windows, each for arm64/x64. To build for a different supported target, set HOST:

HOST=linux-arm64 npm run make

npm run make signs the binary when signing credentials are in the environment—the same ones the make-pear-app GitHub Action provides. With none of them set, the binary is unsigned:

VariablePlatformWhat it does
MAC_CODESIGN_IDENTITYmacOSSigns the executable with this identity and the hardened runtime.
KEYCHAIN_PROFILEmacOSAlso submits the signed executable to Apple's notary service with xcrun notarytool, using this keychain profile, and waits for the result. Set it together with MAC_CODESIGN_IDENTITY.
WINDOWS_CERT_SHA1WindowsSigns the executable with the certificate that has this SHA-1 thumbprint, using signtool.exe from the Windows 10 SDK.

The per-platform make:<platform>-<arch> scripts in package.json are still there for one-off builds. Each one builds an unsigned binary into out/<platform>-<arch>, under the fixed name hello-pear-bare.

See Bundle a Bare app for what npm run make runs under the hood—bare-build, the same tool behind any standalone Bare executable, Pear app or not.

Install over the air

Distribute the executable through the usual channels—a download on your website, apt, or Homebrew. You can also install it peer-to-peer: once your upgrade link is seeding a release, pull it directly with the pear install command:

pear install pear://<key>

After the first install, new releases reach users through the swarm—no reinstall needed. For how staging and seeding work, see Deploy your application and Installing applications.

Example: add a flag

The boilerplate is a minimal CLI plus updater; bin.mjs holds the CLI and startup logic (your peer-to-peer code lives in the worker). Flags are parsed with paparam, so adding one is a two-line change. Add a --name flag to the command definition:

 const cmd = command(
   appName,
   summary(pkg.description),
   flag('--storage <dir>', 'custom storage directory'),
-  flag('--no-updates', 'disable OTA updates for this run')
+  flag('--no-updates', 'disable OTA updates for this run'),
+  flag('--name <name>', 'who to greet on startup')
 )

Then log the greeting after the other startup logs:

 console.log(`Updates: ${updates === false ? 'disabled' : 'enabled'}`)
+
+if (cmd.flags.name) console.log(`Hello, ${cmd.flags.name}!`)

Run npm start -- --name YourName, and the process greets you on boot. That is the CLI layer in bin.mjs; your real peer-to-peer features go in the worker (workers/main.js):

Reuse the storage directory the host passes to the worker so your data lands alongside the updater's—see Storage and distribution.

Customize for your brand

Before you ship, set your identity in package.json:

bin.mjs reads productName || name for both the binary name and the persistent storage directory, so set these before your first release. Then build with npm run make and release with Deploy your application.

Where to go next

Last updated on

Was this helpful?

On this page