diff --git a/README.md b/README.md index f242a56..bc65a27 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,17 @@ terraform/ .github/ workflows/ fork-safe terraform fmt + validate gates docs/ + wizard.md agent-first setup wizard (hand to an AI agent; humans only mint/paste creds) + providers/ per-provider agent-first deep dives (cloudflare, github, gcp*, migration) secrets.md secrets store, GitHub App, rotation state.md HCP backend, workspace layout ci.md workflow contract, fork-PR policy - setup.md out-of-band bootstrap runbook + setup.md out-of-band bootstrap runbook (canonical detail behind the wizard) import.md cf-terraforming runbook for adopting existing Cloudflare state ``` +To bootstrap this repo (or a fork of it for another org), hand [`docs/wizard.md`](./docs/wizard.md) to an AI agent: it runs the setup end-to-end and pauses only when a human must sign up, mint a credential, or paste a secret. + For the contracts the repo is built on — secrets, state, CI — see [`CLAUDE.md`](./CLAUDE.md). For the live design decisions table, look there first. ## Contributing diff --git a/docs/README.md b/docs/README.md index a3ec0a2..4f97fde 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,10 @@ The rest is reference, dipped into as needed. ### Setup and onboarding -- [`setup.md`](./setup.md) — one-time bootstrap of HCP, Cloudflare token, GitHub App, local dev. Done once per maintainer's laptop. +- [`wizard.md`](./wizard.md) — **agent-first** setup wizard. Hand it to an AI agent to run the whole bootstrap end-to-end, pausing only for the steps a human must do (signups, minting credentials, pasting secrets). Orchestrates the docs below; includes a "porting to another org" section. +- [`setup.steps.yaml`](./setup.steps.yaml) — machine-readable manifest the wizard walks: each step tagged `AGENT`/`HUMAN` with a `check` command for resume. +- [`setup.md`](./setup.md) — one-time bootstrap of HCP, Cloudflare token, GitHub App, local dev. The human-readable canonical detail the wizard links into. Done once per maintainer's laptop. +- [`providers/`](./providers/) — per-provider agent-first deep dives: [`cloudflare.md`](./providers/cloudflare.md), [`github.md`](./providers/github.md), [`gcp.md`](./providers/gcp.md) *(template — not active)*, [`migration.md`](./providers/migration.md). - [`import.md`](./import.md) — `cf-terraforming` runbook for adopting existing Cloudflare state into Terraform. - [`worktree-workflow.md`](./worktree-workflow.md) — optional `git worktree` convention for maintainers juggling multiple branches. diff --git a/docs/providers/cloudflare.md b/docs/providers/cloudflare.md new file mode 100644 index 0000000..82a5ac7 --- /dev/null +++ b/docs/providers/cloudflare.md @@ -0,0 +1,93 @@ +# Provider: Cloudflare (agent-first) + +Deep dive for [Phase 2](../wizard.md#phase-2--cloudflare) of the setup wizard. What the +agent does, what the human must do, and how to prove it. Canonical bootstrap detail: +[`setup.md#3`](../setup.md#3-cloudflare-api-token). Token scopes + rotation: +[`secrets.md`](../secrets.md#cloudflare-api-token-scopes). + +## What this repo manages via Cloudflare + +The `perish.dev` zone and all its DNS records (Email Routing + GitHub Pages), through the +`cloudflare/cloudflare` v5 provider. State + token live in the HCP `cloudflare` workspace. + +## The actor split + +| Action | Actor | Why | +|---|---|---| +| Mint the scoped API token | **HUMAN** | No API to bootstrap the *first* token; it's a dashboard-only action. | +| Paste token into HCP (sensitive var) | **HUMAN** | The agent must never see the plaintext. | +| Verify var presence + shape | **AGENT** | HCP API, no secret exposure. | +| Prove the token works (`plan`) | **AGENT** | Speculative plan runs in HCP. | +| Read account/zone IDs (for porting) | **AGENT** | Cloudflare API, once a token exists. | + +## HUMAN — mint + paste (`cf-token`) + +The agent emits a handoff block; the human does exactly this: + +1. → **Create Token** → **Custom token**. +2. Permissions for the `perish.dev` zone and its account: + - Zone — DNS — **Edit** + - Zone — Zone Settings — **Edit** + - *(add a row per new resource type before Terraform manages it — Email Routing Rules, + Rulesets, etc. Edit the token later, don't regenerate — see + [`setup.md`](../setup.md#adding-scopes-to-the-cloudflare-token-later).)* +3. **Account Resources**: Include — your account. **Zone Resources**: Include — + Specific zone — `perish.dev`. +4. Copy the token (shown once). +5. HCP → workspace **`cloudflare`** → Variables → add `cloudflare_api_token` as a + **Terraform** variable, mark **Sensitive**, paste. + +> The token needs **Edit** because the persistent workspace *manages* resources. For +> one-time *discovery* during migration, a separate **Read** token is used and thrown +> away — see [`migration.md`](./migration.md) and [`import.md`](../import.md#discovery-token). + +## AGENT — verify + +The value is redacted, so the agent verifies presence + `sensitive == true`, then proves +the token with a plan: + +```sh +HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) +WS_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/cloudflare" \ + -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + +curl -sf "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data[] | select(.attributes.key=="cloudflare_api_token") | .attributes.sensitive==true' \ + && echo "✓ token present + sensitive" + +cd terraform/cloudflare && terraform init && terraform plan # green = token works +``` + +## AGENT — reading account + zone IDs (porting) + +These are **not secrets** (they're `local`s in `terraform/cloudflare/main.tf`: +`account_id = d8a72309…`, `zone_id = 78ff9bdc…`), so reading them doesn't need the +persistent Edit token — and the agent should **not** touch that token (the +never-see-the-plaintext rule holds). Two clean ways to get the IDs when porting: + +- **Human reads them off the dashboard** — account ID is in the URL / right-hand + sidebar; zone ID is on the domain's Overview page. Paste them back to the agent. +- **Agent uses the short-lived *read-only* discovery token** (the same throwaway token + minted for migration — see [`migration.md`](./migration.md)), never the Edit token: + + ```sh + export CF_READ_TOKEN=... # throwaway READ token, deleted right after — NOT the HCP Edit token + + # Account ID + curl -sf "https://api.cloudflare.com/client/v4/accounts" \ + -H "Authorization: Bearer $CF_READ_TOKEN" | jq -r '.result[0].id' + + # Zone ID for the new domain + curl -sf "https://api.cloudflare.com/client/v4/zones?name=" \ + -H "Authorization: Bearer $CF_READ_TOKEN" | jq -r '.result[0].id' + ``` + +Write the results into `terraform/cloudflare/main.tf` `locals`. See the Porting table in +[`wizard.md`](../wizard.md#porting-to-another-organization). + +## Rotation + +Zero-downtime, agent-assisted: human mints the new token and pastes it; agent runs a no-op +`plan` to prove it; only then does the human revoke the old one. Steps in +[`secrets.md`](../secrets.md#cloudflare-api-token). diff --git a/docs/providers/gcp.md b/docs/providers/gcp.md new file mode 100644 index 0000000..1934bd2 --- /dev/null +++ b/docs/providers/gcp.md @@ -0,0 +1,89 @@ +# Provider: GCP (TEMPLATE — not active) + +> **Status: TEMPLATE. No GCP resources are managed by this repo today.** +> +> GCP is **not** a managed provider. There is no `terraform/gcp/` leaf, no `gcp` HCP +> workspace, and no GCP entry in the [locked-decisions table](../../CLAUDE.md). Adopting +> GCP is a **design decision that must be made first** — per `CLAUDE.md`, update the +> decisions table and [`terraform/README.md`](../../terraform/README.md) **before** any +> code or provisioning. This file is the forward-looking template for *when* that day +> comes, written in the same agent-first shape as the live providers. + +## Prerequisite: make the decision (HUMAN + docs) + +`gcp-decision` in [`setup.steps.yaml`](../setup.steps.yaml) stays **red** until GCP is +intentionally adopted (`test -d terraform/gcp`). Before writing anything: + +1. Add a row to `CLAUDE.md`'s locked-decisions table (what GCP is for, auth method, state). +2. Note the new leaf in `terraform/README.md`. +3. Then, and only then, follow the phases below. + +## Recommended auth: Workload Identity Federation (keyless) + +Prefer **WIF** over a downloaded service-account JSON key. WIF lets HCP present a +short-lived OIDC token that GCP exchanges for temporary credentials — **no long-lived key +to store, paste, or rotate.** A service-account key is the fallback only if WIF can't be +arranged; it would live as a sensitive HCP var exactly like the Cloudflare token, with all +the rotation burden that implies. + +## The actor split (projected) + +| Action | Actor | Why | +|---|---|---| +| Create GCP project, link billing | **HUMAN** | Billing consent is browser + payment; irreducibly human. | +| Enable APIs, create SA / WIF pool | **AGENT** | `gcloud` / GCP API, once auth exists. | +| Approve the WIF trust / OAuth consent | **HUMAN** | One browser consent for the federation trust. | +| Paste SA key into HCP *(only if not using WIF)* | **HUMAN** | Agent must never see the key. | +| Create the `gcp` HCP workspace | **AGENT** | HCP API (same as Phase 1). | +| First `plan` | **AGENT** | Speculative run in HCP. | + +## Phases (projected) + +### HUMAN — project + billing +1. Create a GCP project (`gcloud projects create ` is possible, but billing linkage + and the initial org/consent are browser steps). Note the **project ID**. +2. Link a billing account (browser). + +### AGENT — enable APIs + set up auth +```sh +gcloud config set project +gcloud services enable cloudresourcemanager.googleapis.com iam.googleapis.com + +# WIF (preferred): create a workload identity pool + provider trusting HCP's OIDC issuer, +# and a service account with least-privilege roles that HCP may impersonate. +gcloud iam workload-identity-pools create hcp-pool --location=global ... +``` +Exact WIF config depends on how HCP is configured to emit OIDC; capture it in this file +when implemented. Provider block goes in `terraform/gcp/providers.tf`, using +`google`/`google-beta`, with impersonation rather than a key file. + +### AGENT — HCP workspace +Create a `gcp` workspace (working dir `terraform/gcp`, path filter `terraform/gcp/**`, +remote execution, auto-apply **off**) exactly like Phase 1. If using a SA key instead of +WIF, that's where the sensitive var lives. + +### AGENT — first plan +```sh +cd terraform/gcp && terraform init && terraform plan +``` + +## Migrating existing GCP resources + +Same pattern as every other provider: **import, don't recreate**. GCP resources are +adopted with Terraform 1.5+ `import` blocks and either handwritten HCL or +`terraform plan -generate-config-out`. There is no first-party equivalent to +`cf-terraforming`; `gcloud ... list` + import blocks is the path. See +[`migration.md`](./migration.md#gcp). + +## Leaf skeleton (for when it lands) + +``` +terraform/gcp/ + versions.tf # required_providers { google }, cloud { organization=perishdev, workspaces{name="gcp"} } + providers.tf # google provider, WIF impersonation (no key file) + main.tf # project-level locals (project_id, region — non-secret, like cloudflare/main.tf) + *.tf # one file per resource concern +``` + +Nothing here ships until the decision is recorded. YAGNI — the repo deliberately carries +no provider it doesn't use. diff --git a/docs/providers/github.md b/docs/providers/github.md new file mode 100644 index 0000000..8c8c862 --- /dev/null +++ b/docs/providers/github.md @@ -0,0 +1,95 @@ +# Provider: GitHub (agent-first) + +Deep dive for [Phase 3](../wizard.md#phase-3--github) of the setup wizard. Canonical +bootstrap detail: [`setup.md#4`](../setup.md#4-github-app). Auth rationale + rotation: +[`secrets.md`](../secrets.md#github-app-setup). + +## What this repo manages via GitHub + +`perishdev/infra` and `perishdev/perishdev.github.io` repo settings, `main` branch +protection on both, and the `safe-to-plan` label — through the `integrations/github` v6 +provider. State + App credentials live in the HCP `github-org` workspace. + +## Why a GitHub App (not a PAT) + +Apps aren't tied to a user, support fine-grained permissions, and rotate cleanly (multiple +active keys → overlap-then-cutover). A PAT dies with its owner and is coarse-grained. This +is a [locked decision](../../CLAUDE.md). + +## The actor split + +| Action | Actor | Why | +|---|---|---| +| Create the App + generate private key | **HUMAN** | Browser flow; the `.pem` downloads once. | +| Install the App on the org, scoped to repos | **HUMAN** | Browser install + consent. | +| Paste App ID / installation ID / PEM into HCP | **HUMAN** | Agent must never see the PEM. | +| Verify the four vars exist | **AGENT** | HCP API. | +| Prove the App works (`plan`) | **AGENT** | Speculative plan in HCP. | +| Repo/Pages introspection (`gh api`) | **AGENT** | Read-only, agent-owned. | + +## HUMAN — create, install, paste (`gh-app`) + +1. Create the App: `https://github.com/organizations/perishdev/settings/apps/new` + (replace `perishdev` if the org slug differs). Permissions — start narrow: + - **Repository**: Administration (R/W), Contents (R), Metadata (R), Pull requests (R/W). + - **Organization**: Members (R), Administration (R/W). + - "Where can this app be installed": **Only on this account**. +2. Create → note the **App ID**. Generate a **private key** (`.pem` downloads — treat like + a password; include the full `-----BEGIN/END RSA PRIVATE KEY-----` lines when pasting). +3. **Install** on `perishdev`, scoped to `perishdev/infra` + + `perishdev/perishdev.github.io`. Note the **Installation ID** from the install URL + (`.../installations/`). +4. HCP → workspace **`github-org`** → Variables → four **Terraform** vars: + - `github_owner` = `perishdev` *(not sensitive)* + - `github_app_id` *(sensitive)* + - `github_app_installation_id` *(sensitive)* + - `github_app_pem` = full PEM contents *(sensitive)* + +> **Shortcut worth offering the human — the GitHub App manifest flow.** Instead of +> clicking every permission, the agent can generate an App *manifest* (a JSON blob of +> the permissions above) and hand the human a tiny local HTML page that POSTs it to +> `https://github.com/organizations/perishdev/settings/apps/new`. GitHub then shows a +> single "Create GitHub App" confirmation with permissions pre-filled — trimming ~a +> dozen clicks to one. Caveat that keeps us on the manual path by default: the flow +> redirects back with a temporary `code` that must be exchanged +> (`POST /app-manifests/{code}/conversions`) within **one hour** to retrieve the App ID +> and PEM — and that exchange hands the **PEM to whoever runs it**. Letting the agent do +> the exchange would break the never-see-the-plaintext rule, so if you use the manifest +> flow, the *human* performs the code exchange. Details: +> . +> Installation + Installation-ID capture is a browser step either way. + +## AGENT — verify + +```sh +HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) +WS_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/github-org" \ + -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + +curl -sf "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \ + -H "Authorization: Bearer $HCP_TOKEN" | jq -e ' + [.data[].attributes.key] as $k + | ("github_owner"|IN($k[])) and ("github_app_id"|IN($k[])) + and ("github_app_installation_id"|IN($k[])) and ("github_app_pem"|IN($k[]))' \ + && echo "✓ all four github vars present" + +cd terraform/github && terraform init && terraform plan # green = App auth works +``` + +## Watch-outs the agent should flag + +- **PEM newline mangling** on paste is the most common failure — it works for the first + request then breaks on rotation. If `plan` fails with an auth error, re-paste the PEM + carefully (full BEGIN/END lines). +- **The branch-protection status-check name embeds a per-installation VCS ID** + (`Terraform Cloud/perishdev/repo-id-CffUfWW6H1x6Bauq`). If the GitHub↔HCP OAuth + connection is ever rebuilt, that string changes and **every PR is silently blocked** + until `terraform/github/branch_protection.tf` is updated. See [`ci.md`](../ci.md). +- **Pages cert can wedge** (`https_certificate: null` for >15 min). Fix by removing + + re-adding the custom domain via `gh api` — see [`setup.md`](../setup.md#github-pages-cert-stuck-at-null). + +## Rotation + +GitHub Apps support multiple active keys, so rotation is overlap-then-cutover: human +generates a new key + pastes it, agent proves it with a no-op `plan`, then human deletes +the old key. Steps in [`secrets.md`](../secrets.md#github-app-private-key). diff --git a/docs/providers/migration.md b/docs/providers/migration.md new file mode 100644 index 0000000..ab68261 --- /dev/null +++ b/docs/providers/migration.md @@ -0,0 +1,87 @@ +# Migration: adopting existing resources (agent-first) + +Deep dive for [Phase 5](../wizard.md#phase-5--migration-adopt-existing-resources) of the +setup wizard. How to bring resources that **already exist** under Terraform management +without recreating them — across providers. The Cloudflare specifics are canonical in +[`import.md`](../import.md); this file is the cross-provider pattern and the actor split. + +## The universal pattern + +Every migration, whatever the provider, is the same five moves: + +1. **Discover** what exists (read-only credential). +2. **Generate HCL** for each resource. +3. **Emit `import` blocks** (Terraform 1.5+ `import { to = … id = "…" }`). +4. **Plan** — the success signal is *"will be imported"*, and crucially **nothing + *"will be created"*.** +5. **Commit + apply** on merge (human/API-confirmed, per [`ci.md`](../ci.md)). + +> The single check that catches a botched import: `terraform plan` must show the resource +> as **imported**, never **created**. A `create` for something that already exists means +> the resource address or import ID is wrong — fix before opening the PR. + +## The actor split + +| Action | Actor | Why | +|---|---|---| +| Mint a short-lived **read-only** discovery token | **HUMAN** | Dashboard-only; scoped narrower than the HCP edit token. | +| Run the discovery tool / API | **AGENT** | `cf-terraforming`, `gcloud`, `gh` — read-only. | +| Generate HCL + import blocks | **AGENT** | Deterministic transformation. | +| Review/rename generated HCL, drop unwanted resources | **AGENT** (human confirms scope) | Scope decisions may need a human nod. | +| `terraform plan` to confirm imports-only | **AGENT** | Speculative run in HCP. | +| Delete the discovery token when done | **HUMAN** | Revoke in dashboard. | + +## Cloudflare — canonical + +Use Cloudflare's own [`cf-terraforming`](https://github.com/cloudflare/cf-terraforming), +maintained alongside the provider so import IDs and schemas track provider changes. Full +runbook — install, discovery token, `generate`, `import --modern-import-block`, the +supported-resource matrix, and the script/route gaps — is in +[`import.md`](../import.md). Don't duplicate it; the agent should read and follow it. + +What this repo actually imported: the six Email-Routing DNS records on `perish.dev`. Pages +projects and R2 buckets were discovered but **deliberately excluded** (out of scope). That +scope call is the human-confirmed part of "review generated HCL". + +> `cf-terraforming` is for **one-time onboarding**, not steady state, and **not for CI**. +> New resources should be written in Terraform directly from the start. + +## GitHub + +The `integrations/github` provider has no `cf-terraforming` equivalent; adopt existing +repos/settings with `import` blocks + handwritten (or `-generate-config-out`) HCL. + +```sh +# AGENT discovers via gh (read-only) — e.g. repos to adopt: +gh repo list perishdev --json name,visibility,defaultBranchRef + +# Then, per resource, an import block in terraform/github/*.tf: +# import { to = github_repository.infra id = "infra" } +# import { to = github_branch_protection.infra id = "infra:main" } # provider-specific id format +cd terraform/github && terraform plan # expect: imported, not created +``` + +Import ID formats are provider-specific (a repo is its name; branch protection is +`repo:pattern`; a membership is `org:username`). Check the resource's registry docs for +the exact `id` string before writing the block. + +## GCP + +**Only after GCP is an adopted provider** — see [`gcp.md`](./gcp.md). No first-party +generator; discover with `gcloud ... list`, then Terraform 1.5+ `import` blocks with HCL +either handwritten or via `terraform plan -generate-config-out=generated.tf`: + +```sh +gcloud projects list +gcloud storage buckets list # etc., per resource type +# import { to = google_storage_bucket.assets id = "projects/

