@appforge-ci/cli
appforge — the customer-facing CLI: sign up/log in, manage apps,
trigger/watch builds, buy build-token top-ups, start an in-browser
device-test session. No fleet commands — that's a separate tool,
appforge-admin (packages/admin-cli), for the platform operator only.
Install
On a machine that already has this repo cloned:
pnpm install # once, from the repo root
pnpm --filter @appforge-ci/cli build
cd packages/cli && npm link # symlinks `appforge` onto PATH
From npm (no clone needed):
npm install -g @appforge-ci/cli # or run once: npx @appforge-ci/cli login --web --api-url https://api.appforge.skoor.ee
appforge login --web --api-url https://api.appforge.skoor.ee
Published by .github/workflows/publish.yml on every version tag, together with @appforge-ci/core. The tarball contains the compiled
dist/ only: no tests, no test helpers, no sources.
Usage
appforge signup --email [email protected] --org "My Org" # or: appforge login --email [email protected]
appforge logout # remove the login from THIS machine (the file and leftover copies) AND revoke its token on the server — see "Logging out"
appforge login --email [email protected] --totp-code 123456 # if 2FA is enabled (--recovery-code also works)
appforge login --email [email protected] --email-code # no password: emails you a 6-digit code, then asks for it
appforge login --web # sign in (or sign up) in your browser, like `aws sso login`: no password typed here; prints the claude mcp add command at the end
appforge apps create --name demo --platform flutter --repo-url https://github.com/you/app.git
appforge apps list
appforge apps update <appId> [--name] [--repo-url] [--default-branch] # fix a stale field without recreating the app
appforge apps webhook-secret <appId> # the secret GitHub's webhook "Secret" field needs
appforge apps delete <appId> # permanently delete an app and its builds/OTA history
appforge apps secrets set <appId> --name KEYSTORE_PASSWORD # --value omitted -> prompted, echo off (never in shell history); injected as $KEYSTORE_PASSWORD on every build for this app
appforge apps secrets list <appId> # names only, never values
appforge apps secrets unset <appId> --name KEYSTORE_PASSWORD
appforge fleet-secrets set DEVICE_STREAM_PUBLIC_KEY "$(base64 < device-stream-public.der | tr -d '\n')" # a PUBLIC key; control-plane secrets are refused
appforge fleet-secrets list # names only, never values
appforge fleet-secrets unset DEVICE_STREAM_PUBLIC_KEY_PREVIOUS
appforge orgs rename --name "New Org Name" # fix a typo'd/stale org name (e.g. a placeholder from signup)
appforge orgs set-build-webhook --url https://example.com/hook # POST build id/status/appId/costTokens/artifacts on every build's completion
appforge orgs set-build-webhook --clear # turn build-status notifications back off
appforge build trigger --app-id <appId>
appforge build trigger --app-id <appId> --remote-access # run in a VM you can log in to (docs/remote-access-user.md)
appforge builds ssh <buildId> # shell in that VM while the build runs and for 10 minutes after; ssh options go after --
appforge builds vnc <buildId> # screen sharing into it (opens macOS Screen Sharing on a local port)
appforge builds remote-access <buildId> # is the VM ready, and who opened sessions
appforge builds simulator <buildId> # a succeeded build's iOS Simulator app (*.app.zip) on a Mac VM, shown by macOS Screen Sharing (100 tokens; docs/remote-access-user.md)
appforge apps test-in-simulator <appId> # the same for the app's newest succeeded build that has a *.app.zip
appforge build list --app-id <appId>
appforge build logs <buildId> --follow
appforge build artifact <buildId>
appforge build cancel <buildId>
appforge build explain <buildId> # AI explanation for a failed build (needs ANTHROPIC_API_KEY on the deployment)
appforge build analyze-smoke-test <buildId> # re-run AI analysis of the build's smoke-test screenshots
appforge build test <buildId> # where to test a succeeded build in the browser (the dashboard page: Android device test, or iOS Simulator)
appforge init # draft an appforge.yaml from what's actually in the current repo
appforge billing balance
appforge billing packs
appforge billing topup --pack starter # via LHV Paytech, see docs/payments-lhv.md
appforge billing ledger # every balance change (top-ups, charges, refunds), newest first
appforge billing usage # billing mode + this period's accrued usage, on postpaid
appforge billing setup-card # save a card — required before switching to postpaid
appforge billing mode <prepaid|postpaid-monthly>
appforge billing invoices # monthly invoices (postpaid billing only)
appforge ota channels --app <appId> # list channels and each one's active release
appforge ota releases --app <appId> [--channel production]
appforge ota promote <buildId> --app <appId> [--channel production] [--rollout 25] [--mandatory]
appforge ota set-rollout <releaseId> --app <appId> --rollout 50 # progress an existing release's rollout in place
appforge ota app-key --app <appId> # generate/rotate the manifest key a shipped app embeds
appforge ota activate <releaseId> --app <appId> --channel production # jump directly to a specific release
appforge ota activate --app <appId> --channel production --clear # deactivate the channel instead
appforge ota rollback --app <appId> --channel production # step back exactly one release
appforge tokens list [--limit n] [--offset n] # the org's API tokens, newest first, 50 a page — id, label, created/last-used (never the token value)
appforge tokens revoke <tokenId> # permanently revoke one — 'appforge login' again mints a fresh one
appforge vpn status [--json] # the org's site-to-site VPN tunnel (any member)
appforge vpn create --name hq --endpoint vpn.example.com:51820 --gateway-key <base64> --lan 10.20.0.0/16 [--dns 10.20.0.2 --domain corp.example.com] [--tunnel-cidr 169.254.200.0/30]
appforge vpn peer-config # re-show the [Peer] block for your gateway (prints a secret; admin, audited)
appforge vpn rotate-keys [--yes] # new AppForge keys; tunnel returns to pending (a disabled tunnel stays disabled) until you update your gateway
appforge vpn disable [--yes] | enable # disable cancels + refunds queued VPN builds
appforge vpn delete [--yes]
appforge vpn events [--json] # newest first
appforge vpn test [--json] # start a build-free handshake test of the gateway (owner/admin; nothing charged; once per 5 minutes)
appforge vpn test --status [--json] # the latest test's verdict (any member); exits 0 only on handshake_ok
appforge apps update <appId> --vpn # opt an app in (--no-vpn to opt out; org owner/admin)
Logging out
login/signup store a long-lived afk_ API token in ~/.appforge/config.json. On a Mac that builds for tenants that file is readable
by every standard-tier build, which runs as the same user, so keep the login on your own machine and run appforge logout as soon as you
are done with a shared or build Mac:
- It deletes
config.jsonand anyconfig.json.*copy next to it (the temp file of a killed save, a.bak), prints what it removed, leaves everything else in~/.appforgealone (the VPN state lives there too), and is safe to run when nothing is there. Aconfig.jsonthat is a symlink is unlinked, not followed: the output names the file it pointed to, which stays. - It revokes the token on the server too (
DELETE /auth/token, issue #952): the token stops working at once, so a copy someone already read is dead. The files are read first and removed before any network call, so the Mac is clean even if the API is unreachable; every saved copy's token is revoked, each against the API URL saved beside it. If the revoke fails (offline, an API that predates the route)logoutstill removes the login, exits 0, says it could not revoke, and prints the manual follow-up: from a trusted machine,appforge tokens list(the token is labelledcli:<email>; match it by its created time), thenappforge tokens revoke <tokenId>, andappforge loginagain only there. Deleting the file by hand revokes nothing. The token is never printed. Iflogoutruns where a long-lived CI token is the saved login, that token is the one revoked: mint a new one for the pipeline. loginandsignupprint a warning when builds run as the account you logged in with (the agent's config or LaunchAgent plist is in your home, or the boot-time agent runs as you); a laptop with no agent gets nothing, and neither does another account on a Mac whose agent runs as someone else.appforge-agent doctorfails (cli-credentials-off-build-mac) while a login is stored on a Mac with an agent. Nothing is refused.appforge tokens listshows the newest 50 tokens first. The token an old login left on a Mac is among the oldest, so if it is not in the list, go on with--offset 50,--offset 100, ... (or--limit 200) beforetokens revoke.
See docs/security.md, "A CLI login never stays on a build Mac". The token self-revoke (issue #952) is covered by unit and route tests; not yet run against the deployed control plane (UNVERIFIED until a dated run is recorded there).
Site-to-site VPN
You describe your own WireGuard gateway; AppForge's Mac dials out to it for
builds of apps that opted in. vpn create prints a [Peer] block to paste
into your gateway; update the gateway again after rotating keys or changing
LAN/tunnel ranges. It contains a secret (the preshared key) — treat it
like a password, and only create, peer-config and rotate-keys print it.
All changes need an org owner/admin; any member can read status/events.
vpn test checks that your gateway accepts the handshake without building anything: it
releases the tunnel's keys to one build machine for the test (see docs/vpn.md, "Test tunnel"),
and vpn test --status prints the verdict and when it ran.
Limits: tools that ignore proxy settings (raw sockets, UDP, ICMP,
xcodebuild, simulators) don't use the tunnel; SSH git remotes
(ssh://user@host/path or user@host:path) go through it when you pin
your server's host keys (vpn ssh-hosts set) and install the app's deploy
key (apps deploy-key show), while a bare ssh in a build script is not
routed; your gateway must accept inbound UDP on its endpoint port; status
becomes ready after the first successful VPN build (vpn test checks the
handshake alone; a build is the end-to-end check); builds appear from the tunnel's AppForge-side /30
address, so allow-list it or masquerade.
--api-url (default http://localhost:8787, or set APPFORGE_API_URL)
only needs passing on login/signup — after that it's saved to
~/.appforge/config.json and reused. See the root README.md's Quick
start for the full local-dev flow and docs/self-hosting.md for pointing
this at a real deployment.
login prompts interactively for a 2FA code if the account has it
enabled and neither --totp-code nor --recovery-code was passed — see
docs/security.md's "2FA (TOTP)" note.
login --email-code signs in with a one-time code emailed to the account instead of a password (verified email addresses and
platform admins; the code is typed at a prompt, never passed as a flag, and expires in 10 minutes). Two-factor works the same way as
for a password login. See docs/security.md's "Email one-time-code sign-in".