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 — sealed-box encrypted with the repo's public key and PUT under your own token, never shelled out togh. Your GitHub token therefore needs Contents: write + Actions Secrets: write + Pull requests: write (fine-grained), orreposcope (classic). - 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.
Doing local dev / testing first (no public exposure yet)? See Local-only mode —
init --local-onlyskips the Cloudflare/TFC/PR phases entirely.
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, verifies the cosign signature of every ghcr.io/opuspopuli/* image before pulling (fail-closed — --skip-signature-check to bypass), and finally docker compose --profile public pull && up -d brings the whole stack online. Health-check loop waits until all containers are (healthy).
When you run bootstrap interactively (no -y, no --analysis-model flag),
you'll get a curated picker that pre-selects a model based on the
node's detected unified memory. Tiers are conservative — the LLM shares
memory with ~22 containers + Postgres, so they size on total footprint,
not param count:
qwen3.6:35b-a3b— 35B MoE (~3B active, fast on Apple Silicon), ~24 GB footprint. Pre-selected on 64 GB+ nodes. Best Spanish + multilingual quality.qwen3:14b— ~9 GB footprint. Pre-selected on 32–64 GB nodes.qwen2.5:7b— ~5 GB footprint. Pre-selected on 16–32 GB nodes, and the default for non-interactive-yruns (see below).qwen2.5:3b— ~2–3 GB footprint. Pre-selected on ≤ 16 GB nodes (Mac Mini).Other…— any Ollama model name you specify.
(If memory detection fails, the picker falls back to pre-selecting the
conservative scripted default, qwen2.5:7b.)
The picker is Qwen-only because the Opus Populi platform serves Spanish-speaking civic users and Qwen has the strongest Spanish (and broader multilingual) capability of the open-weight models in this size class. Pick "Other…" for non-Qwen models.
For non-interactive runs (-y) or scripted invocations, the LLM
defaults to qwen2.5:7b (small, fast) — pass --analysis-model to override.
The flag is named for its lane, like every other model flag; it writes
LLM_ANALYSIS_MODEL to the node's .env. Nodes bootstrapped before this
carry LLM_MODEL, which the backend still reads and verify still
understands.
# Just swap the LLM, keep the default embedding model:
npx create-op-node bootstrap --region us-ca --analysis-model llama3.3:70b
# Override both:
npx create-op-node bootstrap \
--region us-ca \
--analysis-model llama3.3:70b \
--embedding-model mxbai-embed-largeInference is two jobs. Analysis — proposition analysis, minutes summaries, bios, RAG — is accuracy-bound: the task is copying a passage verbatim and declining when the source does not support a claim. Ingestion — structural analysis, civics extraction, detail crawling, PDF extraction — is throughput-bound structured extraction across far more documents, where reliable JSON matters and verbatim fidelity does not.
Measured (opuspopuli roadmap §6.4): olmo-3.1:32b-instruct anchors claims at
57% against a 7B's 28% and abstains correctly where the 7B fabricated a fiscal
impact — at ~550 s/measure and 21.4 GB. olmo-3:7b-instruct returned 10/10
valid JSON with zero fabricated figures at ~52 s and ~4.5 GB. Pinning both
jobs to one model spends the large model's wall clock over the larger corpus,
where it buys nothing.
npx create-op-node bootstrap \
--region us-ca \
--analysis-model olmo-3.1:32b-instruct \
--ingestion-model olmo-3:7b-instruct--ingestion-model is opt-in, unlike --vision-model. OCR genuinely
cannot use LLM_MODEL, so a separate pin is mandatory there; ingestion works
perfectly well on the analysis model and simply costs more than it needs to.
Omit the flag and nothing changes: no second model is pulled, no
LLM_INGESTION_MODEL is written, and the backend falls back to LLM_MODEL
exactly as before.
Two things to know before splitting:
- It pulls and warms a second model. Both resident on one host is what
degrades throughput under memory pressure — 21.4 GB + 4.5 GB together is the
condition that produced the 550 s figure, where throughput fell from 2m17s
to 11m46s per measure within a single run.
OLLAMA_MAX_LOADED_MODELSand keep-alive matter more than the pins do while the two share a machine. - The ingestion figures above were measured on the analysis prompt.
getStructuralAnalysisPromptandgetCivicsExtractionPrompthave no eval leg yet, so the choice is a grounded inference rather than a measurement.
The chosen models flow two places:
- Ollama: bootstrap pulls + warms them so the daemon has them
resident before the stack comes up. The embedding model pulls first
(957 MB — fast feedback relative to the LLM); the LLM pulls second
(can be tens of GB). The embedding model is pulled only when
--embeddings-provider ollamais in effect, which is the default. - The node
.env: bootstrap writesLLM_MODEL,EMBEDDINGS_OLLAMA_MODEL, andEMBEDDINGS_PROVIDERinto a managed block of the region repo's.env, which docker compose auto-loads. This is the single source of truth — every service (and every future partial recreate) resolves${LLM_MODEL:-…}to the same value, regardless of shell/launchd env. Model config is deliberately not exported via the LaunchAgent: alaunchctl setenvvalue would shadow.envat interpolation time (shell env >.env), reintroducing the exact drift the.envprevents.
Managed block, not full ownership. bootstrap owns only the region between
# >>> op-node managed >>>and# <<< op-node managed <<<in your.env— anything you hand-add outside it is preserved. On first run against a pre-existing.env, your existing model values are adopted into the block rather than overwritten; the block is only rewritten when you explicitly (re)select a model. Secrets never go in.env— they stay in the macOS Keychain and are hydrated bybin/op-compose.
--embeddings-providerpicks where embeddings run. The default isollama— the host daemon, with--embedding-model(nomic-embed-text-v2-moe, 957 MB). The value is written to.envasEMBEDDINGS_OLLAMA_MODEL, the key the backend reads, as the single source of truth. Pass--embeddings-provider xenovato compute embeddings in-process instead: no model pull, but English-first — nomic answers 8 of 8 Spanish queries against the real ballot-measure corpus clearing the next-best answer by 0.223, where the in-process model clears by 0.061.Use
nomic-embed-text-v2-moe, never the barenomic-embed-text: that name is v1.5, a different model that scores 0/14 on the same corpus and fails silently. Whatever you pick must produce 768-dimension vectors, the width of the pgvector columns; a mismatch refuses to boot. Seedocs/provider-pattern.md.
Template contract: for
LLM_MODELto actually change the running model, the region repo'sdocker-compose-prod.ymlmust use${LLM_MODEL:-…}on the knowledge service'senvironment:block. The currentopuspopuli-nodetemplate does; a fork that hardcodes the value would ignore the.envsilently.
For RAM sizing, the Docker resources doc has a tier table: a 7B-class model needs ~5 GB, the 35B-a3b MoE ~24 GB. Allocate Docker the remainder of unified memory.
To switch models post-bootstrap, either re-run bootstrap with the new
--analysis-model (rewrites the managed block) or edit LLM_MODEL in the
managed block of your .env directly, then ./bin/op-compose -f docker-compose-prod.yml up -d to pick up the change.
For local dev / testing — frontend on your laptop, backend on the Studio over Tailscale, no public exposure. The flow mirrors production-init → production-bootstrap, just with the Cloudflare half cut out on both sides.
On the laptop:
npx create-op-node init --region us-ca --local-onlyThis creates the region repo from the OpusPopuli/opuspopuli-node
template (private, no public exposure), generates the pgsodium master
key, and saves it to Keychain. No Cloudflare, no Terraform Cloud, no PR
to merge.
On the Studio:
npx create-op-node bootstrap --region us-ca --local-onlyDifferences from production bootstrap:
- No Tunnel token required.
init --local-onlyskipped that phase. 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 expose publicly, re-run both commands without
--local-only. Same region repo, same pgsodium key — promotes
cleanly to the 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.
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-data
# Even more: ALSO drop all docker images (forces re-pull on next bootstrap).
# Use this for the "wipe everything and start over" iteration loop while
# debugging a fresh bootstrap. Implies --wipe-data.
npx create-op-node reset --region us-ca --wipe-imagesnpx create-op-node verify --domain your-domain.exampleOff-LAN health probe of a live node, runnable from anywhere with internet access. Six 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).- Ollama model presence (optional, node-local) — asserts the configured
LLM_MODELis actually pulled into the local Ollama, failing loudly with the exactollama pull <model>remedy if not. This catches the config↔runtime drift that otherwise 404s at inference time (a node set to a model that was never downloaded). The model is read from--analysis-modelor, when omitted, the node's.env(--repo-dir, default cwd) — so runningverifyon the node needs no flags. Skipped when no model resolves, which keeps an off-LANverify --domain …from tripping it. The embedding model is asserted only under--embeddings-provider ollama(xenova computes embeddings in-process). - 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.
Run it on the node (from the region repo dir) to also catch model
drift with zero extra flags — the Ollama phase reads LLM_MODEL from the
.env bootstrap wrote:
cd ~/Development/opuspopuli-node-us-ca
npx create-op-node verify --domain yournode.example.org
# or pin the model explicitly from anywhere:
npx create-op-node verify --domain yournode.example.org --analysis-model qwen3.6:35b-a3bA 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.
┌ 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-alameda
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.
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.
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 -wEverything 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.
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.
┌──────────────────────────────────────┐
│ 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. │
└──────────────────────────────────────┘
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, cosign, 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, cosign signature verification of the ghcr.io/opuspopuli/* images (fail-closed; --skip-signature-check bypasses, --certificate-identity-regexp overrides the pinned identity), 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.
- 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.
- 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 build- ESLint v9 flat config for linting: a base pass (
pnpm lint) plus a separate SonarJS pass (pnpm lint:sonar) for cognitive-complexity and hot-spot rules, mirroring the@opuspopuli/regionssetup
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 lint # eslint base pass
pnpm test # vitest
pnpm build # tsup → dist/
node dist/cli.js --help # test the built binaryReleases are automated via release-please:
- Write Conventional Commits.
feat:→ minor bump (or major onBREAKING CHANGE:);fix:→ patch;docs:/refactor:/perf:show up in CHANGELOG;chore:/test:/build:/ci:are hidden. - Merge PRs to
mainas normal. - release-please opens a
chore(main): release X.Y.ZPR that bumpspackage.json+src/cli.ts'sVERSIONconstant + adds a CHANGELOG section. It updates the PR continuously as more commits land. - Merge the release PR. That creates the
vX.Y.Ztag + a GitHub Release.publish.ymlfires on the tag push and runsnpm publish --provenance --access public.
No manual version bumps, no manual tagging. To trigger a release with no functional changes (re-publishing after a CI fix, etc.), push a commit with chore: trigger release — release-please will skip it but still update the PR.
GitHub deliberately doesn't fire workflows on branches or tags pushed by
GITHUB_TOKEN. Without a PAT, two things bite every release:
- The release PR opens but its CI run never starts (sits in Expected — Waiting for status to be reported). You can't merge.
- After you eventually merge,
publish.yml's tag-push trigger doesn't fire either, so npm never gets the new version.
Both go away when release-please uses a PAT instead. One-time setup:
- Create a fine-grained PAT at
github.com/settings/personal-access-tokens/new:
- Resource owner: OpusPopuli (not your personal account — fine-grained PATs are scoped at creation and can't be moved)
- Expiration: your call (90 days for safety; you'll be reminded when it's near expiry)
- Repository access: Only select repositories →
create-op-node - Repository permissions:
- Contents: Read and write
- Pull requests: Read and write
- Workflows: Read and write (release-please updates the
version line in
release-please.yml's manifest output; without this permission the action fails to write)
- Add it as a repo secret: repo → Settings → Secrets and
variables → Actions → New repository secret →
RELEASE_PLEASE_TOKEN, paste the PAT value. - Done. Next release-please run uses the PAT, CI runs normally on
the release PR, and
publish.ymlfires automatically on the tag.
The workflow falls back to GITHUB_TOKEN when the secret isn't
present, so it doesn't break in repos that haven't done the PAT setup
yet — it just goes back to needing the manual workarounds.
AGPL-3.0-or-later. The Opus Populi platform code is AGPL-3.0 + dual commercial; this CLI inherits the AGPL-3.0 terms.
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.