Files
hapi/docs/local-deployment.md
T
wushenghua 7221c8caed feat(deploy): add remote deploy over SSH with a shared signing step
- extract the pinned-identity signing into scripts/sign-build.sh so local and
  remote deploys ship the same stable signature (macOS TCC grants key on the
  signing identity, not the path)
- add scripts/deploy-remote.sh: signs locally, copies the binary to a new
  versioned file on the remote host, swaps the ~/.hapi/bin/hapi symlink,
  kicks the launchd job (default com.hapi.runner, override via
  HAPI_REMOTE_LAUNCHD_LABEL), and verifies the runner executes the new file;
  running sessions survive
- document the remote flow in AGENTS.md and docs/local-deployment.md
2026-09-16 18:29:42 +08:00

6.3 KiB

Local deployment branch

local/deploy-main is the source of the locally deployed HAPI binary.

Update rule

Never reset this branch to main or recreate it from main. Update it with:

git fetch upstream main
git merge upstream/main

Resolve conflicts by preserving the local features below. Build and deploy only after typecheck and focused regression tests pass.

Local features carried by this branch

  • Claude local history import (tiann/hapi#1429)
  • Codex and Cursor mid-turn steering (tiann/hapi#1443)
  • Separate setting to pin all active sessions (tiann/hapi#1447)
  • Claude custom models from settings.json (customClaudeModels); upstream PR tiann/hapi#1318 was closed, so this must remain local
  • macOS case-safe Storage Usage module names, required for local typecheck/build

When one of the open PRs is merged upstream, drop only the equivalent local commits after verifying the merged implementation is present. Do not drop the other local features.

Local work preserved on separate branches

  • Notification preferences and customizable push copy: feat/notification-preferences (tiann/hapi#1360)
  • HTML preview and completed-unseen marker: feat/html-preview
  • Earlier recovery work, including Codex quick import: feat/recover-local-features

These branches must not be deleted during cleanup or upstream updates.

macOS executable deployment (important)

Deploy with:

bun run build:single-exe
scripts/deploy-local.sh [tag]

The script implements the sequence below, including the runner refresh; read on to understand the macOS constraints it works around.

Code-signature cache (never overwrite the stable path)

macOS caches the Mach-O signature against the executable pathname and modification time. Replacing ~/.hapi/bin/hapi in place (including a temp-file + rename) or using a plain cp can leave a stale code-signature cache. The next launch then fails with:

OS_REASON_CODESIGNING
embedded signature doesn't match attached signature

This is a deployment/install issue, not a HAPI application error. Use a fresh versioned path for every build and keep the stable command path as a symlink. Do not deploy by copying over the stable path.

Sign with a stable identity (TCC)

macOS TCC records permission grants against the code-signing identity. An ad-hoc signature (codesign --sign -) has no stable identity, so each rebuild looks like a brand-new app and every protected permission (Documents, Downloads, Apple Music/media library, ...) is asked again. Sign every build with one pinned identity:

  • scripts/deploy-local.sh stores the chosen Apple Development SHA-1 in ~/.hapi/signing-identity and reuses it. Override with HAPI_SIGN_IDENTITY when rotating certificates.
  • All builds use the signed identifier run.hapi.cli.
  • With no Apple Development identity the script falls back to ad-hoc; that still works, but expect TCC prompts to reappear after every deploy.

Agent sessions should also avoid recursive $HOME sweeps (find ~, du -sh ~, ...) unless they prune TCC-protected folders (~/Music, ~/Pictures, ~/Movies, ~/Library): a walk into ~/Music/Music/Media.localized raises a spurious Apple Music prompt attributed to the hapi binary.

Refresh the runner

The runner is a long-lived detached process. New sessions it spawns resolve ~/.hapi/bin/hapi and therefore pick up the new binary automatically, but the runner's own code stays old until the process restarts:

  • compiled binaries never self-update: the heartbeat compares the mtime of the runner's own resolved exec path, which is fixed for the life of the process
  • a stale runner keeps old machine RPC handlers and capability flags, so hub features fail with "restart the runner" errors

scripts/deploy-local.sh runs hapi runner start when a runner is already running; the CLI stops the stale runner and starts a fresh one with HAPI_CLI_EXECUTABLE pinned to the stable symlink. Running sessions are detached and survive the restart.

Manual sequence

set -euo pipefail
build=cli/dist-exe/bun-darwin-arm64/hapi
stamp=$(date +%Y%m%d-%H%M%S)
release="$HOME/.hapi/bin/hapi.$stamp"

identity=$(cat "$HOME/.hapi/signing-identity")   # SHA-1, or a unique cert name

# Bun's linker signature is not suitable after installation; re-sign once.
codesign --remove-signature "$build" 2>/dev/null || true
codesign --force --sign "$identity" --identifier run.hapi.cli "$build"
codesign --verify --deep --strict "$build"

# -p preserves the mtime covered by the code-signature cache.
cp -p "$build" "$release"
codesign --verify --deep --strict "$release"
"$release" --version

# Keep the old target as a rollback point. If hapi is already a symlink,
# replace only the link; otherwise move the legacy regular file aside first.
stable="$HOME/.hapi/bin/hapi"
if [ -L "$stable" ]; then
    old_target=$(readlink "$stable")
else
    old_target="hapi.bak.$stamp"
    mv "$stable" "$HOME/.hapi/bin/$old_target"
fi
ln -sfn "$(basename "$release")" "$stable"

launchctl kickstart -k "gui/$(id -u)/com.hapi.hub"
sleep 2
curl -fsS http://127.0.0.1:3006/health >/dev/null
"$stable" --version

# Refresh a running runner; new sessions already use the new binary via the
# symlink, but the runner's own machine RPCs/capabilities stay stale.
HAPI_CLI_EXECUTABLE="$stable" "$stable" runner start

If the health check fails, immediately restore the prior link and restart the agent:

ln -sfn "$old_target" "$HOME/.hapi/bin/hapi"
launchctl kickstart -k "gui/$(id -u)/com.hapi.hub"

Never use cp, mv, or codesign on the stable symlink target after the launch agent has been started. Keep versioned binaries until the replacement has been running and verified.

Remote machines

Macs that run the same layout (versioned binary + ~/.hapi/bin/hapi symlink + a supervised runner) are updated from here over SSH:

scripts/deploy-remote.sh <ssh-target> [tag]     # e.g. scripts/deploy-remote.sh k2lab stable-signing

The script signs the build with the same pinned identity, copies it to a new versioned file, swaps the symlink, restarts the launchd job (com.hapi.runner by default; override with HAPI_REMOTE_LAUNCHD_LABEL), and verifies the runner executes the new file. Running sessions survive; roll back by restoring the previous symlink and kicking the job again. The remote host needs no build toolchain — it only receives the signed binary.