/buckets/" } +cd terraform/gcp && terraform plan # expect: imported, not created +``` + +## After a clean import plan + +Open a PR (Conventional title). The four required checks run +([`ci.md`](../ci.md)); a maintainer reads the plan (UI or the +[HCP API toolkit](../hcp-api.md)) and confirms the apply on merge. The import executes as a +real run — after which the `import` blocks can be removed in a follow-up (they're one-shot; +the resources are managed by their addresses thereafter). diff --git a/docs/setup.steps.yaml b/docs/setup.steps.yaml new file mode 100644 index 0000000..6550963 --- /dev/null +++ b/docs/setup.steps.yaml @@ -0,0 +1,248 @@ +# Machine-readable manifest for the agent-first setup wizard. +# +# Read this alongside the narrative in docs/wizard.md. An agent walks these steps +# top-to-bottom. On every run it FIRST executes each step's `check` to find where +# setup already is (resume protocol), then resumes at the first red check. +# +# Field contract +# id stable slug, referenced by wizard.md +# phase 0..6, matches docs/wizard.md phases +# provider hcp | cloudflare | github | gcp | repo +# actor AGENT -> agent executes directly +# HUMAN -> agent STOPS, emits the handoff block (see wizard.md), waits +# for "done", then runs `check` before continuing +# title one line +# why what this unblocks (goes in the handoff block's "Why:") +# run AGENT steps: the command(s) the agent runs +# HUMAN steps: the human_action text (browser instructions) +# check command whose success (exit 0) means the step is DONE. Idempotent. +# `null` means "no scriptable check — prove it via the next plan". +# produces the artifact/credential this step yields +# docs canonical section to read for the fine print +# +# RUNNER SETUP — the fields below (org, hcp_api, …) are YAML metadata, NOT shell +# variables. Before running any `check`/`run`, export the two the commands actually +# reference as shell vars: +# export hcp_api=https://app.terraform.io/api/v2 +# export HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) +# The org slug `perishdev` is inlined literally in each command (not $org) — change it +# there, and in hcp_api if self-hosting, when porting. See the "Porting" table in wizard.md. + +org: perishdev +domain: perish.dev +hcp_api: https://app.terraform.io/api/v2 +managed_repos: + - perishdev/infra + - perishdev/perishdev.github.io + +preflight: + - tool: terraform + min: "1.9" + check: "terraform version -json | jq -e '.terraform_version | split(\".\") | ((.[0]|tonumber) > 1) or ((.[0]|tonumber)==1 and (.[1]|tonumber) >= 9)'" + - tool: gh + check: "gh --version" + - tool: jq + check: "jq --version" + - tool: curl + check: "curl --version | head -1" + +steps: + # ── Phase 0 — HCP bootstrap ───────────────────────────────────────────── + - id: hcp-signup + phase: 0 + provider: hcp + actor: HUMAN + title: Create HCP Terraform org and project + why: HCP holds all state and secrets; nothing plans without it. + run: | + 1. Sign up at https://app.terraform.io/ (free tier is enough to start). + 2. Create an organization named exactly `perishdev`. + 3. Inside it, create a project named `infra`. + # NOTE: this check needs $HCP_TOKEN, which is only minted by the NEXT step + # (hcp-login) — an HCP org can't be verified without a token. On a cold run it + # reads red until hcp-login completes; that ordering is intentional, not a bug. + check: 'test -n "$HCP_TOKEN" && curl -sf "$hcp_api/organizations/perishdev" -H "Authorization: Bearer $HCP_TOKEN" | jq -e ".data.id"' + produces: HCP org `perishdev`, project `infra` + docs: docs/setup.md#1-hcp-terraform--organization + + - id: hcp-login + phase: 0 + provider: hcp + actor: HUMAN + title: terraform login (mint the pivot HCP token) + why: This browser login yields the API token that lets the AGENT drive everything after this. + run: | + Run locally: terraform login + Approve in the browser tab it opens. The token is written to + ~/.terraform.d/credentials.tfrc.json. This is the last interactive auth a human does. + check: 'jq -re ".credentials[\"app.terraform.io\"].token" ~/.terraform.d/credentials.tfrc.json' + produces: HCP user API token at ~/.terraform.d/credentials.tfrc.json + docs: docs/setup.md#6-local-development + + - id: hcp-verify + phase: 0 + provider: hcp + actor: AGENT + title: Confirm HCP API reachable + why: Gate — the rest of the wizard is HCP API calls. + run: | + export HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + curl -sf "$hcp_api/organizations/perishdev" -H "Authorization: Bearer $HCP_TOKEN" | jq -e '.data.id' + check: 'curl -sf "$hcp_api/organizations/perishdev" -H "Authorization: Bearer $HCP_TOKEN" | jq -e ".data.id"' + produces: verified API access + docs: docs/state.md#api-access + + # ── Phase 1 — HCP workspaces ──────────────────────────────────────────── + - id: vcs-connect + phase: 1 + provider: hcp + actor: HUMAN + title: Connect GitHub to HCP via OAuth + why: Workspaces need a VCS provider to run speculative plans on PRs. + run: | + In HCP -> org Settings -> Version Control / VCS Providers, connect GitHub via OAuth, + scoped to this repo only. Note the oauth-token id if the agent will script workspace creation. + check: 'curl -sf "$hcp_api/organizations/perishdev/oauth-clients" -H "Authorization: Bearer $HCP_TOKEN" | jq -e ".data | length > 0"' + produces: GitHub<->HCP OAuth connection + docs: docs/setup.md#2-hcp-terraform--workspaces + + - id: workspaces-create + phase: 1 + provider: hcp + actor: AGENT + title: Create cloudflare + github-org workspaces + why: One workspace per leaf; carries working dir, path filter, safety toggles. + run: | + Run the ready-to-run `create_ws` helper in docs/wizard.md (Phase 1) — a POST to + $hcp_api/organizations/perishdev/workspaces per workspace with: + working-directory: terraform/cloudflare (and terraform/github) + file-triggers-enabled: true + trigger-patterns: ["terraform/cloudflare/**"] (and terraform/github/**) + execution-mode: remote + auto-apply: false + speculative-enabled: true # plans on PRs + vcs-repo: identifier perishdev/infra, oauth-token-id from vcs-connect, branch main + Then in each workspace's UI -> Version Control, confirm FORK speculative plans are OFF + (separate toggle, no clean create-time attribute; defaults off). + check: | + for ws in cloudflare github-org; do + curl -sf "$hcp_api/organizations/perishdev/workspaces/$ws" -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data.attributes["auto-apply"]==false' >/dev/null || exit 1 + done + produces: workspaces `cloudflare`, `github-org` + docs: docs/state.md#workspaces + + # ── Phase 2 — Cloudflare ──────────────────────────────────────────────── + - id: cf-token + phase: 2 + provider: cloudflare + actor: HUMAN + title: Mint Cloudflare token and paste into HCP + why: The cloudflare workspace authenticates to Cloudflare with this token. + run: | + 1. https://dash.cloudflare.com/profile/api-tokens -> Create Token -> Custom token. + 2. Permissions: Zone·DNS·Edit, Zone·Zone Settings·Edit (add more as resources grow). + 3. Account Resources: your account. Zone Resources: Specific zone -> perish.dev. + 4. Copy the token (shown once). + 5. HCP -> workspace `cloudflare` -> Variables -> add `cloudflare_api_token` + as a TERRAFORM variable, mark SENSITIVE, paste the value. + check: | + WS_ID=$(curl -sf "$hcp_api/organizations/perishdev/workspaces/cloudflare" -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + curl -sf "$hcp_api/workspaces/$WS_ID/vars" -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data[] | select(.attributes.key=="cloudflare_api_token") | .attributes.sensitive==true' + produces: sensitive var `cloudflare_api_token` + docs: docs/providers/cloudflare.md + + # ── Phase 3 — GitHub App ──────────────────────────────────────────────── + - id: gh-app + phase: 3 + provider: github + actor: HUMAN + title: Create + install GitHub App, paste creds into HCP + why: The github-org workspace authenticates as a GitHub App (not a PAT). + run: | + 1. Create the App under perishdev: https://github.com/organizations/perishdev/settings/apps/new + Permissions: Repo Administration(RW), Contents(R), Metadata(R), Pull requests(RW); + Org Members(R), Administration(RW). Install: only on this account. + 2. Note the App ID. Generate a private key (.pem). + 3. Install on perishdev scoped to perishdev/infra + perishdev/perishdev.github.io; + note the Installation ID from the install URL. + 4. HCP -> workspace `github-org` -> Variables, add as TERRAFORM vars: + github_owner=perishdev (not sensitive), + github_app_id (sensitive), github_app_installation_id (sensitive), + github_app_pem = full PEM incl. BEGIN/END lines (sensitive). + check: | + WS_ID=$(curl -sf "$hcp_api/organizations/perishdev/workspaces/github-org" -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + curl -sf "$hcp_api/workspaces/$WS_ID/vars" -H "Authorization: Bearer $HCP_TOKEN" | jq -e ' + [.data[].attributes.key] as $k + | ("github_owner"|IN($k[])) and ("github_app_id"|IN($k[])) + and ("github_app_installation_id"|IN($k[])) and ("github_app_pem"|IN($k[]))' + produces: sensitive vars github_app_id / _installation_id / _pem, plus github_owner + docs: docs/providers/github.md + + # ── Phase 4 — First plan ──────────────────────────────────────────────── + - id: plan-cloudflare + phase: 4 + provider: cloudflare + actor: AGENT + title: init + speculative plan (terraform/cloudflare) + why: Proves the Cloudflare token end-to-end. + run: "cd terraform/cloudflare && terraform init && terraform plan" + check: "cd terraform/cloudflare && terraform init -input=false >/dev/null && terraform plan -input=false >/dev/null" + produces: green speculative plan for cloudflare + docs: docs/hcp-api.md + + - id: plan-github + phase: 4 + provider: github + actor: AGENT + title: init + speculative plan (terraform/github) + why: Proves the GitHub App credentials end-to-end. + run: "cd terraform/github && terraform init && terraform plan" + check: "cd terraform/github && terraform init -input=false >/dev/null && terraform plan -input=false >/dev/null" + produces: green speculative plan for github-org + docs: docs/hcp-api.md + + # ── Phase 5 — Migration (only if resources pre-exist) ─────────────────── + - id: migrate-discovery-token + phase: 5 + provider: cloudflare + actor: HUMAN + title: Mint short-lived read-only Cloudflare discovery token + why: cf-terraforming reads existing resources; use a throwaway READ token, not the HCP edit token. + run: | + https://dash.cloudflare.com/profile/api-tokens -> Custom token -> READ scopes on each + resource type to discover (DNS·Read, etc.), zone perish.dev, TTL a few hours. Copy the value. + check: ~ # YAML null — ephemeral credential, no scriptable check (see header contract) + produces: throwaway read token (deleted after import) + docs: docs/import.md#discovery-token + + - id: migrate-import + phase: 5 + provider: cloudflare + actor: AGENT + title: Generate HCL + import blocks, verify plan imports (not creates) + why: Bring existing zone/records under management without recreating them. + run: | + Follow docs/import.md: cf-terraforming generate + import (--modern-import-block) + into terraform/cloudflare/generated.tf, then terraform plan. + # One plan, captured once. Success = at least one import AND zero creates. + # (If a change legitimately adds a brand-new resource alongside the import, review + # the plan by hand — this check intentionally treats any 'will be created' as not-done.) + check: "cd terraform/cloudflare && terraform plan -input=false -no-color >/tmp/wizard-import.plan 2>&1 && grep -q 'will be imported' /tmp/wizard-import.plan && ! grep -q 'will be created' /tmp/wizard-import.plan" + produces: generated.tf with import blocks; plan shows imports only + docs: docs/providers/migration.md + + # ── Phase 6 — GCP (template only; NOT active) ─────────────────────────── + - id: gcp-decision + phase: 6 + provider: gcp + actor: HUMAN + title: (Template) Decide to adopt GCP — update design docs first + why: GCP is not a managed provider yet; adopting it changes a locked decision. + run: | + GCP is out of scope today. To adopt it: update CLAUDE.md's locked-decisions table + and terraform/README.md, THEN follow docs/providers/gcp.md. Do not provision ahead of the decision. + check: "test -d terraform/gcp" # red until GCP is intentionally adopted + produces: (none until adopted) + docs: docs/providers/gcp.md diff --git a/docs/wizard.md b/docs/wizard.md new file mode 100644 index 0000000..dc8659f --- /dev/null +++ b/docs/wizard.md @@ -0,0 +1,319 @@ +# Setup wizard (agent-first) + +An **agent-runnable** bootstrap for this repo. Written for an AI agent (Claude Code, +opencode, codex, …) to execute end-to-end, pausing only for the steps a human +irreducibly must do. + +If you are a human: hand this file to your agent and say *"run the setup wizard in +`docs/wizard.md`."* You'll be pinged when a browser click or a credential is needed. + +> This wizard **orchestrates**; it does not duplicate. The authoritative per-step +> detail lives in [`setup.md`](./setup.md) (bootstrap) and [`import.md`](./import.md) +> (Cloudflare migration). The wizard adds the *actor split* and the *resume protocol* +> on top. When a step says "see `setup.md#3`", read that section for the fine print. + +--- + +## The one idea + +**The human is the browser and the keyholder. Everything scriptable is the agent's.** + +There is exactly one thing a human must do that an agent cannot: sit in front of a +browser, sign up for a SaaS, and mint the first credential. After that first +credential exists (an HCP token from `terraform login`), the agent can create +workspaces, set variables, read plans, and confirm applies **over the HCP API** — +no more clicking. + +So the wizard shrinks the human surface to three kinds of action: + +1. **Sign up** for a service (browser-only). +2. **Mint a credential** in a dashboard (browser-only — no API to bootstrap the first token). +3. **Paste a secret** into HCP (browser-only, because the agent must never see the plaintext). + +Everything else — verifying, creating workspaces, importing existing resources, +running the first plan — is the agent's job. + +--- + +## Actors + +Every step is tagged with who performs it: + +| Tag | Meaning | Agent behaviour | +|---|---|---| +| **`AGENT`** | The agent runs it (shell, `gh`, `terraform`, HCP API). | Execute. Verify with the step's `check`. Continue on green. | +| **`HUMAN`** | Irreducibly human (signup, dashboard, secret paste). | **Stop.** Emit the handoff block below. Wait for the human to reply `done`. Then run the `check` to confirm before continuing. | + +### The handoff block + +When a step is `HUMAN`, the agent must **not** guess or fake it. It stops and prints a +block in exactly this shape, then waits: + +``` +┌─ HUMAN ACTION NEEDED ───────────────────────────── +│ Step: — +│ Why: <one line — what this unblocks> +│ Do this: +│ 1. <precise, copy-pasteable instruction, with URL> +│ 2. … +│ When done, reply "done" and I'll verify. +└─────────────────────────────────────────────────── +``` + +After the human replies, the agent runs the step's `check` (see +[`setup.steps.yaml`](./setup.steps.yaml)). If the check fails, the agent re-emits the +handoff with what it observed — it never silently proceeds past a red check. + +--- + +## Resume protocol + +The wizard is **idempotent and resumable**. On every run, before doing anything, the +agent walks [`setup.steps.yaml`](./setup.steps.yaml) top to bottom and runs each +step's `check` to discover *where setup already is*. It resumes at the first step +whose check is red. A finished repo produces all-green and the agent reports "already +set up, nothing to do." + +```text +for step in setup.steps.yaml: + if run(step.check) is green: skip, print "✓ {step.id}" + else: this is where we resume +``` + +Never assume state from memory or a previous session — always re-check. + +--- + +## Preflight (AGENT) + +Confirm the agent's toolbox before starting. All of these are the agent's to install +if missing (via `brew`, the platform package manager, etc.) — none require a human. + +```sh +terraform version # ≥ 1.9 — provisioning + import blocks +gh --version # GitHub CLI — repo ops, Pages, App install checks +jq --version # JSON wrangling for HCP/Cloudflare/GitHub APIs +curl --version # HCP + Cloudflare REST +``` + +Then detect the credential the whole wizard pivots on: + +```sh +# Is there already an HCP token on this machine? +jq -re '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json \ + && echo "HCP token present — agent can drive the API" \ + || echo "No HCP token yet — first HUMAN step will mint one" +``` + +--- + +## Phases + +Run in order. Each phase links to the provider deep-dive under +[`providers/`](./providers/) and to the canonical section of `setup.md`. + +| # | Phase | Actors | Deep dive | +|---|---|---|---| +| 0 | **HCP bootstrap** — sign up, `terraform login`, get the pivot token | `HUMAN` then `AGENT` | [`setup.md#1`](./setup.md#1-hcp-terraform--organization), [`state.md`](./state.md) | +| 1 | **HCP workspaces** — create `cloudflare` + `github-org`, set VCS + safety toggles | `AGENT` (API) with `HUMAN` VCS OAuth | [`setup.md#2`](./setup.md#2-hcp-terraform--workspaces) | +| 2 | **Cloudflare** — mint scoped token, paste into HCP, verify | `HUMAN` mint/paste, `AGENT` verify | [`providers/cloudflare.md`](./providers/cloudflare.md) | +| 3 | **GitHub** — create + install the GitHub App, paste creds into HCP | `HUMAN` create/install/paste, `AGENT` verify | [`providers/github.md`](./providers/github.md) | +| 4 | **First plan** — `init` + speculative `plan` per leaf, read via API | `AGENT` | [`setup.md#6`](./setup.md#6-local-development) | +| 5 | **Migration** — adopt existing resources with import blocks (no re-create) | `AGENT` run, `HUMAN` mint discovery token | [`providers/migration.md`](./providers/migration.md) | +| 6 | **GCP** *(optional, template only)* — not provisioned today | design decision first | [`providers/gcp.md`](./providers/gcp.md) | + +### Phase 0 — HCP bootstrap + +The only unavoidable cold-start. Produces the token that lets the agent script the rest. + +- **`HUMAN` — hcp-signup.** Sign up at <https://app.terraform.io/>, create org + **`perishdev`**, create project **`infra`**. (Org name must equal the `organization` + field in every `terraform/*/versions.tf` — it does: `perishdev`.) +- **`HUMAN` — hcp-login.** Run `terraform login` locally (it opens a browser and asks + HCP for a user API token). This is `HUMAN` because it needs an interactive browser — + but it's the *last* time a human touches the terminal for auth. Token lands in + `~/.terraform.d/credentials.tfrc.json`. +- **`AGENT` — hcp-verify.** From here the agent owns the HCP API: + ```sh + HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + curl -sf "https://app.terraform.io/api/v2/organizations/perishdev" \ + -H "Authorization: Bearer $HCP_TOKEN" | jq -e '.data.id' \ + && echo "✓ HCP org reachable" + ``` + +### Phase 1 — HCP workspaces + +Two workspaces, one per leaf. The agent creates them via API; a human does the one-time +GitHub↔HCP OAuth connection (browser). + +| Workspace | Leaf | VCS working dir | Path filter | Auto-apply | +|---|---|---|---|---| +| `cloudflare` | `terraform/cloudflare/` | `terraform/cloudflare` | `terraform/cloudflare/**` | **no** | +| `github-org` | `terraform/github/` | `terraform/github` | `terraform/github/**` | **no** | + +- **`HUMAN` — vcs-connect.** In HCP → org Settings → VCS Providers, connect GitHub via + OAuth, scoped to this repo only. (Browser-only OAuth handshake.) +- **`AGENT` — workspaces-create.** Create both workspaces via the HCP API with the + correct working directory, path-based run triggering, remote execution, and + auto-apply **off**. The safety toggles that matter — and *why each one bites if wrong* + — are spelled out in [`setup.md#2`](./setup.md#2-hcp-terraform--workspaces): speculative + plans **on**, path-scoped triggers, and — set in the UI — **fork** speculative plans + **off**. Ready-to-run (idempotent — a 422 means the workspace already exists, treat as + ✓): + + ```sh + export HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + ORG=perishdev + REPO=perishdev/infra # the repo HCP watches via VCS + + # oauth-token-id from the VCS connection created in the vcs-connect step + OAUTH_TOKEN_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/$ORG/oauth-clients" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -r '.data[0].relationships["oauth-tokens"].data[0].id // empty') + [ -n "$OAUTH_TOKEN_ID" ] || { echo "No VCS oauth-token found — finish the vcs-connect step first." >&2; return 1; } + + # Build the JSON:API payload with `jq -n` (no heredoc; safe to copy-paste, correct quoting) + create_ws () { # $1 = workspace name $2 = working directory + jq -n --arg name "$1" --arg dir "$2" --arg repo "$REPO" --arg tok "$OAUTH_TOKEN_ID" ' + {data:{type:"workspaces",attributes:{ + name:$name, "working-directory":$dir, "execution-mode":"remote", + "auto-apply":false, "speculative-enabled":true, "file-triggers-enabled":true, + "trigger-patterns":[$dir+"/**"], "queue-all-runs":false, "global-remote-state":false, + "vcs-repo":{identifier:$repo, "oauth-token-id":$tok, branch:"main"}}}}' \ + | curl -sf -X POST "https://app.terraform.io/api/v2/organizations/$ORG/workspaces" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + -H "Content-Type: application/vnd.api+json" -d @- \ + | jq -r '"✓ " + .data.attributes.name + " created"' + } + + create_ws cloudflare terraform/cloudflare + create_ws github-org terraform/github + ``` + + > `trigger-patterns` (glob) requires `file-triggers-enabled: true` — that pair is the + > path-scoping toggle. `speculative-enabled: true` is the master switch for plans on PRs. + > The **fork** speculative-plan toggle is *separate* and has no clean create-time + > attribute — confirm it's **off** in the workspace's UI → Settings → Version Control + > (it defaults off; the label-gated flow in [`ci.md`](./ci.md) replaces it for forks). + + Verify: + ```sh + for ws in cloudflare github-org; do + curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/$ws" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data.attributes["auto-apply"] == false' >/dev/null \ + && echo "✓ $ws exists, auto-apply off" + done + ``` + +> The end state of Phases 0–1 (HCP-as-clickops today) is tracked for future +> Terraform-ification in [Issue #8](https://github.com/perishdev/infra/issues/8). +> Until then these steps are API calls, not `.tf` files. + +### Phase 2 — Cloudflare + +The agent cannot mint a scoped Cloudflare token from nothing (bootstrap problem), and +must never see the plaintext. So minting and pasting are `HUMAN`; verifying is `AGENT`. +Full walkthrough: [`providers/cloudflare.md`](./providers/cloudflare.md). + +- **`HUMAN` — cf-token.** Mint a Custom token (Zone·DNS·Edit, Zone·Settings·Edit for + `perish.dev`), paste it into HCP → `cloudflare` workspace → var `cloudflare_api_token` + (**Terraform** var, **Sensitive**). See [`setup.md#3`](./setup.md#3-cloudflare-api-token). +- **`AGENT` — cf-verify.** The agent can't read the redacted value, so it verifies + *presence and shape*, then proves the token works by running a plan (Phase 4): + ```sh + HCP_TOKEN=$(jq -r '.credentials["app.terraform.io"].token' ~/.terraform.d/credentials.tfrc.json) + WS_ID=$(curl -sf "https://app.terraform.io/api/v2/organizations/perishdev/workspaces/cloudflare" \ + -H "Authorization: Bearer $HCP_TOKEN" | jq -r '.data.id') + curl -sf "https://app.terraform.io/api/v2/workspaces/$WS_ID/vars" \ + -H "Authorization: Bearer $HCP_TOKEN" \ + | jq -e '.data[] | select(.attributes.key=="cloudflare_api_token") | .attributes.sensitive==true' \ + && echo "✓ cloudflare_api_token present and sensitive" + ``` + +### Phase 3 — GitHub + +The `github-org` workspace authenticates as a **GitHub App**, not a PAT. App creation +and installation are browser flows; the three credentials get pasted into HCP. Full +walkthrough incl. the manifest-flow shortcut: [`providers/github.md`](./providers/github.md). + +- **`HUMAN` — gh-app.** Create the App under `perishdev`, install it scoped to + `perishdev/infra` + `perishdev/perishdev.github.io`, paste `github_app_id`, + `github_app_installation_id`, `github_app_pem` (full PEM incl. BEGIN/END lines) into + the `github-org` workspace as sensitive vars. See [`setup.md#4`](./setup.md#4-github-app). +- **`AGENT` — gh-verify.** Confirm all four vars exist (`github_owner` + + the three App creds), then prove them with a plan in Phase 4. + +### Phase 4 — First plan (AGENT) + +Now the agent proves every credential end-to-end. For each leaf: + +```sh +cd terraform/cloudflare # then repeat for terraform/github +terraform init # authenticates to HCP automatically +terraform plan # speculative run in HCP; output streams back +``` + +A VCS-connected workspace **allows `plan` from the CLI but blocks `apply`** — that gate +is intentional (apply happens on merge to `main`, confirmed by a human or an +authenticated API call). Read the plan without the UI via the HCP API toolkit in +[`hcp-api.md`](./hcp-api.md). Green plans on both leaves = credentials proven. + +### Phase 5 — Migration (adopt existing resources) + +If the domain / repos already exist (they do for `perish.dev`), **import** them so +Terraform manages them without recreating. The agent runs `cf-terraforming` and writes +`import` blocks; a human mints the short-lived read-only discovery token. Canonical +runbook: [`import.md`](./import.md); cross-provider generalisation: +[`providers/migration.md`](./providers/migration.md). + +The success signal is unambiguous: `terraform plan` shows every existing resource as +**"will import"**, nothing as **"will create"**. + +### Phase 6 — GCP (template, optional) + +**Not provisioned today.** GCP is not a managed provider in this repo. Adopting it is a +**locked-design-decision change** — update `CLAUDE.md` and `terraform/README.md` first, +then follow the same actor split. Template: [`providers/gcp.md`](./providers/gcp.md). + +--- + +## Done signal + +Setup is complete when the agent reports: + +- ✓ HCP org `perishdev` + project `infra` reachable via API. +- ✓ Workspaces `cloudflare` and `github-org` exist, VCS-linked, auto-apply off, fork + speculative plans off. +- ✓ All sensitive vars present (`cloudflare_api_token`; `github_app_id`, + `github_app_installation_id`, `github_app_pem`). +- ✓ `terraform plan` green on both leaves. +- ✓ Existing resources imported (plan shows imports, no creates) — if migrating. + +At that point day-to-day work follows [`CONTRIBUTING.md`](../CONTRIBUTING.md). + +--- + +## Porting to another organization + +This wizard is written concretely for **`perishdev` / `perish.dev`**. To reuse it as +groundwork for a different org, change these — and nothing else: + +| Token in the docs | Meaning | Where it appears | +|---|---|---| +| `perishdev` | HCP org **and** GitHub org slug | `terraform/*/versions.tf` (`organization`), every HCP API URL, App install URL, workspace names' `-org` suffix | +| `perish.dev` | apex domain | `terraform/cloudflare/main.tf` (`local.domain`), Cloudflare token/zone scoping | +| `d8a72309…` | Cloudflare **account ID** | `terraform/cloudflare/main.tf` (`local.account_id`) | +| `78ff9bdc…` | Cloudflare **zone ID** | `terraform/cloudflare/main.tf` (`local.zone_id`), migration commands | +| `perishdev/infra`, `perishdev/perishdev.github.io` | managed repos | `terraform/github/repos.tf`, App install scope | +| `repo-id-CffUfWW6H1x6Bauq` | per-installation HCP status-check ID | `terraform/github/branch_protection.tf` — **regenerated by HCP**, not chosen; see [`ci.md`](./ci.md) | + +Porting checklist for the agent: + +1. `grep -rn 'perishdev\|perish\.dev\|d8a72309\|78ff9bdc' terraform/ docs/` to enumerate every literal. +2. Replace the org/domain/repo literals with the new org's values. +3. Re-derive the two Cloudflare IDs from the new account (`AGENT` can read them via the + Cloudflare API once the new token exists; see [`providers/cloudflare.md`](./providers/cloudflare.md)). +4. Leave `repo-id-…` alone — it is emitted by HCP when the new VCS connection is made, + and copied into `branch_protection.tf` *after* Phase 1. Never invent it. +5. Re-run the wizard from Phase 0. The resume protocol handles the rest.