Contributing to tapflow

September 12, 2026 · View on GitHub

Common rules: AGENTS.md | Full index: INDEX.md | Community standards: Code of Conduct

Development setup

Requirements: Node.js ≥ 22, pnpm ≥ 9

git clone https://github.com/jo-duchan/tapflow.git
cd tapflow
pnpm install
pnpm dev

pnpm dev starts the relay, dashboard, iOS agent, and Android agent concurrently.

Dev & test commands

All dev/test commands run from the repo root. The playground/ package holds the underlying implementations (relay, agents, seeders) — you do not run them from there directly.

CommandWhat it runs
pnpm devrelay + dashboard + iOS agent + Android agent
pnpm dev:poolrelay + iOS agent + mock agents (multi-device testing without real simulators)
pnpm dev:relay / pnpm dev:ios / pnpm dev:androida single component
pnpm seed / pnpm seed:demoseed the local DB with test / demo data
pnpm doctor / pnpm resetrun the CLI doctor / reset against your local environment
pnpm mcpstart the MCP server (AI-agent path)
pnpm pre-releasebuild the dashboard and serve it from the relay — mirrors the installed-user experience at http://localhost:4000

The dashboard runs on http://localhost:3001 (Vite dev server) and the relay API on http://localhost:4000.

Project structure

packages/
  agent-core/     ← shared DeviceAgent interface
  ios-agent/      ← IOSAgent (macOS)
  android-agent/  ← AndroidAgent (macOS)
  relay/          ← relay server + REST API + SQLite
  dashboard/      ← React SPA (served by relay)
  cli/            ← tapflow CLI
docs/             ← documentation site (VitePress)
playground/       ← local integration test environment

The two READMEs are one document

packages/cli/README.md is what npm renders for the published CLI, and it is README.md with two URL prefixes absolutised — npm resolves neither repo-relative paths nor blob/main links. Edit both, and write the cli copy's links absolute. scripts/__tests__/readmeSync.test.mjs compares them and runs in CI; a block that genuinely has to differ goes between <!-- readme-sync:exempt <reason> --> markers in both files. Today one does: GitHub renders <video> and npm does not.

Claiming an issue

Issues are not assigned in advance. Open a draft PR or post your findings on the issue and it is yours — a comment reserving one does not hold it, so no issue sits blocked behind an intent that never lands.

Check the labels before you start: requires: macOS means the change runs against a real simulator or emulator and cannot be verified without a Mac (Xcode / Android SDK). A good first issue without that label needs only Node.js and pnpm.

Branches & releases

  • main is always deployable. Direct commits are not allowed. Start work on a feature/{topic} branch → PR → merge.
  • Always create new branches from origin/main (git fetch origin && git checkout -b feature/{topic} origin/main). Your local main may be behind.
  • Backfilling a changeset for an earlier PR? Name what it covers, on its own line: Backfills: #413 (quoting it inside a code block does not count, same as the marker below). The release-time audit judges each merge on its own, so without that line it keeps reporting the original merge as a gap for the rest of the cycle — and the only way to tell a real gap from a filled one is to match them up by hand.
  • Every PR that changes published source needs a changeset. The CI changeset job fails otherwise, and it is a required status check on the protect-main ruleset, so that failure blocks the merge; pnpm changeset:check runs the same check locally against committed work. Skip it only by stating why in the PR body, on a line of its own: <!-- no-changeset: reason --> (quoting it inside a code block does not count). A comment-only or test-only change is a fair skip — the point is that it is a decision, not an omission.
  • A dashboard change names @tapflowio/relay, never @tapflowio/dashboard. The dashboard is private and ignored in .changeset/config.json, so pnpm changeset does not offer it in the package list at all. It is built into the relay's public/ and ships inside that package, which is where its release note belongs. Naming both in one changeset is rejected by changeset version.
  • Releases are driven by changesets. A tag push triggers GitHub Actions → npm publish + GitHub Release. Merging to main does not auto-publish.
  • Never publish with raw npm publish — it does not rewrite workspace:* dependencies between packages; the changesets → pnpm publish path does.

Publishing the Docker image from a fork

If you are publishing a fork of tapflow to a custom Docker registry namespace, you need two repository secrets set up in GitHub:

  • DOCKERHUB_USERNAME — your Docker Hub username.
  • DOCKERHUB_TOKEN — your Docker Hub personal access token (Read & Write permissions).

The .github/workflows/docker-publish.yml workflow detects these secrets and publishes multi-platform images (linux/amd64, linux/arm64) on every push to main and on version tags. Without these secrets, CI only builds and smoke-tests the image for validation, without attempting to publish it.

The secrets alone are not enough. That workflow hardcodes IMAGE: tapflow/tapflow, so a fork that sets only the two secrets pushes at this project's namespace and fails on permissions. Change IMAGE to your own namespace in the workflow as well.

