create-op-node
v0.4.0
Published
Interactive bootstrap for an Opus Populi node — Cloudflare infrastructure, Mac Studio, ghcr.io image pull. From sealed box to public API in one command.
Downloads
129
Maintainers
Readme
create-op-node
Interactive bootstrap for an Opus Populi federation node. From a sealed-box Mac Studio + a Cloudflare account to a live public API in one command.
npx create-op-nodeThat's it. The wizard walks you through:
- Cloudflare — verifies your API token's 5 scopes (Zone Read, DNS Edit, Tunnel Edit, R2 Storage Edit, Pages Edit). Fails fast with a specific scope name if anything's missing.
- GitHub — creates your region's node repo from the
OpusPopuli/opuspopuli-nodetemplate via the GitHub API. Seeds the 5 required GitHub Secrets (Cloudflare token, account ID, zone ID, Terraform Cloud token, TFC org) for you. - Terraform Cloud — verifies your TFC token, prepares the workspace.
- First PR — writes
environments/prod.tfvarsfrom your answers, commits, opens the first PR. The node repo'scloudflare-infra.ymlworkflow runsterraform planagainst the PR; on merge tomainit applies — Tunnel, DNS, R2 buckets, and Pages project come up automatically. - pgsodium master key — generates a fresh 64-hex root key, stores it in your macOS login Keychain as
org.opuspopuli.<region>/pgsodium-root-key. No third-party password manager required. - Tunnel token retrieval — after
terraform applylands, fetches the Tunnel token from Terraform Cloud outputs and stores it alongside the pgsodium key in your Keychain.
Then on the Mac Studio itself:
npx create-op-node bootstrapConfigures macOS power settings, installs Homebrew + the CLI tool list, sets up Docker Desktop + Tailscale + Ollama, clones the node repo you created, reads the pgsodium key + Tunnel token from the Studio's Keychain (or prompts you to paste them once, then persists for re-runs), writes the LaunchAgent plist, logs into ghcr.io, pulls + warms the LLM model, and finally docker compose --profile public pull && up -d brings the whole stack online. Health-check loop waits until all containers are (healthy).
Local-only mode (no Cloudflare)
npx create-op-node bootstrap --region us-ca --local-onlyBrings the Studio up for local dev / testing — frontend on your laptop hits the Studio over Tailscale, no public exposure. Differences from the standard run:
- No Tunnel token required.
initis unnecessary; if the pgsodium key isn't in Keychain, bootstrap generates one inline and persists it. cloudflaredstays down. It's gated behind thepubliccompose profile, which--local-onlydoesn't activate. Bootstrap also evicts any leftover cloudflared from a prior public run so it doesn't strand incompose ps.- Backup stack skipped by default.
docker-compose-backup.ymlisn't loaded; pass--compose-file docker-compose-backup.ymlto include it explicitly. - LaunchAgent omits
TUNNEL_TOKEN. OnlyPGSODIUM_ROOT_KEYis exported into the launchd session. - Outro tells you to use Tailscale, not
npx create-op-node verify.
When you're ready to go public, re-run bootstrap without --local-only
and the same Studio promotes to the full production-shaped deploy.
Template version: this mode depends on the
opuspopuli-nodetemplate havingprofiles: [public]on its cloudflared service. If you cloned the template before that landed, refresh your fork (or recreate from template) before using--local-only— otherwise cloudflared starts regardless and will restart-loop without a TUNNEL_TOKEN.
Secret transport between laptop and Studio
The macOS
securityCLI writes to the local login keychain — items don't sync to iCloud Keychain automatically. On the Studio's first bootstrap, the operator pastes the pgsodium key + Tunnel token once (from the laptop's Keychain Access, or wherever you copied them); the Studio bootstrap validates the format and persists locally so re-runs read straight through. Use AirDrop /security find-generic-passwordoutput / Tailscalescpto ferry the values.
Resetting the Studio
To start over (e.g. before rerunning bootstrap against a different
region, or after a misconfiguration), reverse the Studio-side state:
npx create-op-node reset --region us-caThree phases run in reverse-bootstrap order. By default volumes are
preserved — the database survives so you can bring the stack back up
with bootstrap without losing data.
- Stop the stack —
docker compose down. Pass--wipe-datato add-v(destroys named volumes including the database). The wipe mode requires retyping the region label as confirmation — and the prompt deliberately doesn't pre-fill the answer, so you have to type it from memory.ywon't do it. - Unload + remove the LaunchAgent —
launchctl unloadthenrmthe plist and the pgsodium key file.--keep-key-fileleaves the key in place as a belt-and-suspenders backup before a wipe-data run. docker logout— clears the registry-credentials store entry forghcr.io(or override with--registry). This only clears the store entry; if your credential helper caches the token elsewhere (or~/.docker/config.jsonhas stale entries from another host), those need separate cleanup.
Reset does not touch cloud-side state: the Cloudflare resources,
the GitHub repo, and the TFC workspace remain. Keychain items on the
Studio are also left in place — security delete-generic-password -s
org.opuspopuli.<region> -a pgsodium-root-key etc. if you want them
gone.
init is idempotent against existing cloud setup, so re-running it
won't duplicate anything.
Useful flags:
--dry-run— print the plan without acting. Phases that would run show with a?icon; phases that are skipped show with·.--skip-stack/--skip-launch-agent/--skip-docker-logout— surgical resets when only one piece needs cleaning.--no-remove-orphans— drop--remove-orphansfromcompose down. Useful when you ran bootstrap with a custom--compose-fileset and reset without it.--repo-dir <path>— explicit path to the cloned node repo when reset is run from outside the checkout. Passing a path that doesn't look like a node repo is a hard error, not a silent skip.--registry <reg>— log out of a registry other thanghcr.io.
# Try-before-you-buy: preview every step.
npx create-op-node reset --region us-ca --dry-run
# Nuke from orbit: containers + volumes + LaunchAgent + ghcr credentials.
npx create-op-node reset --region us-ca --wipe-dataVerifying a live node
npx create-op-node verify --domain your-domain.exampleOff-LAN health probe of a live node, runnable from anywhere with internet access. Five phases:
- TLS handshake to
api.<domain>:443— surfaces cert subject, issuer, and days-to-expiry. Warns when the cert is within--cert-warn-daysof expiring (default 14d). Negative expiries render asexpired Nd ago. GET https://api.<domain>/healthmust return 200.POST https://api.<domain>/apiwith{ __typename }must return a valid GraphQL envelope (catches the "TLS green, but a misconfigured proxy returns HTML" case).- Cloudflare Tunnel status (optional) — looks up
connectionsvia the CF API. Zero connectors registered → warning that cloudflared on the Studio is offline. Requires all three of--cf-token(or--cf-token-file),--cf-account-id,--tunnel-id; partial configuration warns + names the missing flag. cosign verify(optional, repeatable--image) — keyless verification against the GitHub Actions OIDC issuer + Fulcio + the Rekor transparency log. Silently skipped whencosignisn't onPATH(install withbrew install cosignto enable).
No phase short-circuits the others — verify always runs the full pass so
the operator sees the whole landscape in one report. Exits non-zero only
when at least one phase failed; warnings are reported but don't fail the
run. Skipped phases are hidden by default; add --show-skipped to see them.
Full flag set:
npx create-op-node verify \
--domain yournode.example.org \
--cf-token-file ~/.config/opuspopuli/cf-token \
--cf-account-id $CF_ACCOUNT_ID \
--tunnel-id $TUNNEL_ID \
--image ghcr.io/opuspopuli/api:latest \
--image ghcr.io/opuspopuli/users:latest \
--cert-warn-days 21--cf-token-file is preferred over --cf-token for cron / systemd
invocations — the latter ends up in ps output, the former doesn't.
Use --api-host <host> to override the default api.<domain>
construction when your node exposes the API at a different subdomain.
Bootstrapping a region config
A node serves data; what data it serves is defined by a declarative region
config in OpusPopuli/opuspopuli-regions.
Hand-writing one of those JSON files against the schema is the same kind of
fiddly, error-prone step the rest of this CLI exists to remove — so there's a
subcommand for it. Run it from the root of your opuspopuli-regions checkout:
npx create-op-node regionThe wizard walks you through level (state or county), names, the two-letter state code, FIPS code, timezone, and at least one data source (URL, data type, source type, content goal). It then:
- Derives the
regionIdand keepsname === config.regionId(the invariant the regions repo enforces). - Validates the generated file against the vendored copy of
region-plugin.schema.json(the canonical contract) using an ESM-native JSON Schema validator, then layers on the cross-field rules the repo'spnpm testadds in code: semver shape, FIPS length per level (2 digits for state, 5 for county), county-id-prefixed-by-parent, no duplicate data sources keyed by(dataType, url). All checks run before writing, so the file lands green instead of bouncing off CI. - Writes it to the canonical path
(
regions/<state>/<state>.jsonorregions/<state>/counties/<county>/<county>.json).
Conventions you may not expect
- New configs are stamped at version
0.1.0— the documented starting point inopuspopuli-regions/CLAUDE.md. Bump manually as the config matures (additions → minor, breaking changes → major).boundarySourcesis not prompted for. It's optional per the schema, so the scaffolded file is valid without it — but if your region has TIGER / ArcGIS boundary coverage and you want PostGIS point-in-polygon district lookups, you'll need to add the block by hand after scaffolding (seeregions/california/california.jsonfor a worked example).civics_blocksis not part of the region config schema. It's a per-region taxonomy that lives elsewhere in the platform — don't look for a prompt for it here.
Then it's just pnpm test + a PR. Non-interactive flags (--level, --name,
--parent, --state-code, --fips, --timezone, --out-dir, --force) are
available for scripting; run create-op-node region --help for the list.
What it looks like
┌ create-op-node region
◇ What level is this region?
│ County
◇ County name?
│ Alameda
◇ Parent state slug?
│ california
◇ Display name?
│ Alameda County
◇ One-line description of the data coverage?
│ Civic data for Alameda County, California
◇ Two-letter state code?
│ CA
◇ County FIPS (5 digits)?
│ 06001
◇ IANA timezone?
│ America/Los_Angeles
◇ Data source #1 — URL?
│ https://bos.acgov.org/
◇ Data type?
│ meetings
◇ Source type?
│ html_scrape
◇ Content goal (what should the scraper extract)?
│ Fetch Board of Supervisors agendas, minutes, and votes
◇ Category label (optional)?
│ Board of Supervisors
◇ Add another data source?
│ No
◆ regions/california/counties/alameda/alameda.json (preview) ──────────╮
│ { │
│ "name": "california-alameda", │
│ "displayName": "Alameda County", │
│ "description": "Civic data for Alameda County, California", │
│ "version": "0.1.0", │
│ "config": { │
│ "regionId": "california-alameda", │
│ "regionName": "Alameda County", │
│ "description": "Civic data for Alameda County, California", │
│ "timezone": "America/Los_Angeles", │
│ "stateCode": "CA", │
│ "fipsCode": "06001", │
│ "dataSources": [ │
│ { │
│ "url": "https://bos.acgov.org/", │
│ "dataType": "meetings", │
│ "sourceType": "html_scrape", │
│ "contentGoal": "Fetch Board of Supervisors agendas, ...", │
│ "category": "Board of Supervisors" │
│ } │
│ ] │
│ }, │
│ "parentRegionId": "california" │
│ } │
├────────────────────────────────────────────────────────────────────────╯
◇ Write regions/california/counties/alameda/alameda.json?
│ Yes
◆ Done ────────────────────────────────────────────────────╮
│ ✓ Wrote regions/california/counties/alameda/alameda.json │
│ │
│ Next steps in your opuspopuli-regions checkout: │
│ pnpm test # schema + hierarchy │
│ pnpm test:connectivity # URL reachability │
│ git add … && git commit && open a PR │
├──────────────────────────────────────────────────────────────╯
└ Region scaffolded: california-alamedaCaveats
A couple of honest sharp edges, since this command lives in the node CLI rather than in the regions repo itself:
- Run it from a
opuspopuli-regionscheckout. The file is written relative to--out-dir(default: current directory) at the canonicalregions/…path. Run it anywhere else and the file lands in the wrong tree. - The validation here mirrors the regions schema; it does not import it.
create-op-nodecan't seeregion-plugin.schema.jsonat runtime, so its pre-write checks are a hand-maintained copy of the rules. They can drift if the schema changes. The regions repo's ownpnpm testis the source of truth — always run it after scaffolding; treat a green run there, not a green run here, as the real signal. If the two ever disagree, the schema wins and this command needs updating.
What lands where
create-op-node touches several secret stores. Here's the full map of
what we own (the two macOS Keychain items) vs. what we just route to its
destination.
Stored by create-op-node in macOS Keychain
Two items per region, both generic-password class
(kSecClassGenericPassword). Visible in Keychain Access.app
(/System/Applications/Utilities/Keychain Access.app) — not in the
new Passwords.app, which is filtered to website-login items only.
| # | Service | Account | Label (GUI display) | Value format | Written by | Read by |
|---|---|---|---|---|---|---|
| 1 | org.opuspopuli.<region> | pgsodium-root-key | Opus Populi (<region>) — pgsodium root key | 64 lowercase hex chars | init on laptop | bootstrap on Studio |
| 2 | org.opuspopuli.<region> | tunnel-token | Opus Populi (<region>) — Cloudflare Tunnel token | JWT-style base64url string | init on laptop (after TFC apply) | bootstrap on Studio |
Both items also carry -D 'Opus Populi secret' (the "Kind" column in
Keychain Access) so you can filter for them at a glance.
Inspect from a shell:
# Metadata only (safe to share output):
security find-generic-password -s org.opuspopuli.us-ca -a pgsodium-root-key
# Reveal the value (you'll be prompted to allow access on first call):
security find-generic-password -s org.opuspopuli.us-ca -a pgsodium-root-key -wStored elsewhere (we don't put these in Keychain)
Everything else flows through transiently or lives in its destination system's own credential store.
| Secret | Where it lives | Why not in Keychain |
|---|---|---|
| Cloudflare API token | Pasted into init prompt → forwarded to GitHub Secrets + Terraform Cloud vars | One-shot during init. Re-runs prompt again. We could store it; adds risk vs benefit. |
| Cloudflare account ID, zone ID | Same as above | Not really a "secret" but flow alongside the token |
| Terraform Cloud token | Pasted, used to verify + poll runs | Same one-shot pattern |
| GitHub PAT | Read from gh auth token if available, else pasted | gh already manages it |
| pgsodium key (Studio runtime form) | ~/.config/opuspopuli/pgsodium_root_key (mode 0400) | LaunchAgent reads it at every login → interpolates into the PGSODIUM_ROOT_KEY env var. Same value as in Keychain; file is runtime form. |
| Cloudflare Tunnel token (Studio runtime form) | Baked into ~/Library/LaunchAgents/org.opuspopuli.envloader.plist (mode 0600) | launchd's launchctl setenv TUNNEL_TOKEN injects it into the session at every boot. Same value as in Keychain; plist is runtime form. |
| ghcr.io credentials | ~/.docker/config.json or docker-credential-osxkeychain | Docker manages its own credential store — it actually saves the ghcr token to a separate Keychain item under service ghcr.io. We just call docker login. |
Per region, the only persistent secrets create-op-node owns are
the two Keychain items above. Everything else is either transient
(prompted, used, forgotten) or lives in its destination system.
Why this exists
Each Opus Populi region is operated independently by a local maintainer — its own Cloudflare account, its own Mac Studio, its own domain. The full bootstrap is a few hours of manual steps across Cloudflare, GitHub, Terraform Cloud, macOS Setup Assistant, Docker Desktop, Tailscale, Ollama, and the node's own Docker Compose stack. Doable from the runbook, but error-prone.
This CLI exists to make that bootstrap foolproof — every prompt validates immediately, every secret is retrieved from a secure source (never echoed, never written to disk in plaintext), and every step has an explicit "what happens next" message. The goal is zero documentation reading required to get a node running.
The CLI itself never holds any credentials beyond the scope of a single command — secrets flow from your macOS Keychain → through the CLI → directly into the destination (GitHub Secrets, Terraform Cloud workspace variables, Mac Studio LaunchAgent). Nothing persists in this process.
Architecture
┌──────────────────────────────────────┐
│ Your laptop │
│ │
npx create-op-node ─┤ ┌─ init ──────► Cloudflare API │
│ ├─ bootstrap GitHub API │
│ ├─ verify Terraform Cloud │
│ └─ region `op` CLI (optional)│
│ (writes a regions repo config) │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ <your-org>/opuspopuli-node-<region> │
│ (created from template by `init`) │
│ │
│ Terraform applies to your CF account│
│ Mac Studio pulls compose + scripts │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ Mac Studio │
│ │
│ `bootstrap` configures the OS + │
│ installs tools + brings up Docker │
│ Compose, pulling ghcr.io images. │
└──────────────────────────────────────┘Status
init — fully wired. Full Phase 1 of the runbook: prompts → Cloudflare 5-scope probe → Terraform Cloud verify → GitHub template clone → 5 repo secrets seeded → branch + prod.tfvars committed → PR opened → pgsodium key generated → (after operator merges PR) Terraform apply polled → Tunnel token retrieved + saved to the macOS Keychain.
bootstrap — fully wired. Phase 2 on the Mac Studio: macOS sanity (auto-restart, disk sleep), Homebrew + tool installs (gh, pnpm, jq, cloudflared, rclone, ollama, docker, tailscale), GitHub + Tailscale signin prompts, pgsodium key + Tunnel token read from the Studio's Keychain (or pasted in once if first run on that machine, then persisted), LaunchAgent written + loaded, ghcr.io login, Ollama models pulled + warmed, region repo located or cloned, docker compose pull && up -d, health-check loop until everything reports (healthy).
verify — scaffold stub. Type-safe argument parsing only; prints a roadmap-style message and exits.
region — fully wired. Scaffolds schema-valid region configs for the OpusPopuli/opuspopuli-regions repo.
Roadmap
- v0.1.0 ✅
initend-to-end +regionscaffolder. - v0.2.0 ✅
bootstrapfully wired on the Studio side. - v0.3.0 —
verifyfully wired: TLS + GraphQL + cosign signature checks. - v0.4.0 — Resend domain + DKIM automation, drift detection, automated backup-restore drill.
Stack (2026)
- Node 22 LTS (native
fetch, native test runner — no polyfills) - TypeScript strict +
verbatimModuleSyntax - ESM-only — no CJS shim
commanderv13 for argument parsing@clack/promptsfor the interactive UI@octokit/restfor GitHubcloudflareofficial SDKexecafor shell-outpicocolorsfor terminal colorszodfor runtime validationvitestfor teststsupfor the single-file ESM buildoxlint(fast, Rust-based) for pre-commit lint; ESLint v9 flat config for the full CI pass
Contributing
This is a young project against the still-stabilizing opuspopuli-node template. PRs welcome; please open an issue first to discuss anything non-trivial.
pnpm install
pnpm dev -- --help # run from source
pnpm test # vitest
pnpm build # tsup → dist/
node dist/cli.js --help # test the built binaryLicense
AGPL-3.0-or-later. The Opus Populi platform code is AGPL-3.0 + dual commercial; this CLI inherits the AGPL-3.0 terms.
Related
OpusPopuli/opuspopuli-node— the per-region deployment template this CLI creates from.OpusPopuli/opuspopuli-regions— declarative region configs;create-op-node regionscaffolds one.OpusPopuli/opuspopuli— the central monorepo that builds + publishesghcr.io/opuspopuli/*images.OpusPopuli/prompt-service— private prompt-template service consumed by every node.
