Back to list

Blog

30 Seconds to Connect: How We Built the `npx tacticremote` Distribution Path

The engineering story behind `npx tacticremote`, from Bun-compiled binaries and a thin npm launcher to QR auto-save and faster first-run onboarding.

Published Tags: engineering / cli / distribution / onboarding
Blog

Follow product and engineering updates from this channel.

Browse category

The goal was simple to state and surprisingly hard to execute: a new user should be able to run one command, scan a QR code, and have a live terminal on their phone — all within 30 seconds.

This post covers how we got there.

The Problem

Before npx tacticremote existed, starting the server was a multi-step process that varied by platform:

  • macOS: Download a DMG, mount it, move the app to Applications, launch it, deal with Gatekeeper.
  • Linux / CI: Clone the repository, run npm install, and work out how to launch and keep the server running in that environment.

For developers on remote Linux VMs or inside Docker containers, this was genuinely painful. The repo-clone path required git, Node.js, and a working npm environment with access to the registry. There was no single command you could hand someone and say "run this."

We wanted npx tacticremote → scan QR → done.

Why Not Just npm install?

The server has a small runtime dependency set, but npm install still creates friction. Today that runtime package includes ws, bonjour-service, and qrcode-terminal.

npm resolution still takes time before the server does anything useful. On restricted environments such as corporate networks or containerized CI, it can also fail because of proxy settings, registry mirrors, or permission issues.

More critically, the CLI path should feel lightweight. If the published package carried the full runtime tree, the first-run experience would still be "download JavaScript, then start the server," which is not the onboarding bar we wanted.

The published npm layer needed to stay as thin as possible.

The Bun Compilation Approach

We compile the server into a single self-contained binary using Bun. The result is one executable, roughly 20 MB before gzip compression, that contains:

  • The Bun runtime
  • All runtime dependencies
  • The full server source

No node_modules. No separate Bun install. No dependency resolution at startup. You fetch one binary and run it.

GitHub Actions (.github/workflows/build-cli.yml) builds four targets on every release:

  • bun-darwin-arm64 — Apple Silicon Mac
  • bun-darwin-x64 — Intel Mac
  • bun-linux-x64 — x86_64 Linux
  • bun-linux-arm64 — ARM Linux (Raspberry Pi, AWS Graviton, etc.)

Each binary is uploaded to a GitHub Release as a gzipped asset.

The Thin Wrapper

The npm package published to npmjs.com is not the server itself. It is a 124-line launcher script at bin/tacticremote.js. Here is what it actually does:

  1. Detects the current OS and CPU architecture using process.platform and process.arch
  2. Resolves the correct binary name for this platform
  3. Checks ~/.tacticremote/bin for a cached binary at the current version
  4. If not cached: downloads a gzipped binary from GitHub Releases, decompresses it, and writes it to disk with execute permissions
  5. Spawns the binary, inherits stdio, and forwards signals like SIGINT and SIGTERM
const binaryName = `tacticremote-${platform}-${arch}`
const url = `https://github.com/TacticSpaceTech/TacticRemote/releases/download/cli-v${version}/${binaryName}.gz`
const cacheDir = path.join(os.homedir(), '.tacticremote', 'bin')
// Download, gunzip, cache locally, chmod +x, spawn

First run downloads the release asset and caches it locally. Subsequent runs reuse the cached binary for that package version. The binary version is tied to the npm package version being invoked.

This pattern is not new. esbuild pioneered it, turbo uses it, prisma does something similar. The idea is that npm is a distribution channel, not a compilation environment. Use it to deliver a downloader, not a dependency tree.

The package.json Split

We maintain two package manifests side by side:

  • package.json — the development config. Contains all devDependencies (Jest, ESLint, Prettier), test scripts, lint scripts, and the full dependency list used when developing locally.
  • package.npm.json — the lean publish manifest. It points the bin field at tacticremote.js and omits the runtime dependency tree.

That split keeps the development workflow and the published package honest. It also leaves us with one obvious maintenance cost: the two manifests have to stay aligned where metadata overlaps.

That manifest sync is still a known fragility. The repo does not yet contain a publish-time validation step to guarantee the handoff stays correct, so CI automation is the next obvious improvement.

The 30-Second Onboarding Flow

With the distribution problem solved, we audited every step of the connection flow to remove friction.

Seconds 0–5. The user runs npx tacticremote in their terminal. On first run, the shim detects no cached binary and downloads it. On every subsequent run, the cache check takes milliseconds.

Seconds 5–10. The server binary starts. It detects the machine's local IP address, generates a random API key, and prints a QR code directly in the terminal using Unicode block characters. No browser required, no web UI to navigate to.

Seconds 10–15. The user opens Tactic Remote on their phone. The home screen shows a "Connect Your Computer" card with the npx tacticremote command and a "Scan QR" button. Tapping the button opens the camera.

Seconds 15–25. The user scans the QR code in their terminal. The QR encodes a JSON payload with the server's IP address, port, and API key. The app decodes this payload and saves the server configuration automatically — no "Add Server" screen, no manual IP entry, no API key copy-paste.

Seconds 25–30. The WebSocket handshake completes. Terminal output starts streaming to the phone. The connection indicator turns green.

The QR auto-save step eliminated the most friction. The previous onboarding flow required: tap "Add Server", enter IP address, enter port, enter API key, tap Save, tap Connect. Five taps and three manual data entries, each of which could go wrong. The QR scan collapses all of that into a single gesture.

What We'd Do Differently

A few things we know are suboptimal and haven't fixed yet:

No artifact signature verification yet. The launcher downloads over HTTPS, but it does not yet verify the binary against a published signature or checksum. That is the most important remaining integrity gap in the current approach.

Manifest handoff still needs automation. We keep a development manifest and a publish manifest, but the repo still needs a stricter publish-time guardrail to verify they stay in sync.

No fallback mirror. If GitHub Releases is unavailable, the launcher has nowhere else to fetch from. A secondary mirror would make the download path more resilient.

The Bigger Picture

The distribution path is built around one principle: users should not have to think about infrastructure. They do not need to know what Bun is, which CPU architecture their Linux box uses, or how to copy an API key by hand. The command starts the path, the launcher fetches the right binary, and the QR code carries the connection details.

The server itself — its architecture, its single-dependency design, its security model — is covered in the companion post One Dependency: Building a Production Server with Just the ws Package. This post is about getting that server into users' hands quickly.

If you haven't tried the 30-second flow yet, head to /download and give it a shot.


For the server architecture story, see One Dependency: Building a Production Server with Just the ws Package. For setup instructions, see the Getting Started guide.

Try Tactic Remote

Control your coding Agents from your phone

Connect to Claude Code, Codex, and other Agents on your Mac, Windows, or Linux computer. Check progress and send the next instruction from iPhone or iPad.