GHCR needs neither. The same workflow copies each published manifest to ghcr.io/<owner>/<repo>, authenticating with the GITHUB_TOKEN Actions already provides — so there is no secret to create and none to rotate, and the name is derived from your fork rather than hardcoded. It is gated on the Docker Hub secrets only because it copies what the Docker Hub push produced; a fork that never sets them publishes to neither.

One click, once, and nothing automates it. GHCR creates a package private on its first push, so a fork's mirror answers no anonymous pull until somebody sets its visibility to Public at https://github.com/<owner>/<repo>/pkgs/container/<repo>. A workflow run cannot change that setting, so the last GHCR step logs out and retries the pull unauthenticated: it warns on every publish until the package is public, and reddening the release for it would be the wrong signal. tapflow's own package is already public — this is a fork-only step.

Versioning (Semver)

Versions follow MAJOR.MINOR.PATCH. Determine the bump from the commits since the last release:

BumpWhen
patchfix, perf, docs, chore, refactor — no API change
minorfeat — new functionality, backward-compatible
majorAny breaking change (see AGENTS.md Core Principles for scope)

Before v1.0.0: breaking changes may land in minor versions. Once v1.0.0 is tagged, the table above is strictly enforced.

If a single release contains commits of mixed types, the highest bump wins (major > minor > patch).

Pre-release tags

Use the following suffixes for staged rollouts:

v0.3.0-alpha.1   # unstable, internal testing
v0.3.0-beta.1    # feature-complete, external testing
v0.3.0-rc.1      # release candidate, no new features

CHANGELOG

CHANGELOG.md follows the Keep a Changelog format.

Sections (use only what applies — omit empty ones):

SectionWhen to use
### Breaking ChangesAny change requiring user action to migrate
### AddedNew features or commands
### ChangedChanges to existing behaviour
### DeprecatedFeatures that will be removed in a future release
### RemovedFeatures removed in this release
### FixedBug fixes
### SecuritySecurity-related fixes

On every PR that touches user-facing behaviour, add an entry under ## [Unreleased]. Keep entries concise — one line per item, starting with a backtick-quoted identifier when applicable.

Not every changeset earns an entry. Protocol typing and internal refactors would fill the file with noise a self-hoster cannot act on, so a changeset opts out from inside its own body, on a line of its own:

<!-- changelog: internal — protocol typing, nothing a user can observe -->

The marker goes in the changeset rather than the PR body, because it classifies that one change — a PR carrying two changesets can need it for only one of them. internal is matched literally, so <!-- changelog: docs-only --> does not opt out. The CI changeset job checks only that CHANGELOG.md was touched; no check can tell whether the prose matches the diff.

Breaking Changes go in ### Breaking Changes with a one-line description and a Migrate: hint. For complex migrations, a separate MIGRATION.md may be added, but prefer keeping it inline unless the guide exceeds ~10 lines.

At release time, rename ## [Unreleased] to ## [x.y.z] - YYYY-MM-DD, add a fresh empty ## [Unreleased] above it, and append a comparison link at the bottom:

[x.y.z]: https://github.com/jo-duchan/tapflow/compare/vPREV...vx.y.z
[Unreleased]: https://github.com/jo-duchan/tapflow/compare/vx.y.z...HEAD

Tests

All packages:

pnpm test

Repository scripts suite (cross-package static checks):

pnpm test:scripts

A specific package:

pnpm --filter @tapflowio/ios-agent test
pnpm --filter @tapflowio/android-agent test
pnpm --filter @tapflowio/relay test
pnpm --filter @tapflowio/cli test

Run the tests for any changed packages before opening a PR. New behavior must be covered by tests written first, passing before the PR is opened.

Test principles

No Potemkin tests. A test must be able to fail. If no production code change could break it, delete it. expect(result).toBeDefined() alone is not a test — assert the actual value.

No flaky tests. Use vi.useFakeTimers() instead of setTimeout waits. Fix Date.now() with vi.setSystemTime(). Clean up global state in beforeEach/afterEach. Never depend on real network ports or file paths.

Mock only at system boundaries — real network, OS calls, external processes. Internal module interactions run against real code.

Name the mutation. For every test, know the production change that would make it fail. For a test asserting that something does not happen, make that change and watch it fail before you commit — an absence assertion passes when nothing happens at all, so a green run on its own is not evidence it holds anything. test-and-guard-coverage.md collects the cases where that went wrong, including a guard bypassed four ways with the whole suite green.

Technical internals

Platform-specific implementation notes for contributors:

Commit messages — Conventional Commits

<type>(<scope>): <subject>
  • type: feat · fix · test · refactor · docs · chore · perf
  • scope: the changed package name (agent-core · ios-agent · android-agent · relay · dashboard · cli · playground)

Language

Write PRs, issues, and commit messages in English (internal .work/ and CLAUDE.md docs may be in any language).

Reporting bugs

Use the Bug Report issue template. Include steps to reproduce, expected vs. actual behavior, and your environment (tapflow version, Node.js version, and Xcode version for iOS issues).