From 6a977d2485800019305bcb6147a441ddc5457238 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:58:59 +0100
Subject: [PATCH 01/26] feat(hosting): add the hosting provider seam and its
cloudflare adapter
The seam `abcd site setup` routes a rendered site through
(itd-2609061543533170, spc-2609212141407459 scope 2): an Adapter carries the
repository half as data (the deploy secrets, the deploy step, the host
configuration file) and Connect returns the host half (inspect, create,
route, address).
One provider ships: an assets-only Cloudflare Worker, the host abcd's own
site uses. Its calls are written against the published v4 API reference
and exercised only against cloudflaretest, an in-process fake that can fail
every route it answers. The token lives inside the connected client, is
scrubbed from every host message before it can reach an error, and
redirects are refused rather than followed.
Assisted-by: Claude:claude-opus-5-5
---
.../adapter/hosting/cloudflare/cloudflare.go | 419 ++++++++++++++++++
.../hosting/cloudflare/cloudflare_test.go | 270 +++++++++++
.../hosting/cloudflare/cloudflaretest/fake.go | 260 +++++++++++
internal/adapter/hosting/hosting.go | 75 ++++
4 files changed, 1024 insertions(+)
create mode 100644 internal/adapter/hosting/cloudflare/cloudflare.go
create mode 100644 internal/adapter/hosting/cloudflare/cloudflare_test.go
create mode 100644 internal/adapter/hosting/cloudflare/cloudflaretest/fake.go
create mode 100644 internal/adapter/hosting/hosting.go
diff --git a/internal/adapter/hosting/cloudflare/cloudflare.go b/internal/adapter/hosting/cloudflare/cloudflare.go
new file mode 100644
index 000000000..57f43e7e9
--- /dev/null
+++ b/internal/adapter/hosting/cloudflare/cloudflare.go
@@ -0,0 +1,419 @@
+// Package cloudflare is the one hosting provider abcd ships
+// (itd-2609061543533170): an assets-only Cloudflare Worker, the host abcd's own
+// site is served from.
+//
+// The repository half is data: wrangler.jsonc, and a deploy step that runs the
+// pinned wrangler action with the two environment secrets it names. The host
+// half speaks the Cloudflare v4 API over HTTPS with the person's API token:
+//
+// GET /accounts the one account the token reaches
+// GET /accounts/{a}/workers/workers does the Worker exist (paged)
+// POST /accounts/{a}/workers/workers create it, workers.dev on
+// GET /accounts/{a}/workers/domains is the domain routed, and to whom
+// PUT /accounts/{a}/workers/domains route the domain to the Worker
+// GET /accounts/{a}/workers/subdomain the workers.dev address
+//
+// These calls are written against Cloudflare's published API reference and are
+// exercised in this repository only against cloudflaretest's in-process fake;
+// none of them has been run against the live service by its tests.
+//
+// The token is held by the connected client and set on one request header. It
+// is never formatted into an error: a host's error message is remote-controlled
+// text, so it is truncated and has the token scrubbed out of it before it is
+// allowed into a returned error, which callers print.
+package cloudflare
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/intentdriven/abcd/internal/adapter/hosting"
+)
+
+// ProductionBaseURL is the Cloudflare v4 API.
+const ProductionBaseURL = "https://api.cloudflare.com/client/v4"
+
+// requestTimeout bounds one API call.
+const requestTimeout = 30 * time.Second
+
+// maxResponseBytes bounds what one response may put into memory.
+const maxResponseBytes = 4 << 20
+
+// maxPages bounds the Worker listing's pagination.
+const maxPages = 50
+
+// maxHostMessage bounds how much of a host's error message reaches an error.
+const maxHostMessage = 200
+
+// wranglerAction and wranglerVersion are the deploy step's pins. They are the
+// pins abcd's own site workflow deploys with; TestTheDeployPinsFollowAbcdsOwn
+// holds the two in step.
+const (
+ wranglerAction = "cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0"
+ wranglerVersion = "4.123.0"
+ // compatibilityDate is the Workers runtime date the host configuration
+ // declares. An assets-only Worker runs no script, so the date selects no
+ // behaviour the site depends on; it is fixed so the file is a function of
+ // the site alone and a re-run writes nothing.
+ compatibilityDate = "2026-08-19"
+)
+
+// Secret names the deploy job reads. The account id is not secret, but it rides
+// as one so the workflow names nothing account-specific in the tree.
+const (
+ SecretToken = "CLOUDFLARE_API_TOKEN"
+ SecretAccount = "CLOUDFLARE_ACCOUNT_ID"
+)
+
+// CredentialName is the machine credential this provider resolves.
+const CredentialName = "hosting.cloudflare"
+
+// Adapter is the cloudflare provider. The zero value speaks to the production
+// API; BaseURL points it at a fake.
+type Adapter struct {
+ BaseURL string
+}
+
+var _ hosting.Adapter = Adapter{}
+
+// Name is the manifest key.
+func (Adapter) Name() string { return "cloudflare" }
+
+// CredentialName is the name the API token is resolved by.
+func (Adapter) CredentialName() string { return CredentialName }
+
+// Secrets are the deploy environment's secret names.
+func (Adapter) Secrets() []string { return []string{SecretToken, SecretAccount} }
+
+// DeployStep runs `wrangler deploy` through the pinned action. `deploy` also
+// asserts the routes wrangler.jsonc declares, so a lost domain attachment is
+// re-claimed by the next release rather than staying lost.
+func (Adapter) DeployStep() string {
+ return ` - name: Deploy to Cloudflare
+ uses: ` + wranglerAction + `
+ with:
+ apiToken: ${{ secrets.` + SecretToken + ` }}
+ accountId: ${{ secrets.` + SecretAccount + ` }}
+ command: deploy
+ wranglerVersion: '` + wranglerVersion + `'
+`
+}
+
+// HostConfig renders wrangler.jsonc for s. The values are validated by the
+// caller; they are JSON-encoded here regardless, so no value can break out of
+// its string.
+func (Adapter) HostConfig(s hosting.Site) (string, []byte) {
+ q := func(v string) string { b, _ := json.Marshal(v); return string(b) }
+ var b strings.Builder
+ b.WriteString("{\n")
+ b.WriteString(" // Written by `abcd site setup`. An assets-only Worker serving the site the\n")
+ b.WriteString(" // release workflow renders (.github/workflows/site.yml); the deploy job runs\n")
+ b.WriteString(" // `wrangler deploy`, which also asserts the routes below. Re-running the\n")
+ b.WriteString(" // verb rewrites nothing while this file is unchanged, and refuses a hand edit.\n")
+ b.WriteString(` "name": ` + q(s.Name) + ",\n")
+ b.WriteString(` "compatibility_date": ` + q(compatibilityDate) + ",\n")
+ if s.Domain != "" {
+ b.WriteString(` "routes": [` + "\n")
+ b.WriteString(` { "pattern": ` + q(s.Domain) + `, "custom_domain": true }` + "\n")
+ b.WriteString(" ],\n")
+ }
+ b.WriteString(` "assets": {` + "\n")
+ b.WriteString(` "directory": "./site",` + "\n")
+ b.WriteString(` "not_found_handling": "404-page"` + "\n")
+ b.WriteString(" }\n")
+ b.WriteString("}\n")
+ return "wrangler.jsonc", []byte(b.String())
+}
+
+// Connect returns the host half acting with token.
+func (a Adapter) Connect(token string) hosting.Provider {
+ base := a.BaseURL
+ if base == "" {
+ base = ProductionBaseURL
+ }
+ return &client{
+ base: strings.TrimRight(base, "/"),
+ token: token,
+ http: &http.Client{
+ Timeout: requestTimeout,
+ // A redirect would carry the request to an address the base did not
+ // name. The API does not redirect, so one is refused rather than
+ // followed.
+ CheckRedirect: func(*http.Request, []*http.Request) error { return errRedirect },
+ },
+ }
+}
+
+var errRedirect = errors.New("the host answered with a redirect, which is not followed")
+
+// nameRe and domainRe re-check what the caller validated: the name becomes a
+// path segment and a body field, the domain a query value and a body field.
+var (
+ nameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+ domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`)
+ accountRe = regexp.MustCompile(`^[A-Za-z0-9]{1,64}$`)
+ subRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+)
+
+func checkSite(s hosting.Site) error {
+ if !nameRe.MatchString(s.Name) {
+ return errors.New("cloudflare: the host name is not a plain Worker name, so it will not be sent")
+ }
+ if s.Domain != "" && !domainRe.MatchString(s.Domain) {
+ return errors.New("cloudflare: the domain is not a plain domain name, so it will not be sent")
+ }
+ return nil
+}
+
+type client struct {
+ base string
+ token string
+ http *http.Client
+ account string
+}
+
+type envelope struct {
+ Success bool `json:"success"`
+ Errors []apiMessage `json:"errors"`
+ Result json.RawMessage `json:"result"`
+ Info struct {
+ Page int `json:"page"`
+ TotalPages int `json:"total_pages"`
+ } `json:"result_info"`
+}
+
+type apiMessage struct {
+ Code int `json:"code"`
+ Message string `json:"message"`
+}
+
+// call makes one request. what names the route in an error (never the token,
+// never a query value).
+func (c *client) call(ctx context.Context, method, what, path string, query url.Values, body any) (envelope, error) {
+ var rd io.Reader
+ if body != nil {
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return envelope{}, err
+ }
+ rd = bytes.NewReader(raw)
+ }
+ u := c.base + path
+ if len(query) > 0 {
+ u += "?" + query.Encode()
+ }
+ ctx, cancel := context.WithTimeout(ctx, requestTimeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, method, u, rd)
+ if err != nil {
+ return envelope{}, fmt.Errorf("cloudflare: %s: the request could not be built", what)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.token)
+ req.Header.Set("Accept", "application/json")
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ if errors.Is(err, errRedirect) {
+ return envelope{}, fmt.Errorf("cloudflare: %s: %v", what, errRedirect)
+ }
+ // The transport error names the URL, which holds no credential; the
+ // scrub is belt and braces.
+ return envelope{}, fmt.Errorf("cloudflare: %s: %s", what, c.scrub(err.Error()))
+ }
+ defer resp.Body.Close()
+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
+ if err != nil {
+ return envelope{}, fmt.Errorf("cloudflare: %s: the response could not be read", what)
+ }
+ var env envelope
+ jerr := json.Unmarshal(raw, &env)
+ if resp.StatusCode < 200 || resp.StatusCode > 299 || jerr != nil || !env.Success {
+ return envelope{}, fmt.Errorf("cloudflare: %s: HTTP %d%s", what, resp.StatusCode, c.messages(env.Errors))
+ }
+ return env, nil
+}
+
+// messages renders the host's error messages, bounded and scrubbed.
+func (c *client) messages(ms []apiMessage) string {
+ if len(ms) == 0 {
+ return ""
+ }
+ var parts []string
+ for _, m := range ms {
+ parts = append(parts, strconv.Itoa(m.Code)+" "+m.Message)
+ }
+ s := c.scrub(strings.Join(parts, "; "))
+ if len(s) > maxHostMessage {
+ s = s[:maxHostMessage] + "…"
+ }
+ return " (" + s + ")"
+}
+
+// scrub removes the token from text a host or a transport produced.
+func (c *client) scrub(s string) string {
+ if c.token == "" {
+ return s
+ }
+ return strings.ReplaceAll(s, c.token, "[credential]")
+}
+
+// accountID resolves the one account the token reaches, once.
+func (c *client) accountID(ctx context.Context) (string, error) {
+ if c.account != "" {
+ return c.account, nil
+ }
+ env, err := c.call(ctx, http.MethodGet, "list accounts", "/accounts", url.Values{"per_page": {"50"}}, nil)
+ if err != nil {
+ return "", err
+ }
+ var accounts []struct {
+ ID string `json:"id"`
+ }
+ if err := json.Unmarshal(env.Result, &accounts); err != nil {
+ return "", errors.New("cloudflare: list accounts: the result is not a list of accounts")
+ }
+ switch len(accounts) {
+ case 0:
+ return "", errors.New("cloudflare: the credential reaches no account, so there is nowhere to create the site")
+ case 1:
+ default:
+ return "", fmt.Errorf("cloudflare: the credential reaches %d accounts; abcd will not pick one, so scope the token to the account that should host the site", len(accounts))
+ }
+ if !accountRe.MatchString(accounts[0].ID) {
+ return "", errors.New("cloudflare: the account id the host returned is not a plain identifier")
+ }
+ c.account = accounts[0].ID
+ return c.account, nil
+}
+
+// Inspect reads whether the Worker exists and whether the domain routes to it.
+func (c *client) Inspect(ctx context.Context, s hosting.Site) (hosting.State, error) {
+ if err := checkSite(s); err != nil {
+ return hosting.State{}, err
+ }
+ acct, err := c.accountID(ctx)
+ if err != nil {
+ return hosting.State{}, err
+ }
+ var st hosting.State
+ for page := 1; page <= maxPages; page++ {
+ env, err := c.call(ctx, http.MethodGet, "list Workers", "/accounts/"+url.PathEscape(acct)+"/workers/workers",
+ url.Values{"per_page": {"100"}, "page": {strconv.Itoa(page)}}, nil)
+ if err != nil {
+ return hosting.State{}, err
+ }
+ var workers []struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(env.Result, &workers); err != nil {
+ return hosting.State{}, errors.New("cloudflare: list Workers: the result is not a list of Workers")
+ }
+ for _, w := range workers {
+ if w.Name == s.Name {
+ st.Exists = true
+ }
+ }
+ if st.Exists || env.Info.TotalPages <= page {
+ break
+ }
+ }
+ if s.Domain == "" {
+ st.Routed = true
+ return st, nil
+ }
+ env, err := c.call(ctx, http.MethodGet, "list Worker domains", "/accounts/"+url.PathEscape(acct)+"/workers/domains",
+ url.Values{"hostname": {s.Domain}}, nil)
+ if err != nil {
+ return hosting.State{}, err
+ }
+ var domains []struct {
+ Hostname string `json:"hostname"`
+ Service string `json:"service"`
+ }
+ if err := json.Unmarshal(env.Result, &domains); err != nil {
+ return hosting.State{}, errors.New("cloudflare: list Worker domains: the result is not a list of domains")
+ }
+ for _, d := range domains {
+ if d.Hostname != s.Domain {
+ continue
+ }
+ if d.Service != s.Name {
+ svc := d.Service
+ if !nameRe.MatchString(svc) {
+ svc = "a Worker with an unprintable name"
+ }
+ return hosting.State{}, fmt.Errorf("cloudflare: %s is already routed to %s; abcd will not move a live domain off another Worker", s.Domain, svc)
+ }
+ st.Routed = true
+ }
+ return st, nil
+}
+
+// Create creates the Worker with its workers.dev address on.
+func (c *client) Create(ctx context.Context, s hosting.Site) error {
+ if err := checkSite(s); err != nil {
+ return err
+ }
+ acct, err := c.accountID(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = c.call(ctx, http.MethodPost, "create Worker", "/accounts/"+url.PathEscape(acct)+"/workers/workers", nil,
+ map[string]any{"name": s.Name, "subdomain": map[string]bool{"enabled": true}})
+ return err
+}
+
+// Route attaches the custom domain to the Worker.
+func (c *client) Route(ctx context.Context, s hosting.Site) error {
+ if err := checkSite(s); err != nil {
+ return err
+ }
+ if s.Domain == "" {
+ return nil
+ }
+ acct, err := c.accountID(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = c.call(ctx, http.MethodPut, "route domain", "/accounts/"+url.PathEscape(acct)+"/workers/domains", nil,
+ map[string]string{"hostname": s.Domain, "service": s.Name})
+ return err
+}
+
+// Address is the custom domain when there is one, the workers.dev name
+// otherwise.
+func (c *client) Address(ctx context.Context, s hosting.Site) (string, error) {
+ if err := checkSite(s); err != nil {
+ return "", err
+ }
+ if s.Domain != "" {
+ return "https://" + s.Domain, nil
+ }
+ acct, err := c.accountID(ctx)
+ if err != nil {
+ return "", err
+ }
+ env, err := c.call(ctx, http.MethodGet, "read workers.dev subdomain", "/accounts/"+url.PathEscape(acct)+"/workers/subdomain", nil, nil)
+ if err != nil {
+ return "", err
+ }
+ var sub struct {
+ Subdomain string `json:"subdomain"`
+ }
+ if err := json.Unmarshal(env.Result, &sub); err != nil || !subRe.MatchString(sub.Subdomain) {
+ return "", errors.New("cloudflare: the account has no plain workers.dev subdomain to serve the site from")
+ }
+ return "https://" + s.Name + "." + sub.Subdomain + ".workers.dev", nil
+}
diff --git a/internal/adapter/hosting/cloudflare/cloudflare_test.go b/internal/adapter/hosting/cloudflare/cloudflare_test.go
new file mode 100644
index 000000000..b7b9a518a
--- /dev/null
+++ b/internal/adapter/hosting/cloudflare/cloudflare_test.go
@@ -0,0 +1,270 @@
+package cloudflare
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/intentdriven/abcd/internal/adapter/hosting"
+ "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare/cloudflaretest"
+)
+
+func connect(t *testing.T, base string, token string) hosting.Provider {
+ t.Helper()
+ return Adapter{BaseURL: base}.Connect(token)
+}
+
+var site = hosting.Site{Name: "example-site", Domain: "docs.example.com"}
+
+// TestCreateRouteAndReportAgainstTheFake walks the whole host half once: the
+// read says nothing exists, the two writes create and route, the read after says
+// both hold, and the address is the domain.
+func TestCreateRouteAndReportAgainstTheFake(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ p := connect(t, fake.URL, cloudflaretest.Token)
+ ctx := context.Background()
+
+ st, err := p.Inspect(ctx, site)
+ if err != nil {
+ t.Fatalf("inspect: %v", err)
+ }
+ if st.Exists || st.Routed {
+ t.Fatalf("a fresh account reports %+v, want nothing", st)
+ }
+ if fake.Writes() != 0 {
+ t.Fatalf("Inspect wrote: %v", fake.CallLog())
+ }
+ if err := p.Create(ctx, site); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ if err := p.Route(ctx, site); err != nil {
+ t.Fatalf("route: %v", err)
+ }
+ st, err = p.Inspect(ctx, site)
+ if err != nil {
+ t.Fatalf("inspect after: %v", err)
+ }
+ if !st.Exists || !st.Routed {
+ t.Fatalf("after create and route, state is %+v", st)
+ }
+ addr, err := p.Address(ctx, site)
+ if err != nil {
+ t.Fatalf("address: %v", err)
+ }
+ if addr != "https://docs.example.com" {
+ t.Fatalf("address = %q", addr)
+ }
+ body := fake.Bodies[cloudflaretest.AttachDomain][0]
+ if body["hostname"] != "docs.example.com" || body["service"] != "example-site" {
+ t.Fatalf("route body = %v", body)
+ }
+ created := fake.Bodies[cloudflaretest.CreateWorker][0]
+ if created["name"] != "example-site" {
+ t.Fatalf("create body = %v", created)
+ }
+}
+
+// TestAddressWithoutADomainIsTheWorkersDevName: a site with no custom domain
+// is served from the account's workers.dev subdomain, and routing it is a no-op.
+func TestAddressWithoutADomainIsTheWorkersDevName(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ p := connect(t, fake.URL, cloudflaretest.Token)
+ s := hosting.Site{Name: "example-site"}
+ ctx := context.Background()
+ st, err := p.Inspect(ctx, s)
+ if err != nil || !st.Routed {
+ t.Fatalf("no-domain site: state %+v err %v; want Routed vacuously true", st, err)
+ }
+ if err := p.Route(ctx, s); err != nil {
+ t.Fatalf("route: %v", err)
+ }
+ if fake.Writes() != 0 {
+ t.Fatalf("routing a site with no domain wrote: %v", fake.CallLog())
+ }
+ addr, err := p.Address(ctx, s)
+ if err != nil {
+ t.Fatalf("address: %v", err)
+ }
+ if want := "https://example-site." + cloudflaretest.Subdomain + ".workers.dev"; addr != want {
+ t.Fatalf("address = %q, want %q", addr, want)
+ }
+}
+
+// TestEveryCallFailsLoudly makes each route the adapter calls fail in turn, and
+// requires the step that made the call to return an error naming the route's
+// status, never a success and never the credential.
+func TestEveryCallFailsLoudly(t *testing.T) {
+ steps := []struct {
+ route string
+ run func(p hosting.Provider) error
+ }{
+ {cloudflaretest.ListAccounts, func(p hosting.Provider) error { _, err := p.Inspect(context.Background(), site); return err }},
+ {cloudflaretest.ListWorkers, func(p hosting.Provider) error { _, err := p.Inspect(context.Background(), site); return err }},
+ {cloudflaretest.ListDomains, func(p hosting.Provider) error { _, err := p.Inspect(context.Background(), site); return err }},
+ {cloudflaretest.CreateWorker, func(p hosting.Provider) error { return p.Create(context.Background(), site) }},
+ {cloudflaretest.AttachDomain, func(p hosting.Provider) error {
+ _ = p.Create(context.Background(), site)
+ return p.Route(context.Background(), site)
+ }},
+ {cloudflaretest.GetSubdomain, func(p hosting.Provider) error {
+ _, err := p.Address(context.Background(), hosting.Site{Name: "example-site"})
+ return err
+ }},
+ }
+ for _, st := range steps {
+ t.Run(st.route, func(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ fake.Fail[st.route] = http.StatusInternalServerError
+ err := st.run(connect(t, fake.URL, cloudflaretest.Token))
+ if err == nil {
+ t.Fatalf("%s failed on the host and the adapter reported success", st.route)
+ }
+ if !strings.Contains(err.Error(), "500") {
+ t.Fatalf("the error does not carry the host's status: %v", err)
+ }
+ if strings.Contains(err.Error(), cloudflaretest.Token) {
+ t.Fatalf("the error carries the credential: %v", err)
+ }
+ })
+ }
+}
+
+// TestTheCredentialNeverReachesAnError: a host that echoes the Authorization
+// header back in its error message must not get the token into the error the
+// adapter returns, because that error is printed.
+func TestTheCredentialNeverReachesAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"success":false,"errors":[{"code":1,"message":"bad header: ` + r.Header.Get("Authorization") + `"}],"result":null}`))
+ }))
+ defer srv.Close()
+ _, err := connect(t, srv.URL, cloudflaretest.Token).Inspect(context.Background(), site)
+ if err == nil {
+ t.Fatal("a 403 read as success")
+ }
+ if strings.Contains(err.Error(), cloudflaretest.Token) {
+ t.Fatalf("the credential reached the error: %v", err)
+ }
+}
+
+// TestAWrongCredentialIsRefused: the fake refuses any other bearer, and the
+// adapter reports the refusal.
+func TestAWrongCredentialIsRefused(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ if _, err := connect(t, fake.URL, "not-the-token").Inspect(context.Background(), site); err == nil ||
+ !strings.Contains(err.Error(), "403") {
+ t.Fatalf("a wrong credential: err = %v, want a 403", err)
+ }
+}
+
+// TestAccountMustBeUnambiguous: the adapter takes the account from the token's
+// reach, so a token that reaches none, or more than one, is refused before any
+// write rather than guessed at.
+func TestAccountMustBeUnambiguous(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ accounts []string
+ want string
+ }{
+ {"none", nil, "no account"},
+ {"two", []string{cloudflaretest.Account, "fedcba9876543210fedcba9876543210"}, "2 accounts"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ fake.Accounts = tc.accounts
+ _, err := connect(t, fake.URL, cloudflaretest.Token).Inspect(context.Background(), site)
+ if err == nil || !strings.Contains(err.Error(), tc.want) {
+ t.Fatalf("err = %v, want one naming %q", err, tc.want)
+ }
+ })
+ }
+}
+
+// TestADomainOnAnotherHostIsNotMoved: a domain already routed to a different
+// Worker is somebody's live site, and the adapter refuses rather than
+// re-pointing it.
+func TestADomainOnAnotherHostIsNotMoved(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ fake.Workers["other-site"] = true
+ fake.Domains["docs.example.com"] = "other-site"
+ _, err := connect(t, fake.URL, cloudflaretest.Token).Inspect(context.Background(), site)
+ if err == nil || !strings.Contains(err.Error(), "other-site") {
+ t.Fatalf("err = %v, want a refusal naming the Worker that holds the domain", err)
+ }
+}
+
+// TestTheWorkerListIsPaged: a Worker on the second page of the listing exists.
+func TestTheWorkerListIsPaged(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ fake.PageSize = 1
+ fake.Workers["a-first"] = true
+ fake.Workers["example-site"] = true
+ st, err := connect(t, fake.URL, cloudflaretest.Token).Inspect(context.Background(), hosting.Site{Name: "example-site"})
+ if err != nil {
+ t.Fatalf("inspect: %v", err)
+ }
+ if !st.Exists {
+ t.Fatal("a Worker on the second page was reported absent")
+ }
+}
+
+// TestARedirectIsNotFollowed: a redirect would carry the request somewhere the
+// base address did not name.
+func TestARedirectIsNotFollowed(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "http://192.0.2.1/elsewhere", http.StatusFound)
+ }))
+ defer srv.Close()
+ if _, err := connect(t, srv.URL, cloudflaretest.Token).Inspect(context.Background(), site); err == nil {
+ t.Fatal("a redirect was followed or read as success")
+ }
+}
+
+// TestAnUnsafeNameNeverReachesARequest: the name becomes a path and a body
+// field, so the adapter holds it to the host-name charset itself.
+func TestAnUnsafeNameNeverReachesARequest(t *testing.T) {
+ fake := cloudflaretest.New(t)
+ p := connect(t, fake.URL, cloudflaretest.Token)
+ for _, s := range []hosting.Site{{Name: "../accounts"}, {Name: "ok-name", Domain: "https://example.com"}} {
+ if err := p.Create(context.Background(), s); err == nil {
+ t.Fatalf("Create accepted %+v", s)
+ }
+ }
+ if fake.Writes() != 0 {
+ t.Fatalf("an unsafe name reached the host: %v", fake.CallLog())
+ }
+}
+
+// TestRepositoryHalf pins the data the verb writes into the repository: the
+// host configuration names the Worker and its route and carries no secret, and
+// the deploy step reads its credential from the named secrets alone.
+func TestRepositoryHalf(t *testing.T) {
+ a := Adapter{}
+ if a.Name() != "cloudflare" {
+ t.Fatalf("name = %q", a.Name())
+ }
+ rel, data := a.HostConfig(site)
+ if rel != "wrangler.jsonc" {
+ t.Fatalf("host config path = %q", rel)
+ }
+ text := string(data)
+ for _, want := range []string{`"name": "example-site"`, `"pattern": "docs.example.com"`, `"directory": "./site"`} {
+ if !strings.Contains(text, want) {
+ t.Fatalf("host config lacks %s:\n%s", want, text)
+ }
+ }
+ if _, bare := a.HostConfig(hosting.Site{Name: "example-site"}); strings.Contains(string(bare), `"routes":`) {
+ t.Fatalf("a site with no domain declares routes:\n%s", bare)
+ }
+ step := a.DeployStep()
+ for _, s := range a.Secrets() {
+ if !strings.Contains(step, "${{ secrets."+s+" }}") {
+ t.Fatalf("the deploy step does not read secret %s:\n%s", s, step)
+ }
+ }
+ if strings.Contains(step, "run:") {
+ t.Fatalf("the deploy step runs a shell; it should only call the pinned action:\n%s", step)
+ }
+}
diff --git a/internal/adapter/hosting/cloudflare/cloudflaretest/fake.go b/internal/adapter/hosting/cloudflare/cloudflaretest/fake.go
new file mode 100644
index 000000000..25eb88e1f
--- /dev/null
+++ b/internal/adapter/hosting/cloudflare/cloudflaretest/fake.go
@@ -0,0 +1,260 @@
+// Package cloudflaretest is an in-process fake of the slice of the Cloudflare
+// v4 API the cloudflare hosting adapter calls, for tests only. No test in this
+// repository reaches the real service: the adapter and the verb above it are
+// exercised against this server, and every route it answers can be made to
+// fail, so each error path is a test rather than a hope.
+//
+// The fake answers the documented shapes (the v4 envelope: success, errors,
+// result, result_info) for exactly these routes:
+//
+// GET /accounts
+// GET /accounts/{account}/workers/workers
+// POST /accounts/{account}/workers/workers
+// GET /accounts/{account}/workers/domains
+// PUT /accounts/{account}/workers/domains
+// GET /accounts/{account}/workers/subdomain
+//
+// Anything else is a 404, so a request the adapter was not written to make
+// fails the test that provoked it.
+package cloudflaretest
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Token is the credential the fake accepts. Any other bearer is a 403.
+const Token = "cf-fake-token-0000000000000000000000000000"
+
+// Account is the one account the default fake holds.
+const Account = "0123456789abcdef0123456789abcdef"
+
+// Subdomain is the account's workers.dev subdomain.
+const Subdomain = "example-sub"
+
+// Route keys name a route for Fail and for the call log.
+const (
+ ListAccounts = "GET accounts"
+ ListWorkers = "GET workers"
+ CreateWorker = "POST workers"
+ ListDomains = "GET domains"
+ AttachDomain = "PUT domains"
+ GetSubdomain = "GET subdomain"
+ unknownRoute = "unknown"
+ envelopeError = 10000
+)
+
+// Server is the fake. Its fields are the host's state; lock with Mu to read
+// them while a request may be in flight.
+type Server struct {
+ *httptest.Server
+ Mu sync.Mutex
+ // Accounts the token reaches.
+ Accounts []string
+ // Workers by name.
+ Workers map[string]bool
+ // Domains maps a hostname to the Worker it is routed to.
+ Domains map[string]string
+ // Fail answers a route with this HTTP status instead of serving it.
+ Fail map[string]int
+ // PageSize splits the Workers list into pages of this size (0 = one page).
+ PageSize int
+ // Calls is every request received, as its route key.
+ Calls []string
+ // Bodies holds each write's decoded JSON body, keyed by route.
+ Bodies map[string][]map[string]any
+ // Auth records every Authorization header value received, so a test can
+ // assert the credential went where it should and nowhere else.
+ Auth []string
+}
+
+// New starts a fake with one account, no Workers and no domains.
+func New(t testing.TB) *Server {
+ t.Helper()
+ s := &Server{
+ Accounts: []string{Account},
+ Workers: map[string]bool{},
+ Domains: map[string]string{},
+ Fail: map[string]int{},
+ Bodies: map[string][]map[string]any{},
+ }
+ s.Server = httptest.NewServer(http.HandlerFunc(s.serve))
+ t.Cleanup(s.Close)
+ return s
+}
+
+// Writes is how many write requests the fake received.
+func (s *Server) Writes() int {
+ s.Mu.Lock()
+ defer s.Mu.Unlock()
+ n := 0
+ for _, c := range s.Calls {
+ if !strings.HasPrefix(c, "GET ") {
+ n++
+ }
+ }
+ return n
+}
+
+// CallLog returns a copy of the call log.
+func (s *Server) CallLog() []string {
+ s.Mu.Lock()
+ defer s.Mu.Unlock()
+ return append([]string(nil), s.Calls...)
+}
+
+func (s *Server) route(r *http.Request) (key, account string) {
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
+ switch {
+ case len(parts) == 1 && parts[0] == "accounts" && r.Method == http.MethodGet:
+ return ListAccounts, ""
+ case len(parts) == 4 && parts[0] == "accounts" && parts[2] == "workers":
+ account = parts[1]
+ switch {
+ case parts[3] == "workers" && r.Method == http.MethodGet:
+ return ListWorkers, account
+ case parts[3] == "workers" && r.Method == http.MethodPost:
+ return CreateWorker, account
+ case parts[3] == "domains" && r.Method == http.MethodGet:
+ return ListDomains, account
+ case parts[3] == "domains" && r.Method == http.MethodPut:
+ return AttachDomain, account
+ case parts[3] == "subdomain" && r.Method == http.MethodGet:
+ return GetSubdomain, account
+ }
+ }
+ return unknownRoute, ""
+}
+
+func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
+ s.Mu.Lock()
+ defer s.Mu.Unlock()
+ key, account := s.route(r)
+ s.Calls = append(s.Calls, key)
+ s.Auth = append(s.Auth, r.Header.Get("Authorization"))
+ if r.Header.Get("Authorization") != "Bearer "+Token {
+ fail(w, http.StatusForbidden, "Authentication error")
+ return
+ }
+ if code, ok := s.Fail[key]; ok {
+ fail(w, code, "injected failure on "+key)
+ return
+ }
+ if key == unknownRoute {
+ fail(w, http.StatusNotFound, "no such route")
+ return
+ }
+ if account != "" && !contains(s.Accounts, account) {
+ fail(w, http.StatusForbidden, "account not reachable")
+ return
+ }
+ var body map[string]any
+ if r.Method != http.MethodGet {
+ raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
+ if err := json.Unmarshal(raw, &body); err != nil {
+ fail(w, http.StatusBadRequest, "body is not JSON")
+ return
+ }
+ s.Bodies[key] = append(s.Bodies[key], body)
+ }
+ switch key {
+ case ListAccounts:
+ var out []map[string]string
+ for _, a := range s.Accounts {
+ out = append(out, map[string]string{"id": a, "name": "account " + a[:4]})
+ }
+ ok(w, out, nil)
+ case ListWorkers:
+ names := make([]string, 0, len(s.Workers))
+ for n := range s.Workers {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ page, _ := strconv.Atoi(r.URL.Query().Get("page"))
+ if page < 1 {
+ page = 1
+ }
+ size := s.PageSize
+ if size <= 0 {
+ size = len(names) + 1
+ }
+ total := (len(names) + size - 1) / size
+ if total == 0 {
+ total = 1
+ }
+ lo, hi := (page-1)*size, page*size
+ if lo > len(names) {
+ lo = len(names)
+ }
+ if hi > len(names) {
+ hi = len(names)
+ }
+ var out []map[string]any
+ for _, n := range names[lo:hi] {
+ out = append(out, map[string]any{"id": "id-" + n, "name": n})
+ }
+ ok(w, out, map[string]int{"page": page, "total_pages": total})
+ case CreateWorker:
+ name, _ := body["name"].(string)
+ if name == "" || s.Workers[name] {
+ fail(w, http.StatusConflict, "worker exists or has no name")
+ return
+ }
+ s.Workers[name] = true
+ ok(w, map[string]any{"id": "id-" + name, "name": name}, nil)
+ case ListDomains:
+ host := r.URL.Query().Get("hostname")
+ var out []map[string]string
+ for h, svc := range s.Domains {
+ if host == "" || h == host {
+ out = append(out, map[string]string{"hostname": h, "service": svc})
+ }
+ }
+ ok(w, out, nil)
+ case AttachDomain:
+ host, _ := body["hostname"].(string)
+ svc, _ := body["service"].(string)
+ if !s.Workers[svc] {
+ fail(w, http.StatusBadRequest, "no such worker")
+ return
+ }
+ s.Domains[host] = svc
+ ok(w, map[string]string{"hostname": host, "service": svc}, nil)
+ case GetSubdomain:
+ ok(w, map[string]string{"subdomain": Subdomain}, nil)
+ }
+}
+
+func ok(w http.ResponseWriter, result any, info any) {
+ env := map[string]any{"success": true, "errors": []any{}, "messages": []any{}, "result": result}
+ if info != nil {
+ env["result_info"] = info
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(env)
+}
+
+func fail(w http.ResponseWriter, code int, msg string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "success": false, "result": nil,
+ "errors": []map[string]any{{"code": envelopeError, "message": msg}},
+ })
+}
+
+func contains(xs []string, x string) bool {
+ for _, v := range xs {
+ if v == x {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/adapter/hosting/hosting.go b/internal/adapter/hosting/hosting.go
new file mode 100644
index 000000000..2cb4ddb61
--- /dev/null
+++ b/internal/adapter/hosting/hosting.go
@@ -0,0 +1,75 @@
+// Package hosting is the provider seam `abcd site setup` routes a rendered site
+// through (itd-2609061543533170, spc-2609212141407459 scope 2). It is the second
+// seam under internal/adapter beside the scanners, and it follows their shape:
+// the core consumes the interface, never a vendor, and a provider is one
+// implementation of it.
+//
+// One provider ships (cloudflare, the host abcd's own site uses). A second is a
+// new implementation of Adapter and one line in the core's provider list, never
+// a change to the verb.
+//
+// The seam has two halves because a provider touches the site twice:
+//
+// - in the REPOSITORY, it names the secrets its deploy job reads, the deploy
+// step itself, and the host configuration file that step deploys from.
+// These are data, rendered into files the verb writes; nothing here runs.
+// - on the HOST, through its API and the person's credential, it creates the
+// host, routes the domain to it and reports the live address. Every call
+// is made only after the verb's confirmation, and Inspect, the read, is the
+// only call made before it.
+//
+// The credential is handed to Connect and lives only inside the returned
+// Provider. No method returns it, no error carries it, and nothing writes it.
+package hosting
+
+import "context"
+
+// Site is what a provider hosts: the host's name, and the custom domain routed
+// to it, if any. Both are validated by the caller before they reach a provider
+// and again by the provider before they reach a request.
+type Site struct {
+ Name string
+ Domain string
+}
+
+// State is what the host holds for a site, read without writing anything.
+type State struct {
+ // Exists is true when the host already exists.
+ Exists bool
+ // Routed is true when the domain is already routed to the host, and
+ // vacuously true when the site asks for no domain.
+ Routed bool
+}
+
+// Provider is the live half of the seam: one connected account.
+type Provider interface {
+ // Inspect reads what the host holds for s. It writes nothing.
+ Inspect(ctx context.Context, s Site) (State, error)
+ // Create creates the host for s.
+ Create(ctx context.Context, s Site) error
+ // Route routes s.Domain to the host. A site with no domain is a no-op.
+ Route(ctx context.Context, s Site) error
+ // Address reports the address the site is served from.
+ Address(ctx context.Context, s Site) (string, error)
+}
+
+// Adapter is one provider: the repository half as data, and Connect for the
+// host half. It is the interface a second provider implements.
+type Adapter interface {
+ // Name is the provider's key in the composition manifest's hosting block.
+ Name() string
+ // CredentialName is the name the credential is resolved by on this
+ // machine. It names the credential; it never is one.
+ CredentialName() string
+ // Secrets are the forge environment secrets the deploy job reads, by name.
+ Secrets() []string
+ // DeployStep is the workflow step, as YAML indented for a job's step list,
+ // that deploys the unpacked site from ./site. It reads its credentials from
+ // the secrets named by Secrets and nothing else.
+ DeployStep() string
+ // HostConfig is the committed configuration file the deploy step reads,
+ // repo-relative, and its bytes for s.
+ HostConfig(s Site) (rel string, data []byte)
+ // Connect returns a Provider acting with token. The token stays inside it.
+ Connect(token string) Provider
+}
From 38301e724f541dcec7e48ee706c93f4c3dae95e5 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:59:02 +0100
Subject: [PATCH 02/26] feat(credential): read an external credential by name
from a machine-scoped store
The one reader adapters resolve a credential through. The interim source
is ~/.abcd/credentials.json, a JSON object of names to values, refused
loudly unless it is a regular file owned by the caller at mode 0600 or
tighter. The credential store proper (itd-2609221017023290) replaces the
source behind the same interface; no reader changes.
Assisted-by: Claude:claude-opus-5-5
---
internal/core/credential/credential.go | 112 +++++++++++++++++++
internal/core/credential/credential_test.go | 114 ++++++++++++++++++++
2 files changed, 226 insertions(+)
create mode 100644 internal/core/credential/credential.go
create mode 100644 internal/core/credential/credential_test.go
diff --git a/internal/core/credential/credential.go b/internal/core/credential/credential.go
new file mode 100644
index 000000000..25c87113f
--- /dev/null
+++ b/internal/core/credential/credential.go
@@ -0,0 +1,112 @@
+// Package credential is the one reader every adapter resolves an external
+// credential through, by NAME.
+//
+// This is the interim source itd-2609061543533170 ruled for `abcd site setup`
+// (its `## Decisions`, 2026-09-25): the credential store proper, with its three
+// homes and its walkthrough, is itd-2609221017023290, which is planned and not
+// built. Until it lands, a credential is read from one machine-scoped file,
+// ~/.abcd/credentials.json, a JSON object mapping a credential name to its
+// value. The successor replaces the source behind Source; no reader changes.
+//
+// The file is refused, loudly and never treated as absent, unless it is a
+// regular file (not a symlink), owned by the caller, and readable and writable
+// by the owner alone (mode 0600 or tighter): a secret that group or other can
+// read is not kept, and one that somebody else wrote is not the caller's.
+//
+// The value never leaves Resolve except as its return: no error formats it,
+// nothing logs it, and nothing here writes anywhere, least of all the
+// repository. A malformed file is refused without echoing a byte of it.
+package credential
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+
+ "github.com/intentdriven/abcd/internal/fsutil"
+)
+
+// StoreFileName is the interim store's file under ~/.abcd/.
+const StoreFileName = "credentials.json"
+
+// maxStoreBytes bounds the store read.
+const maxStoreBytes = 64 << 10
+
+// ErrNotSet is a credential that resolves to nothing: no store, no entry, or an
+// empty value. It is the one error a caller may treat as "carry on without it".
+var ErrNotSet = errors.New("credential not set")
+
+// Source resolves a credential by name.
+type Source interface {
+ Resolve(name string) (string, error)
+}
+
+// nameRe is the credential-name charset. A name is looked up, never used as a
+// path, but it is printed in refusals, so it is held to plain characters.
+var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
+
+// Machine is the interim machine-scoped source rooted at home (the caller's
+// home directory). An empty home resolves every name to ErrNotSet.
+func Machine(home string) Source { return machine{home: home} }
+
+// UserMachine is Machine at the caller's home directory.
+func UserMachine() Source {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ home = ""
+ }
+ return Machine(home)
+}
+
+type machine struct{ home string }
+
+// StorePath is where the interim store lives, displayed with ~ so no
+// developer-identity path reaches output.
+const StorePath = "~/.abcd/" + StoreFileName
+
+func (m machine) Resolve(name string) (string, error) {
+ if !nameRe.MatchString(name) {
+ return "", errors.New("credential: the name is not a plain credential name")
+ }
+ if m.home == "" {
+ return "", ErrNotSet
+ }
+ p := filepath.Join(m.home, ".abcd", StoreFileName)
+ fi, err := os.Lstat(p)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return "", ErrNotSet
+ }
+ return "", fmt.Errorf("credential: %s could not be examined, so it is not read", StorePath)
+ }
+ if !fi.Mode().IsRegular() {
+ return "", fmt.Errorf("credential: %s is not a regular file (a symlink is never followed), so it is not read", StorePath)
+ }
+ if fi.Mode().Perm()&0o077 != 0 {
+ return "", fmt.Errorf("credential: %s can be read or written by group or other (mode %04o), so it is not read; `chmod 0600 %s`", StorePath, fi.Mode().Perm(), StorePath)
+ }
+ // ReadDeclaration re-checks the leaf on its own descriptor and refuses a
+ // file this uid does not own.
+ raw, refusal, err := fsutil.ReadDeclaration(p, maxStoreBytes)
+ switch {
+ case refusal == fsutil.DeclarationAbsent && errors.Is(err, os.ErrNotExist):
+ return "", ErrNotSet
+ case refusal == fsutil.DeclarationForeignOwner:
+ return "", fmt.Errorf("credential: %s is not owned by you, so it is not read", StorePath)
+ case err != nil:
+ return "", fmt.Errorf("credential: %s could not be read safely (mode 0600, owned by you, a regular file), so it is not read", StorePath)
+ }
+ var store map[string]string
+ if err := json.Unmarshal(raw, &store); err != nil {
+ // The decoder's message can quote the file's bytes; it is dropped.
+ return "", fmt.Errorf("credential: %s is not a JSON object of names to strings", StorePath)
+ }
+ v := store[name]
+ if v == "" {
+ return "", ErrNotSet
+ }
+ return v, nil
+}
diff --git a/internal/core/credential/credential_test.go b/internal/core/credential/credential_test.go
new file mode 100644
index 000000000..da1709e35
--- /dev/null
+++ b/internal/core/credential/credential_test.go
@@ -0,0 +1,114 @@
+package credential
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+const secretValue = "tok-0123456789-not-a-real-secret"
+
+func writeStore(t *testing.T, home, body string, mode os.FileMode) string {
+ t.Helper()
+ dir := filepath.Join(home, ".abcd")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, StoreFileName)
+ if err := os.WriteFile(p, []byte(body), mode); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(p, mode); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+func TestResolveReadsAnOwnerOnlyStore(t *testing.T) {
+ home := t.TempDir()
+ writeStore(t, home, `{"hosting.cloudflare": "`+secretValue+`"}`, 0o600)
+ got, err := Machine(home).Resolve("hosting.cloudflare")
+ if err != nil {
+ t.Fatalf("resolve: %v", err)
+ }
+ if got != secretValue {
+ t.Fatal("resolve returned a different value from the one stored")
+ }
+}
+
+func TestAnAbsentStoreOrNameIsNotSet(t *testing.T) {
+ home := t.TempDir()
+ if _, err := Machine(home).Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) {
+ t.Fatalf("no store: err = %v, want ErrNotSet", err)
+ }
+ writeStore(t, home, `{"other": "x"}`, 0o600)
+ if _, err := Machine(home).Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) {
+ t.Fatalf("absent name: err = %v, want ErrNotSet", err)
+ }
+ writeStore(t, home, `{"hosting.cloudflare": ""}`, 0o600)
+ if _, err := Machine(home).Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) {
+ t.Fatalf("empty value: err = %v, want ErrNotSet", err)
+ }
+}
+
+// TestAStoreOthersCanReadIsRefused: a secret group or other can read is not
+// kept, and it is refused loudly, never read and never treated as absent.
+func TestAStoreOthersCanReadIsRefused(t *testing.T) {
+ for _, mode := range []os.FileMode{0o640, 0o604, 0o644, 0o660} {
+ home := t.TempDir()
+ writeStore(t, home, `{"hosting.cloudflare": "`+secretValue+`"}`, mode)
+ v, err := Machine(home).Resolve("hosting.cloudflare")
+ if err == nil || errors.Is(err, ErrNotSet) {
+ t.Fatalf("mode %o: err = %v, want a refusal", mode, err)
+ }
+ if v != "" || strings.Contains(err.Error(), secretValue) {
+ t.Fatalf("mode %o: the refusal carries the value", mode)
+ }
+ if !strings.Contains(err.Error(), "0600") {
+ t.Fatalf("mode %o: the refusal does not name the remedy: %v", mode, err)
+ }
+ }
+}
+
+func TestASymlinkedStoreIsRefused(t *testing.T) {
+ home := t.TempDir()
+ real := filepath.Join(t.TempDir(), "elsewhere.json")
+ if err := os.WriteFile(real, []byte(`{"hosting.cloudflare": "`+secretValue+`"}`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(home, ".abcd"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(real, filepath.Join(home, ".abcd", StoreFileName)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Machine(home).Resolve("hosting.cloudflare"); err == nil || errors.Is(err, ErrNotSet) {
+ t.Fatalf("a symlinked store: err = %v, want a refusal", err)
+ }
+}
+
+func TestAMalformedStoreNeverEchoesItsBytes(t *testing.T) {
+ home := t.TempDir()
+ writeStore(t, home, `{"hosting.cloudflare": "`+secretValue+`",,}`, 0o600)
+ _, err := Machine(home).Resolve("hosting.cloudflare")
+ if err == nil || errors.Is(err, ErrNotSet) {
+ t.Fatalf("malformed: err = %v, want a refusal", err)
+ }
+ if strings.Contains(err.Error(), secretValue) {
+ t.Fatal("the refusal echoes the store's contents")
+ }
+}
+
+func TestAnUnsafeNameIsRefused(t *testing.T) {
+ if _, err := Machine(t.TempDir()).Resolve("../x"); err == nil || errors.Is(err, ErrNotSet) {
+ t.Fatalf("err = %v, want a refusal", err)
+ }
+}
+
+func TestNoHomeIsNotSet(t *testing.T) {
+ if _, err := Machine("").Resolve("hosting.cloudflare"); !errors.Is(err, ErrNotSet) {
+ t.Fatalf("err = %v, want ErrNotSet", err)
+ }
+}
From 81e7eb57a384ea11bb969137026c62166853bcc7 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Fri, 25 Sep 2026 23:59:09 +0100
Subject: [PATCH 03/26] feat(site): the closed page set with per-page switches
in the composition
The manifest gains a `pages` block (itd-2609061543533170 criterion 4): the
landing page, the explorer, the record pages, the graph, the timeline, the
glossary and the status page render for every repository, and a switch can
only take one away. Switching a page off removes the page, its navigation
entry and every link the renderer drew to it; the explorer's switch takes
every explorer page with it. The landing page and the record pages carry
the site, so switching either off beneath the explorer is refused, and a
key outside the set is refused as any unknown manifest key is.
The manifest also gains the `hosting` block `abcd site setup` reads
(provider, host name, optional domain), validated against the shipped
provider list; the build never reads it.
Assisted-by: Claude:claude-opus-5-5
---
internal/core/site/compose.go | 4 +-
internal/core/site/explorer.go | 36 ++++--
internal/core/site/health.go | 2 +-
internal/core/site/hosting.go | 44 ++++++++
internal/core/site/manifest.go | 12 +-
internal/core/site/pages.go | 78 +++++++++++++
internal/core/site/pages_test.go | 181 +++++++++++++++++++++++++++++++
internal/core/site/providers.go | 37 +++++++
internal/core/site/timeline.go | 9 +-
9 files changed, 390 insertions(+), 13 deletions(-)
create mode 100644 internal/core/site/hosting.go
create mode 100644 internal/core/site/pages.go
create mode 100644 internal/core/site/pages_test.go
create mode 100644 internal/core/site/providers.go
diff --git a/internal/core/site/compose.go b/internal/core/site/compose.go
index 4cd4aea99..a4a09ccb8 100644
--- a/internal/core/site/compose.go
+++ b/internal/core/site/compose.go
@@ -332,7 +332,9 @@ func (c *composer) headerFor(active string) string {
b.WriteString(`` + escapeText(c.ui.NavStory) + ``)
b.WriteString(`` + escapeText(c.ui.NavInstall) + ``)
b.WriteString(`` + escapeText(c.ui.NavDocs) + ``)
- b.WriteString(`` + escapeText(c.ui.NavRecord) + ``)
+ if c.manifest.Pages.resolve().explorer {
+ b.WriteString(`` + escapeText(c.ui.NavRecord) + ``)
+ }
if c.repo.Repository != "" {
b.WriteString(`` + escapeText(c.forgeLabel()) + ` ↗`)
}
diff --git a/internal/core/site/explorer.go b/internal/core/site/explorer.go
index 72a41c724..617251f7e 100644
--- a/internal/core/site/explorer.go
+++ b/internal/core/site/explorer.go
@@ -92,6 +92,8 @@ type explorer struct {
eyebrow, eyebrowSrc string
// bib is the bibliography, or nil where the repository keeps none.
bib *Bibliography
+ // pages is the page set the manifest's switches leave on.
+ pages pageSet
}
// newExplorer indexes the export for the pages.
@@ -110,10 +112,17 @@ func newExplorer(c *composer, export RecordExport, bib *Bibliography, recordRoot
mentions: map[string][]string{},
stubs: map[string][]ExportEdge{},
glossaryByPath: map[string]glossaryEntry{},
- }
- entries, err := loadGlossaryEntries(c.root)
- if err != nil {
- return nil, err
+ pages: c.manifest.Pages.resolve(),
+ }
+ // A glossary switched off is a glossary the explorer never loads: no pages,
+ // no navigation entry and no term links, the same graceful absence a
+ // repository that keeps none gets.
+ var entries []glossaryEntry
+ if e.pages.glossary {
+ var err error
+ if entries, err = loadGlossaryEntries(c.root); err != nil {
+ return nil, err
+ }
}
e.glossary = entries
for _, en := range entries {
@@ -171,6 +180,9 @@ func (e *explorer) hasReferences() bool { return e.bib != nil && len(e.bib.Entri
// Pages renders every explorer page, keyed by its output path.
func (e *explorer) Pages() (map[string]string, error) {
pages := map[string]string{}
+ if !e.pages.explorer {
+ return pages, nil
+ }
add := func(route string, render func() (string, error)) error {
html, err := render()
if err != nil {
@@ -182,8 +194,10 @@ func (e *explorer) Pages() (map[string]string, error) {
if err := add(routeDashboard, e.dashboard); err != nil {
return nil, err
}
- if err := add(routeGraph, e.graphPage); err != nil {
- return nil, err
+ if e.pages.graph {
+ if err := add(routeGraph, e.graphPage); err != nil {
+ return nil, err
+ }
}
if err := add(routeContributors, e.contributorsPage); err != nil {
return nil, err
@@ -281,7 +295,9 @@ func (e *explorer) subnav(active string) string {
if e.hasDevelopment() {
tabs = append(tabs, tab{routeDevelopment, e.c.ui.RecordNav.Development})
}
- tabs = append(tabs, tab{routeGraph, e.c.ui.RecordNav.Graph})
+ if e.pages.graph {
+ tabs = append(tabs, tab{routeGraph, e.c.ui.RecordNav.Graph})
+ }
if e.hasHealth() {
tabs = append(tabs, tab{routeHealth, e.c.ui.RecordNav.Health})
}
@@ -471,8 +487,10 @@ func (e *explorer) dashboard() (string, error) {
// The genealogy sits directly under the counts, folded shut: it is how the
// record got where it is, which a reader asks for rather than arrives at.
- b.WriteString(panelDisclosure("c12", ui.RecordNav.Timeline, "",
- strconv.Itoa(len(e.export.Releases))+" "+ui.Tiles.Releases, e.genealogy()))
+ if e.pages.timeline {
+ b.WriteString(panelDisclosure("c12", ui.RecordNav.Timeline, "",
+ strconv.Itoa(len(e.export.Releases))+" "+ui.Tiles.Releases, e.genealogy()))
+ }
// State bars, one per store that grades its records at all.
for _, typ := range e.storeOrder() {
diff --git a/internal/core/site/health.go b/internal/core/site/health.go
index 4327f02af..77f3d4f76 100644
--- a/internal/core/site/health.go
+++ b/internal/core/site/health.go
@@ -41,7 +41,7 @@ const isolatedListCap = 20
// Without records and without a history there is no check to run, and the page
// and its navigation entry are omitted (itd-140: graceful absence).
func (e *explorer) hasHealth() bool {
- return len(e.export.Nodes) > 0 || e.export.Authorship.Commits > 0
+ return e.pages.status && (len(e.export.Nodes) > 0 || e.export.Authorship.Commits > 0)
}
// healthPage renders `/record/health/`: one panel per family of finding.
diff --git a/internal/core/site/hosting.go b/internal/core/site/hosting.go
new file mode 100644
index 000000000..7259a427e
--- /dev/null
+++ b/internal/core/site/hosting.go
@@ -0,0 +1,44 @@
+package site
+
+import "regexp"
+
+// Hosting is the manifest's `hosting` block: which provider adapter `abcd site
+// setup` routes the site through, the name of the host it creates there, and the
+// custom domain it attaches, if any. It holds no credential and no account
+// identifier: the credential is the machine's, read by name at setup time, and
+// never enters the repository.
+type Hosting struct {
+ Provider string `json:"provider"`
+ Name string `json:"name"`
+ Domain string `json:"domain,omitempty"`
+}
+
+// hostNameRe is the host-name charset: lowercase letters, digits and inner
+// hyphens, at most 63 characters. It is the narrowest of the providers' rules
+// and is applied before the name reaches an API path or a workflow file.
+var hostNameRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+// domainRe is a lowercase DNS name of at least two labels, no scheme, no port,
+// no path and no trailing dot. It reaches a provider API body and a committed
+// config file, so it is held to the plainest spelling.
+var domainRe = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+// validate refuses a hosting block setup could not act on.
+func (h *Hosting) validate(bad func(string, ...any) error) error {
+ if h == nil {
+ return nil
+ }
+ if h.Provider == "" {
+ return bad("hosting.provider is empty; it names the adapter setup routes the site through")
+ }
+ if _, ok := adapterNamed(h.Provider); !ok {
+ return bad("hosting.provider %q is not a provider abcd ships (%s)", h.Provider, providerList())
+ }
+ if !hostNameRe.MatchString(h.Name) {
+ return bad("hosting.name %q is not a host name (lowercase letters, digits and inner hyphens, at most 63)", h.Name)
+ }
+ if h.Domain != "" && (len(h.Domain) > 253 || !domainRe.MatchString(h.Domain)) {
+ return bad("hosting.domain %q is not a plain lowercase domain name (no scheme, port or path)", h.Domain)
+ }
+ return nil
+}
diff --git a/internal/core/site/manifest.go b/internal/core/site/manifest.go
index 6a1b370bd..64db72314 100644
--- a/internal/core/site/manifest.go
+++ b/internal/core/site/manifest.go
@@ -201,6 +201,13 @@ type Manifest struct {
// MEASURES the unresolved references and publishes the count; the ratchet
// that refuses a larger one is that verb's.
Checks ManifestGate `json:"checks"`
+ // Pages switches pages of the closed page set off (itd-2609061543533170).
+ // Absent, every page renders; the set is the same for every repository and
+ // a switch can only take a page away, never add one.
+ Pages PageSwitches `json:"pages"`
+ // Hosting names where `abcd site setup` puts the rendered site. Absent,
+ // setup derives it; the build never reads it.
+ Hosting *Hosting `json:"hosting,omitempty"`
}
// BlockRef selects a span of a file by heading.
@@ -440,7 +447,10 @@ func (m Manifest) validate() error {
if err := m.validateDeferred(bad); err != nil {
return err
}
- return nil
+ if err := m.Pages.validate(bad); err != nil {
+ return err
+ }
+ return m.Hosting.validate(bad)
}
// validateDeferred checks the keys this build does not act on yet.
diff --git a/internal/core/site/pages.go b/internal/core/site/pages.go
new file mode 100644
index 000000000..06be3c205
--- /dev/null
+++ b/internal/core/site/pages.go
@@ -0,0 +1,78 @@
+package site
+
+// The closed page set (itd-2609061543533170, spc-2609212141407459 scope 3).
+//
+// Every repository abcd renders gets the same pages abcd's own site has: the
+// landing page, the record explorer, one page per record, the relationship
+// graph, the timeline, the glossary and the status page. The composition
+// manifest's `pages` block switches pages OFF; it cannot switch anything on,
+// because the set is closed, and a key outside it is refused at load by the
+// manifest's unknown-field rule like any other typo.
+//
+// Two pages carry the site rather than sit in it, so their switches are
+// refused rather than honoured:
+//
+// - the landing page is the site's root; a site with no root is not a site;
+// - the record pages ARE the explorer's substance, linked from every other
+// explorer page, so they follow the explorer's switch and cannot be turned
+// off beneath it.
+//
+// Switching the explorer off takes every explorer page with it (the record
+// pages, the graph, the timeline, the glossary and the status page), and the
+// header's link to it. Each other switch removes its page, its navigation entry
+// and every link the renderer would have drawn to it.
+
+// PageSwitches is the `pages` block. A nil field is on.
+type PageSwitches struct {
+ Landing *bool `json:"landing,omitempty"`
+ Explorer *bool `json:"explorer,omitempty"`
+ RecordPages *bool `json:"record_pages,omitempty"`
+ Graph *bool `json:"graph,omitempty"`
+ Timeline *bool `json:"timeline,omitempty"`
+ Glossary *bool `json:"glossary,omitempty"`
+ Status *bool `json:"status,omitempty"`
+}
+
+// PageNames is the closed page set, in the order the manifest documents it.
+var PageNames = []string{"landing", "explorer", "record_pages", "graph", "timeline", "glossary", "status"}
+
+// on reports a switch: absent is on.
+func on(b *bool) bool { return b == nil || *b }
+
+// pageSet is the resolved switches the renderer consults.
+type pageSet struct {
+ explorer, graph, timeline, glossary, status bool
+}
+
+// resolve folds the explorer switch over the pages beneath it.
+func (p PageSwitches) resolve() pageSet {
+ ex := on(p.Explorer)
+ return pageSet{
+ explorer: ex,
+ graph: ex && on(p.Graph),
+ timeline: ex && on(p.Timeline),
+ glossary: ex && on(p.Glossary),
+ status: ex && on(p.Status),
+ }
+}
+
+// validate refuses the two switches the set cannot honour.
+func (p PageSwitches) validate(bad func(string, ...any) error) error {
+ if !on(p.Landing) {
+ return bad("pages.landing is false, but the landing page is the site's root and cannot be switched off")
+ }
+ if on(p.Explorer) && !on(p.RecordPages) {
+ return bad("pages.record_pages is false while the explorer is on; the record pages are what every explorer page links to, so they follow pages.explorer")
+ }
+ if !on(p.Explorer) {
+ for _, f := range []struct {
+ key string
+ v *bool
+ }{{"record_pages", p.RecordPages}, {"graph", p.Graph}, {"timeline", p.Timeline}, {"glossary", p.Glossary}, {"status", p.Status}} {
+ if f.v != nil && *f.v {
+ return bad("pages.%s is true while pages.explorer is false; it is an explorer page and goes with the explorer", f.key)
+ }
+ }
+ }
+ return nil
+}
diff --git a/internal/core/site/pages_test.go b/internal/core/site/pages_test.go
new file mode 100644
index 000000000..b939cb85a
--- /dev/null
+++ b/internal/core/site/pages_test.go
@@ -0,0 +1,181 @@
+package site
+
+// The closed page set and its per-page switches (itd-2609061543533170,
+// criterion 4): the same pages for every repository, switched off per
+// repository, never on to something extra.
+
+import (
+ "errors"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// withPages writes a `pages` block into the fixture's manifest.
+func withPages(t *testing.T, f *fixture, block string) {
+ t.Helper()
+ p := filepath.Join(f.Root(), ManifestRelPath)
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := strings.Replace(string(raw), `"schema_version": 1,`, `"schema_version": 1,
+ "pages": `+block+`,`, 1)
+ if s == string(raw) {
+ t.Fatal("the fixture manifest has no schema_version line to anchor the pages block on")
+ }
+ if err := os.WriteFile(p, []byte(s), 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// htmlPages returns every rendered HTML page, keyed by output-relative path.
+func htmlPages(t *testing.T, out string) map[string]string {
+ t.Helper()
+ pages := map[string]string{}
+ err := filepath.WalkDir(out, func(p string, d fs.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(p, ".html") {
+ return err
+ }
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(out, p)
+ pages[filepath.ToSlash(rel)] = string(raw)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pages
+}
+
+var hrefRe = regexp.MustCompile(`href="(/[^"#?]*)`)
+
+// linksInto names every page that links under route.
+func linksInto(pages map[string]string, route string) []string {
+ var from []string
+ for name, html := range pages {
+ for _, m := range hrefRe.FindAllStringSubmatch(html, -1) {
+ if strings.HasPrefix(m[1], "/"+route) {
+ from = append(from, name+" → "+m[1])
+ }
+ }
+ }
+ return from
+}
+
+// TestEveryPageOfTheSetRendersByDefault: with no pages block, the whole set
+// renders — the landing page, the explorer, a record page, the graph, the
+// timeline, the glossary and the status page.
+func TestEveryPageOfTheSetRendersByDefault(t *testing.T) {
+ f := newFixture(t)
+ withGlossary(t, f)
+ out := t.TempDir()
+ buildFixture(t, f, out)
+ pages := htmlPages(t, out)
+ for _, want := range []string{
+ "index.html", "record/index.html", "record/adr/adr-1/index.html", "record/graph/index.html",
+ "record/glossary/index.html", "record/health/index.html",
+ } {
+ if _, ok := pages[want]; !ok {
+ t.Errorf("default build lacks %s", want)
+ }
+ }
+ if !strings.Contains(pages["record/index.html"], `class="panel tl"`) {
+ t.Error("default dashboard lacks the timeline")
+ }
+}
+
+// TestASwitchedOffPageIsGoneAndNothingLinksToIt: each switch removes its page
+// and every link the renderer would have drawn to it, so a reader never meets
+// a link to a page the site does not have.
+func TestASwitchedOffPageIsGoneAndNothingLinksToIt(t *testing.T) {
+ for _, tc := range []struct {
+ key, route string
+ }{
+ {"graph", routeGraph},
+ {"glossary", routeGlossary},
+ {"status", routeHealth},
+ } {
+ t.Run(tc.key, func(t *testing.T) {
+ f := newFixture(t)
+ withGlossary(t, f)
+ withPages(t, f, `{"`+tc.key+`": false}`)
+ out := t.TempDir()
+ buildFixture(t, f, out)
+ pages := htmlPages(t, out)
+ for name := range pages {
+ if strings.HasPrefix(name, tc.route) {
+ t.Errorf("%s is switched off and %s was rendered", tc.key, name)
+ }
+ }
+ if links := linksInto(pages, tc.route); len(links) > 0 {
+ t.Errorf("%s is switched off and pages still link to it: %v", tc.key, links)
+ }
+ if _, ok := pages["record/index.html"]; !ok {
+ t.Error("switching one page off took the explorer with it")
+ }
+ })
+ }
+ t.Run("timeline", func(t *testing.T) {
+ f := newFixture(t)
+ withPages(t, f, `{"timeline": false}`)
+ out := t.TempDir()
+ buildFixture(t, f, out)
+ if strings.Contains(htmlPages(t, out)["record/index.html"], `class="panel tl"`) {
+ t.Error("the timeline is switched off and the dashboard still draws it")
+ }
+ })
+}
+
+// TestTheExplorerSwitchTakesEveryExplorerPage: with the explorer off, the
+// landing page is the site, and its header no longer offers the record.
+func TestTheExplorerSwitchTakesEveryExplorerPage(t *testing.T) {
+ f := newFixture(t)
+ withGlossary(t, f)
+ withPages(t, f, `{"explorer": false}`)
+ out := t.TempDir()
+ buildFixture(t, f, out)
+ pages := htmlPages(t, out)
+ for name := range pages {
+ if name != "index.html" {
+ t.Errorf("the explorer is off and %s was rendered", name)
+ }
+ }
+ for _, r := range []string{"record/", "contributors/", "references/"} {
+ if links := linksInto(pages, r); len(links) > 0 {
+ t.Errorf("the explorer is off and the landing page links into it: %v", links)
+ }
+ }
+}
+
+// TestSwitchesTheSetCannotHonourAreRefused: the landing page and the record
+// pages carry the site, a sub-page cannot be on under an explorer that is off,
+// and a page outside the closed set is a typo, not a request.
+func TestSwitchesTheSetCannotHonourAreRefused(t *testing.T) {
+ for _, block := range []string{
+ `{"landing": false}`,
+ `{"record_pages": false}`,
+ `{"explorer": false, "graph": true}`,
+ `{"blog": true}`,
+ } {
+ t.Run(block, func(t *testing.T) {
+ f := newFixture(t)
+ withPages(t, f, block)
+ _, err := LoadManifest(f.Root())
+ if !errors.Is(err, ErrManifestInvalid) {
+ t.Fatalf("pages %s: err = %v, want ErrManifestInvalid", block, err)
+ }
+ })
+ }
+ f := newFixture(t)
+ withPages(t, f, `{"explorer": false, "record_pages": false}`)
+ if _, err := LoadManifest(f.Root()); err != nil {
+ t.Fatalf("switching the record pages off with the explorer is consistent: %v", err)
+ }
+}
diff --git a/internal/core/site/providers.go b/internal/core/site/providers.go
new file mode 100644
index 000000000..961c1f79c
--- /dev/null
+++ b/internal/core/site/providers.go
@@ -0,0 +1,37 @@
+package site
+
+import (
+ "strings"
+
+ "github.com/intentdriven/abcd/internal/adapter/hosting"
+ "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare"
+)
+
+// adapters is the provider list: every hosting adapter abcd ships, by manifest
+// key. A second provider is one implementation of hosting.Adapter and one entry
+// here; the verb does not change (itd-2609061543533170 criterion 3).
+var adapters = []hosting.Adapter{cloudflare.Adapter{}}
+
+// DefaultProvider is the provider setup routes through when the manifest names
+// none.
+const DefaultProvider = "cloudflare"
+
+// Providers is the adapter list, by name, in the order abcd ships them.
+func Providers() []string {
+ out := make([]string, 0, len(adapters))
+ for _, a := range adapters {
+ out = append(out, a.Name())
+ }
+ return out
+}
+
+func adapterNamed(name string) (hosting.Adapter, bool) {
+ for _, a := range adapters {
+ if a.Name() == name {
+ return a, true
+ }
+ }
+ return nil, false
+}
+
+func providerList() string { return strings.Join(Providers(), ", ") }
diff --git a/internal/core/site/timeline.go b/internal/core/site/timeline.go
index 10defca41..4f7a5cc8a 100644
--- a/internal/core/site/timeline.go
+++ b/internal/core/site/timeline.go
@@ -412,7 +412,14 @@ func (e *explorer) mark(n ExportNode, p tlPoint, r float64, colour string) strin
case "fade":
extra = ` opacity="0.45"`
}
- return `` +
+ // A mark opens the record in the graph; with the graph switched off it
+ // opens the record's own page instead, so it never points at a page the
+ // site does not have.
+ href := "/" + escapeAttr(routeGraph) + "?focus=" + escapeAttr(n.ID)
+ if !e.pages.graph {
+ href = "/" + escapeAttr(RecordRoute(n))
+ }
+ return `` +
`` + escapeText(n.ID+" · "+n.Date+" · "+n.Lifecycle) + `
` + escapeText(shortTitle(n)) + `` +
``
From 593b598be5a501d579fa88b1437c509f68156f92 Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:06:15 +0100
Subject: [PATCH 04/26] refactor(scaffold): share the all-or-nothing writer as
WriteFiles
`abcd site setup` lays machinery into a managed repository the way the
release scaffold does (spc-2609212141407459: the verb reuses the launch
scaffold's workflow writer), so the two-pass classify-then-write logic moves
into WriteFiles, which Scaffold now calls unchanged. It gains one
disposition: a seed file the repository owns once it exists is kept,
never refused and never overwritten.
Assisted-by: Claude:claude-opus-5-5
---
internal/core/launch/scaffold/scaffold.go | 103 +++++++++++-------
.../core/launch/scaffold/writefiles_test.go | 55 ++++++++++
2 files changed, 118 insertions(+), 40 deletions(-)
create mode 100644 internal/core/launch/scaffold/writefiles_test.go
diff --git a/internal/core/launch/scaffold/scaffold.go b/internal/core/launch/scaffold/scaffold.go
index f4fe34b45..08b90adb5 100644
--- a/internal/core/launch/scaffold/scaffold.go
+++ b/internal/core/launch/scaffold/scaffold.go
@@ -38,6 +38,9 @@ const (
// sibling was hand-edited) or a write faulted first, so it was NOT written. The
// scaffold is all-or-nothing, so this reports honestly that nothing landed.
StatusSkipped FileStatus = "skipped"
+ // StatusKept — a seed file (PlannedFile.Seed) that already exists: it is the
+ // repository's own, so it was left exactly as it is.
+ StatusKept FileStatus = "kept"
)
// disposition is the pre-write classification of a target: what it is on disk,
@@ -105,42 +108,71 @@ func Scaffold(req Request) (Report, error) {
}
report := Report{Substitutions: subs, DefaultBranch: branch, GoVersion: goVersion}
- planned := []struct {
- rel string
- data []byte
- }{
- {ReleaseYMLPath, rendered.ReleaseYML},
- {AutoReleaseYMLPath, rendered.AutoReleaseYML},
- {RunbookPath, rendered.Runbook},
+ outcomes, wrote, refused, err := WriteFiles(req.RepoRoot, []PlannedFile{
+ {Path: ReleaseYMLPath, Data: rendered.ReleaseYML},
+ {Path: AutoReleaseYMLPath, Data: rendered.AutoReleaseYML},
+ {Path: RunbookPath, Data: rendered.Runbook},
+ }, req.Confirm)
+ report.Files, report.Wrote, report.Refused = outcomes, wrote, refused
+ if err != nil {
+ return report, err
}
+ report.NoOp = wrote == 0
+ return report, nil
+}
+// PlannedFile is one file a WriteFiles run places, at a slash-separated
+// repo-relative Path.
+type PlannedFile struct {
+ Path string
+ Data []byte
+ // Seed marks a file the repository owns once it exists: written when
+ // absent, and otherwise left exactly as it is (StatusKept), never refused
+ // and never overwritten, --confirm or not. Machinery is the opposite: abcd
+ // owns its bytes, so a copy that differs is refused or, confirmed, replaced.
+ Seed bool
+}
+
+// WriteFiles is the scaffold's writer, shared with every verb that lays
+// machinery into a managed repository (`abcd site setup` is the second). It is
+// idempotent and fail-safe:
+//
+// - a file absent on disk is written;
+// - a file byte-identical to the planned bytes is a no-op (StatusCurrent);
+// - a machinery file that exists and DIFFERS is REFUSED and left untouched
+// unless confirm is set, in which case it is overwritten;
+// - a seed file that exists is kept (StatusKept) whatever it holds.
+//
+// A run that refuses any file writes NOTHING and returns ErrScaffoldBlocked with
+// the outcomes, so the caller can render exactly what was and was not touched —
+// no partial half-write.
+func WriteFiles(repoRoot string, planned []PlannedFile, confirm bool) (outcomes []FileOutcome, wrote, refused int, err error) {
// First pass: classify every file WITHOUT writing. A refusal on any file with
- // Confirm unset aborts the whole run before a single write, so the scaffold is
+ // confirm unset aborts the whole run before a single write, so the scaffold is
// all-or-nothing rather than half-applied. Nothing is marked "written" here —
// a planned write is only tentative until the second pass commits it, so the
// report never claims a file landed that did not (the StatusWritten contract).
- outcomes := make([]FileOutcome, len(planned))
+ outcomes = make([]FileOutcome, len(planned))
writeNeeded := make([]bool, len(planned))
overwrite := make([]bool, len(planned))
- refused := 0
for i, p := range planned {
- abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel))
- disp, detail := classify(abs, p.data)
- switch disp {
- case dispCurrent:
- outcomes[i] = FileOutcome{Path: p.rel, Status: StatusCurrent}
- case dispAbsent:
+ abs := filepath.Join(repoRoot, filepath.FromSlash(p.Path))
+ disp, detail := classify(abs, p.Data)
+ switch {
+ case disp == dispCurrent:
+ outcomes[i] = FileOutcome{Path: p.Path, Status: StatusCurrent}
+ case disp == dispAbsent:
writeNeeded[i] = true
- outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written
- case dispDiffers:
- if req.Confirm {
- writeNeeded[i] = true
- overwrite[i] = true
- outcomes[i] = FileOutcome{Path: p.rel, Status: StatusSkipped} // provisional until written
- } else {
- refused++
- outcomes[i] = FileOutcome{Path: p.rel, Status: StatusRefused, Detail: detail}
- }
+ outcomes[i] = FileOutcome{Path: p.Path, Status: StatusSkipped} // provisional until written
+ case p.Seed:
+ outcomes[i] = FileOutcome{Path: p.Path, Status: StatusKept, Detail: "present: the repository's own, left as it is"}
+ case confirm:
+ writeNeeded[i] = true
+ overwrite[i] = true
+ outcomes[i] = FileOutcome{Path: p.Path, Status: StatusSkipped} // provisional until written
+ default:
+ refused++
+ outcomes[i] = FileOutcome{Path: p.Path, Status: StatusRefused, Detail: detail}
}
}
@@ -153,28 +185,23 @@ func Scaffold(req Request) (Report, error) {
outcomes[i].Detail = "not written: the run refused because another file was hand-edited (all-or-nothing)"
}
}
- report.Files = outcomes
- report.Refused = refused
- return report, ErrScaffoldBlocked
+ return outcomes, 0, refused, ErrScaffoldBlocked
}
// Second pass: commit the writes. Every file that reaches here is either a
// create or a confirmed overwrite; a file becomes StatusWritten only once its
// write actually succeeds.
- wrote := 0
for i, p := range planned {
if !writeNeeded[i] {
continue
}
- abs := filepath.Join(req.RepoRoot, filepath.FromSlash(p.rel))
- if err := fsutil.WriteFileAtomicPreserveMode(abs, p.data); err != nil {
+ abs := filepath.Join(repoRoot, filepath.FromSlash(p.Path))
+ if err := fsutil.WriteFileAtomicPreserveMode(abs, p.Data); err != nil {
// The atomic writer never leaves a half-written file; the files written
// before this fault are marked written, this one and any later planned
// file stay StatusSkipped (not written), so the report matches disk.
outcomes[i].Detail = "not written: a write faulted on this file"
- report.Files = outcomes
- report.Wrote = wrote
- return report, fmt.Errorf("scaffold: write %s: %w", p.rel, err)
+ return outcomes, wrote, 0, fmt.Errorf("scaffold: write %s: %w", p.Path, err)
}
outcomes[i].Status = StatusWritten
if overwrite[i] {
@@ -182,11 +209,7 @@ func Scaffold(req Request) (Report, error) {
}
wrote++
}
-
- report.Files = outcomes
- report.Wrote = wrote
- report.NoOp = wrote == 0
- return report, nil
+ return outcomes, wrote, 0, nil
}
// classify reports whether abs is absent (→ dispAbsent, write it), byte-equal to
diff --git a/internal/core/launch/scaffold/writefiles_test.go b/internal/core/launch/scaffold/writefiles_test.go
new file mode 100644
index 000000000..59a1ee1d4
--- /dev/null
+++ b/internal/core/launch/scaffold/writefiles_test.go
@@ -0,0 +1,55 @@
+package scaffold
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// TestWriteFilesKeepsASeedAndRefusesDriftedMachinery: a seed file the
+// repository already has is its own and is kept whatever it holds, while a
+// machinery file that drifted refuses the whole run, and the refusal writes
+// nothing — not even the absent seed beside it.
+func TestWriteFilesKeepsASeedAndRefusesDriftedMachinery(t *testing.T) {
+ root := t.TempDir()
+ must := func(err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ must(os.MkdirAll(filepath.Join(root, "a"), 0o755))
+ must(os.WriteFile(filepath.Join(root, "a", "seed.json"), []byte("the repository's own\n"), 0o644))
+
+ out, wrote, refused, err := WriteFiles(root, []PlannedFile{
+ {Path: "a/seed.json", Data: []byte("abcd's default\n"), Seed: true},
+ {Path: "a/new-seed.json", Data: []byte("new\n"), Seed: true},
+ {Path: "b/machine.yml", Data: []byte("machinery\n")},
+ }, false)
+ if err != nil || wrote != 2 || refused != 0 {
+ t.Fatalf("first run: wrote %d refused %d err %v", wrote, refused, err)
+ }
+ if out[0].Status != StatusKept {
+ t.Fatalf("a present seed is %q, want kept", out[0].Status)
+ }
+ if got, _ := os.ReadFile(filepath.Join(root, "a", "seed.json")); string(got) != "the repository's own\n" {
+ t.Fatalf("the present seed was rewritten: %q", got)
+ }
+
+ must(os.WriteFile(filepath.Join(root, "b", "machine.yml"), []byte("hand-edited\n"), 0o644))
+ must(os.Remove(filepath.Join(root, "a", "new-seed.json")))
+ out, wrote, refused, err = WriteFiles(root, []PlannedFile{
+ {Path: "a/new-seed.json", Data: []byte("new\n"), Seed: true},
+ {Path: "b/machine.yml", Data: []byte("machinery\n")},
+ }, false)
+ if !errors.Is(err, ErrScaffoldBlocked) || wrote != 0 || refused != 1 {
+ t.Fatalf("drifted machinery: wrote %d refused %d err %v", wrote, refused, err)
+ }
+ if _, serr := os.Stat(filepath.Join(root, "a", "new-seed.json")); !os.IsNotExist(serr) {
+ t.Fatal("a refused run wrote the absent seed")
+ }
+ if out[1].Status != StatusRefused {
+ t.Fatalf("drifted machinery is %q, want refused", out[1].Status)
+ }
+}
From 982fc805062550deef67eb447cc573b824bccfdc Mon Sep 17 00:00:00 2001
From: REPPL <77722411+REPPL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 00:06:18 +0100
Subject: [PATCH 05/26] feat(site): `site setup` takes a managed repository's
site to a live address
The verb's core (itd-2609061543533170, spc-2609212141407459 scope 1),
in three stages reported one by one:
- the repository: the composition derived from the identity block and the
documentation, the site's static inputs seeded from abcd's own, the
render-on-release-then-deploy workflow, and the provider's host
configuration, written through the scaffold's writer so a second run
writes nothing and drifted machinery refuses the whole run;
- the forge: the site-render and site environments, restricted to the
default branch and release tags, created through the GitHub API as the
person who invoked the verb, only after a confirmation naming the changes
(adr-44);
- the host: with a hosting credential on this machine, the adapter creates
and routes the host and reports the address, again only once confirmed;
without one the stage stops and the exact remaining step is printed.
No secret value passes through abcd: the deploy environment's secrets are
checked by name and their `gh secret set` commands printed. The forge and
the provider are exercised against in-process fakes that fail every call.
Assisted-by: Claude:claude-opus-5-5
---
internal/core/ahoy/remote.go | 16 +
internal/core/site/forge.go | 160 ++++
internal/core/site/setup.go | 713 +++++++++++++++++
internal/core/site/setup_test.go | 628 +++++++++++++++
internal/core/site/setupsrc/headers | 133 ++++
internal/core/site/setupsrc/record.js | 897 ++++++++++++++++++++++
internal/core/site/setupsrc/site.css | 717 +++++++++++++++++
internal/core/site/setupsrc/site.js | 108 +++
internal/core/site/setupsrc/site.yml.tmpl | 182 +++++
internal/core/site/setupsrc/ui.json | 116 +++
10 files changed, 3670 insertions(+)
create mode 100644 internal/core/site/forge.go
create mode 100644 internal/core/site/setup.go
create mode 100644 internal/core/site/setup_test.go
create mode 100644 internal/core/site/setupsrc/headers
create mode 100644 internal/core/site/setupsrc/record.js
create mode 100644 internal/core/site/setupsrc/site.css
create mode 100644 internal/core/site/setupsrc/site.js
create mode 100644 internal/core/site/setupsrc/site.yml.tmpl
create mode 100644 internal/core/site/setupsrc/ui.json
diff --git a/internal/core/ahoy/remote.go b/internal/core/ahoy/remote.go
index 32466ad86..8325880b6 100644
--- a/internal/core/ahoy/remote.go
+++ b/internal/core/ahoy/remote.go
@@ -563,3 +563,19 @@ func writeRepoSettingsMirror(cwd, repo string, merge RemoteMergeHygiene) (bool,
}
return true, nil
}
+
+// GitHubRepo is the owner/name this checkout's origin remote names, under the
+// same github.com-only, plain-name rules the remote apply writes behind. Every
+// verb that writes to the forge resolves its repository here, so there is one
+// answer to "which repository may this write reach".
+func GitHubRepo(cwd string) (string, error) { return resolveGitHubRepo(cwd) }
+
+// GH runs one `gh` subcommand under cwd with the same bounds the remote apply
+// uses: a timeout, a capped read, and the caller's own authenticated identity,
+// so a forge write is made by the person who invoked the verb and abcd never
+// holds a forge token. Callers pass `--hostname github.com` explicitly for the
+// reason githubHost states.
+func GH(cwd string, stdin []byte, args ...string) ([]byte, error) { return runGH(cwd, stdin, args...) }
+
+// GitHubHost is the API host every forge request names explicitly.
+const GitHubHost = githubHost
diff --git a/internal/core/site/forge.go b/internal/core/site/forge.go
new file mode 100644
index 000000000..3f1654f0c
--- /dev/null
+++ b/internal/core/site/forge.go
@@ -0,0 +1,160 @@
+package site
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/url"
+
+ "github.com/intentdriven/abcd/internal/core/ahoy"
+)
+
+// Forge is the slice of the forge `abcd site setup` reads and writes: the
+// deployment environments the site workflow runs in, and the NAMES of the
+// secrets one of them holds. It never reads or writes a secret's value — the
+// forge encrypts those, and setting one is the person's step.
+type Forge interface {
+ // Repo names the repository every call acts on.
+ Repo() string
+ // Environments reads every environment the repository has, by name.
+ Environments(ctx context.Context) (map[string]EnvironmentState, error)
+ // Policies reads one environment's deployment branch and tag policies.
+ Policies(ctx context.Context, env string) ([]BranchPolicy, error)
+ // PutEnvironment creates env, or updates it, restricted to custom policies.
+ PutEnvironment(ctx context.Context, env string) error
+ // AddPolicy admits one branch or tag pattern to env.
+ AddPolicy(ctx context.Context, env string, p BranchPolicy) error
+ // SecretNames lists the names of env's secrets. Values are never read.
+ SecretNames(ctx context.Context, env string) ([]string, error)
+}
+
+// EnvironmentState is one environment's deployment policy as the forge reports
+// it. An environment with no policy at all admits every ref.
+type EnvironmentState struct {
+ ProtectedBranches bool
+ CustomBranchPolicies bool
+}
+
+// BranchPolicy is one deployment policy: a branch or tag name pattern.
+type BranchPolicy struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+// ghForge is Forge over the GitHub CLI, acting as the person who invoked the
+// verb (ahoy.GH): abcd holds no forge token.
+type ghForge struct {
+ dir string
+ repo string
+}
+
+// GitHubForge is the forge for the checkout at dir, or an error saying why the
+// checkout names no GitHub repository abcd may act on.
+func GitHubForge(dir string) (Forge, error) {
+ repo, err := ahoy.GitHubRepo(dir)
+ if err != nil {
+ return nil, err
+ }
+ return &ghForge{dir: dir, repo: repo}, nil
+}
+
+func (g *ghForge) Repo() string { return g.repo }
+
+func (g *ghForge) api(stdin []byte, method, path string) ([]byte, error) {
+ args := []string{"api", "--hostname", ahoy.GitHubHost, "-H", "Accept: application/vnd.github+json"}
+ if method != "" {
+ args = append(args, "--method", method)
+ }
+ args = append(args, path)
+ if stdin != nil {
+ args = append(args, "--input", "-")
+ }
+ return ahoy.GH(g.dir, stdin, args...)
+}
+
+// envPath builds an environment path. The environment names are this
+// package's own constants, escaped regardless.
+func (g *ghForge) envPath(env, rest string) string {
+ return "repos/" + g.repo + "/environments/" + url.PathEscape(env) + rest
+}
+
+func (g *ghForge) Environments(context.Context) (map[string]EnvironmentState, error) {
+ out, err := g.api(nil, "", "repos/"+g.repo+"/environments?per_page=100")
+ if err != nil {
+ return nil, err
+ }
+ var doc struct {
+ Environments []struct {
+ Name string `json:"name"`
+ Policy *struct {
+ Protected bool `json:"protected_branches"`
+ Custom bool `json:"custom_branch_policies"`
+ } `json:"deployment_branch_policy"`
+ } `json:"environments"`
+ }
+ if err := json.Unmarshal(out, &doc); err != nil {
+ return nil, errors.New("the environments response could not be read as JSON")
+ }
+ envs := map[string]EnvironmentState{}
+ for _, e := range doc.Environments {
+ st := EnvironmentState{}
+ if e.Policy != nil {
+ st.ProtectedBranches, st.CustomBranchPolicies = e.Policy.Protected, e.Policy.Custom
+ }
+ envs[e.Name] = st
+ }
+ return envs, nil
+}
+
+func (g *ghForge) Policies(_ context.Context, env string) ([]BranchPolicy, error) {
+ out, err := g.api(nil, "", g.envPath(env, "/deployment-branch-policies?per_page=100"))
+ if err != nil {
+ return nil, err
+ }
+ var doc struct {
+ Policies []BranchPolicy `json:"branch_policies"`
+ }
+ if err := json.Unmarshal(out, &doc); err != nil {
+ return nil, errors.New("the deployment policies response could not be read as JSON")
+ }
+ for i := range doc.Policies {
+ if doc.Policies[i].Type == "" {
+ doc.Policies[i].Type = "branch"
+ }
+ }
+ return doc.Policies, nil
+}
+
+func (g *ghForge) PutEnvironment(_ context.Context, env string) error {
+ body, _ := json.Marshal(map[string]any{
+ "deployment_branch_policy": map[string]bool{"protected_branches": false, "custom_branch_policies": true},
+ })
+ _, err := g.api(body, "PUT", g.envPath(env, ""))
+ return err
+}
+
+func (g *ghForge) AddPolicy(_ context.Context, env string, p BranchPolicy) error {
+ body, _ := json.Marshal(p)
+ _, err := g.api(body, "POST", g.envPath(env, "/deployment-branch-policies"))
+ return err
+}
+
+func (g *ghForge) SecretNames(_ context.Context, env string) ([]string, error) {
+ out, err := g.api(nil, "", g.envPath(env, "/secrets?per_page=100"))
+ if err != nil {
+ return nil, err
+ }
+ var doc struct {
+ Secrets []struct {
+ Name string `json:"name"`
+ } `json:"secrets"`
+ }
+ if err := json.Unmarshal(out, &doc); err != nil {
+ return nil, errors.New("the secrets response could not be read as JSON")
+ }
+ names := make([]string, 0, len(doc.Secrets))
+ for _, s := range doc.Secrets {
+ names = append(names, s.Name)
+ }
+ return names, nil
+}
diff --git a/internal/core/site/setup.go b/internal/core/site/setup.go
new file mode 100644
index 000000000..ec7a68ef4
--- /dev/null
+++ b/internal/core/site/setup.go
@@ -0,0 +1,713 @@
+package site
+
+// `abcd site setup` (itd-2609061543533170, spc-2609212141407459 scope 1): one
+// verb that takes a managed repository's site from the checkout to a live
+// address.
+//
+// It works in three stages, in this order, and each stage's outcome is
+// reported whether or not the next one runs:
+//
+// 1. THE REPOSITORY. The composition (.abcd/site.json, derived from the
+// identity block and the documentation), the site's static inputs under
+// site-src/, the render-on-release-then-deploy workflow, and the
+// provider's host configuration. Written through the launch scaffold's
+// writer, so a file already current is not rewritten, a file the
+// repository owns once it exists (the composition and the static inputs)
+// is kept, and machinery that drifted refuses the whole stage — with
+// nothing written and nothing remote attempted — unless it is confirmed.
+// 2. THE FORGE. The two deployment environments the workflow runs in,
+// restricted to the default branch and release tags, created or corrected
+// through the forge's API as the person who invoked the verb (adr-44: a
+// remote write only through a dedicated verb, invoked AND confirmed).
+// 3. THE HOST. With a hosting credential on this machine, the provider
+// adapter creates the host, routes the domain to it and reports the live
+// address, again only after a confirmation that names the changes.
+// Without one, the stage stops and says exactly what remains.
+//
+// What never happens: a secret's value is read or written anywhere (the
+// forge's environment secrets are the person's step, printed as the exact
+// command), the credential is written into the repository or the report, or a
+// file outside the list above is touched.
+
+import (
+ "bytes"
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+ "text/template"
+
+ "github.com/intentdriven/abcd/internal/adapter/hosting"
+ "github.com/intentdriven/abcd/internal/core/ahoy"
+ "github.com/intentdriven/abcd/internal/core/credential"
+ "github.com/intentdriven/abcd/internal/core/launch/scaffold"
+ "github.com/intentdriven/abcd/internal/core/positioning"
+ "github.com/intentdriven/abcd/internal/gitutil"
+)
+
+//go:embed setupsrc/ui.json setupsrc/site.css setupsrc/site.js setupsrc/record.js setupsrc/headers setupsrc/site.yml.tmpl
+var setupSources embed.FS
+
+// seedNames are the static inputs a managed repository is seeded with under
+// site-src/: byte copies of abcd's own (TestTheSeedSourcesAreAbcdsOwn). The
+// redirects map is not among them: it is abcd's own URL history.
+var seedNames = []string{"ui.json", "site.css", "site.js", "record.js", "headers"}
+
+// SiteWorkflowRelPath is the workflow setup writes.
+const SiteWorkflowRelPath = ".github/workflows/site.yml"
+
+// The two deployment environments the workflow runs in.
+const (
+ EnvRender = "site-render"
+ EnvDeploy = "site"
+)
+
+// abcdReleaseRepo is where the workflow downloads the abcd binary it renders
+// with, and whose release workflow must have signed it.
+const abcdReleaseRepo = "intentdriven/abcd"
+
+// Overall statuses.
+const (
+ // StatusChanged: something was written, locally or remotely.
+ StatusChanged = "changed"
+ // StatusNoChange: every file, environment and host was already current.
+ StatusNoChange = "no_change"
+ // StatusDeclined: a confirmation was declined, so a remote write did not
+ // happen.
+ StatusDeclined = "declined"
+ // StatusRefused: a gate or a failure stopped a stage.
+ StatusRefused = "refused"
+)
+
+// Remote outcome statuses (an environment).
+const (
+ RemoteCurrent = "current"
+ RemoteWritten = "written"
+ RemoteDeclined = "declined"
+ RemoteRefused = "refused"
+ RemoteUnreachable = "unreachable"
+ RemoteNotReached = "not_reached"
+)
+
+// Host outcome statuses.
+const (
+ HostCurrent = "current"
+ HostWritten = "written"
+ HostDeclined = "declined"
+ HostRefused = "refused"
+ HostNoCredential = "no_credential"
+ HostNotReached = "not_reached"
+)
+
+// ErrNotManaged refuses a folder abcd does not manage.
+var ErrNotManaged = errors.New("site setup: this is not a repository abcd manages (run `abcd ahoy install` first)")
+
+// Asker confirms a remote write. A nil Asker declines.
+type Asker interface {
+ Confirm(question string) bool
+}
+
+// SetupRequest is one run of the verb.
+type SetupRequest struct {
+ // RepoRoot is any directory inside the repository.
+ RepoRoot string
+ // Name and Domain seed the hosting block when the composition has none.
+ // Name defaults to the repository's own name.
+ Name, Domain string
+ // Confirm replaces machinery that drifted from what setup writes.
+ Confirm bool
+ // Asker confirms each remote write; nil declines them all.
+ Asker Asker
+ // Forge is the repository's forge; nil resolves GitHub through gh.
+ Forge Forge
+ // Credentials resolves the hosting credential by name; nil is this
+ // machine's store.
+ Credentials credential.Source
+ // Adapter overrides the provider the composition names (tests point it at
+ // a fake); nil resolves the composition's provider from the list.
+ Adapter hosting.Adapter
+ Context context.Context
+}
+
+// EnvironmentOutcome is one environment's result.
+type EnvironmentOutcome struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Changes []string `json:"changes,omitempty"`
+}
+
+// HostOutcome is the host stage's result.
+type HostOutcome struct {
+ Provider string `json:"provider"`
+ Name string `json:"name"`
+ Domain string `json:"domain,omitempty"`
+ Status string `json:"status"`
+ Changes []string `json:"changes,omitempty"`
+ Address string `json:"address,omitempty"`
+ Detail string `json:"detail,omitempty"`
+}
+
+// SetupResult is what the verb did, stage by stage, and what remains.
+type SetupResult struct {
+ Status string `json:"status"`
+ Repo string `json:"repo,omitempty"`
+ Files []scaffold.FileOutcome `json:"files"`
+ Environments []EnvironmentOutcome `json:"environments"`
+ Host HostOutcome `json:"host"`
+ // Remaining are the exact steps left for the person, in order.
+ Remaining []string `json:"remaining,omitempty"`
+ // Notes say what the verb deliberately did not do, and why.
+ Notes []string `json:"notes,omitempty"`
+}
+
+// Setup runs the verb. It returns an error only for a gate that stops it before
+// any stage runs; every later failure is a refused stage in the result.
+func Setup(req SetupRequest) (SetupResult, error) {
+ ctx := req.Context
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ root, err := setupRoot(req.RepoRoot)
+ if err != nil {
+ return SetupResult{}, err
+ }
+ det, err := ahoy.Detect(root)
+ if err != nil {
+ return SetupResult{}, fmt.Errorf("site setup: could not classify this folder: %w", err)
+ }
+ if det.FolderKind != ahoy.ManagedRepo {
+ return SetupResult{}, ErrNotManaged
+ }
+
+ manifest, manifestBytes, err := setupComposition(root, req)
+ if err != nil {
+ return SetupResult{}, err
+ }
+ hostingBlock := *manifest.Hosting
+ adapter := req.Adapter
+ if adapter == nil {
+ a, ok := adapterNamed(hostingBlock.Provider)
+ if !ok {
+ return SetupResult{}, fmt.Errorf("site setup: hosting.provider %q is not a provider abcd ships (%s)", hostingBlock.Provider, providerList())
+ }
+ adapter = a
+ }
+ s := hosting.Site{Name: hostingBlock.Name, Domain: hostingBlock.Domain}
+ branch, _ := scaffold.DeriveRepoFacts(root)
+
+ res := SetupResult{Host: HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain, Status: HostNotReached}}
+
+ // Stage 1: the repository.
+ planned, err := plannedFiles(root, manifest, manifestBytes, branch, adapter, s)
+ if err != nil {
+ return SetupResult{}, err
+ }
+ outcomes, wrote, _, werr := scaffold.WriteFiles(root, planned, req.Confirm)
+ res.Files = outcomes
+ if werr != nil {
+ res.Status = StatusRefused
+ if errors.Is(werr, scaffold.ErrScaffoldBlocked) {
+ res.Notes = append(res.Notes, "a file setup owns was edited by hand, so nothing was written and nothing remote was attempted; "+
+ "re-run with --confirm to replace it")
+ } else {
+ res.Notes = append(res.Notes, "a write failed, so nothing remote was attempted: "+scrubRoot(werr, root))
+ }
+ for _, env := range []string{EnvRender, EnvDeploy} {
+ res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteNotReached})
+ }
+ return res, nil
+ }
+ changed := wrote > 0
+ declined, refused := false, false
+
+ // Stage 2: the forge.
+ forge := req.Forge
+ if forge == nil {
+ f, ferr := GitHubForge(root)
+ if ferr != nil {
+ res.Notes = append(res.Notes, "the forge is not reachable from this checkout, so the environments were not created: "+ferr.Error())
+ } else {
+ forge = f
+ }
+ }
+ envOK := true
+ if forge == nil {
+ envOK = false
+ for _, env := range []string{EnvRender, EnvDeploy} {
+ res.Environments = append(res.Environments, EnvironmentOutcome{Name: env, Status: RemoteUnreachable})
+ }
+ res.Remaining = append(res.Remaining, fmt.Sprintf(
+ "create the forge environments %s and %s, each admitting only branch %s and tags v*, as the workflow's header describes",
+ EnvRender, EnvDeploy, branch))
+ } else {
+ res.Repo = forge.Repo()
+ envs, st, note := setupEnvironments(ctx, forge, branch, req.Asker)
+ res.Environments = envs
+ switch st {
+ case RemoteWritten:
+ changed = true
+ case RemoteDeclined:
+ declined, envOK = true, false
+ res.Remaining = append(res.Remaining, "re-run `abcd site setup` and confirm, to create the environments "+EnvRender+" and "+EnvDeploy)
+ case RemoteRefused:
+ refused, envOK = true, false
+ res.Notes = append(res.Notes, note)
+ }
+ }
+
+ // Stage 3: the host. A forge failure stops here: the environments are what
+ // the deploy runs in, and a host created for a deploy that cannot run is a
+ // half-built site.
+ if refused {
+ res.Host.Detail = "not reached: the forge stage failed"
+ } else {
+ hc, hdeclined, hrefused := setupHost(ctx, adapter, s, req)
+ res.Host = hc
+ switch {
+ case hrefused:
+ refused = true
+ case hdeclined:
+ declined = true
+ case hc.Status == HostWritten:
+ changed = true
+ }
+ if hc.Status == HostNoCredential {
+ step := fmt.Sprintf("store a %s API token under the name %s in %s (mode 0600) and re-run `abcd site setup`, "+
+ "or create the host %s", adapter.Name(), adapter.CredentialName(), credential.StorePath, s.Name)
+ if s.Domain != "" {
+ step += " and route " + s.Domain + " to it"
+ }
+ res.Remaining = append(res.Remaining, step+" in the provider's own console")
+ }
+ if hdeclined {
+ res.Remaining = append(res.Remaining, "re-run `abcd site setup` and confirm, to create and route the host")
+ }
+ }
+
+ // The deploy environment's secrets: never set by abcd (a value would pass
+ // through it), but their presence is readable by name.
+ res.Remaining = append(res.Remaining, secretSteps(ctx, forge, envOK, adapter, &res)...)
+
+ if wrote > 0 {
+ var paths []string
+ for _, f := range res.Files {
+ if f.Status == scaffold.StatusWritten {
+ paths = append(paths, f.Path)
+ }
+ }
+ res.Remaining = append([]string{"commit the written files and push them to " + branch + ": `git add " +
+ strings.Join(paths, " ") + "`"}, res.Remaining...)
+ }
+ res.Notes = append(res.Notes, "the site renders and deploys on the next published release; "+
+ "`gh workflow run site.yml` deploys the latest one now")
+
+ switch {
+ case refused:
+ res.Status = StatusRefused
+ case declined:
+ res.Status = StatusDeclined
+ case changed:
+ res.Status = StatusChanged
+ default:
+ res.Status = StatusNoChange
+ }
+ return res, nil
+}
+
+// setupRoot anchors the run at the working-tree root.
+func setupRoot(dir string) (string, error) {
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return "", err
+ }
+ top, err := gitutil.Run(abs, "rev-parse", "--show-toplevel")
+ if err != nil || strings.TrimSpace(top) == "" {
+ return "", errors.New("site setup: this is not inside a git checkout")
+ }
+ return strings.TrimSpace(top), nil
+}
+
+// scrubRoot keeps the checkout's absolute path out of a message.
+func scrubRoot(err error, root string) string {
+ return strings.ReplaceAll(err.Error(), root, ".")
+}
+
+// setupComposition returns the composition setup works from, and the bytes to
+// write when the repository has none yet (nil when it has one).
+func setupComposition(root string, req SetupRequest) (Manifest, []byte, error) {
+ bad := func(format string, args ...any) error {
+ return fmt.Errorf("%w: %s", ErrManifestInvalid, fmt.Sprintf(format, args...))
+ }
+ m, err := LoadManifest(root)
+ switch {
+ case err == nil:
+ if m.Hosting == nil {
+ h := &Hosting{Provider: DefaultProvider, Name: req.Name, Domain: req.Domain}
+ if h.Name == "" {
+ h.Name = defaultHostName(root)
+ }
+ if err := h.validate(bad); err != nil {
+ return Manifest{}, nil, err
+ }
+ if req.Domain != "" || req.Name != "" {
+ // The composition is the repository's own, so setup does not
+ // rewrite it; the domain has to live in it for the next run to
+ // route the same one.
+ blk, _ := json.Marshal(h)
+ return Manifest{}, nil, fmt.Errorf("site setup: %s exists and declares no hosting block; add `\"hosting\": %s` to it rather than passing --name or --domain",
+ ManifestRelPath, blk)
+ }
+ m.Hosting = h
+ } else if (req.Name != "" && req.Name != m.Hosting.Name) || (req.Domain != "" && req.Domain != m.Hosting.Domain) {
+ return Manifest{}, nil, fmt.Errorf("site setup: %s already names the host %q and domain %q; edit its hosting block to change them",
+ ManifestRelPath, m.Hosting.Name, m.Hosting.Domain)
+ }
+ return m, nil, nil
+ case !os.IsNotExist(err):
+ return Manifest{}, nil, err
+ }
+
+ cfg, ok, cerr := positioning.LoadConfig(root)
+ if cerr != nil {
+ return Manifest{}, nil, fmt.Errorf("site setup: the identity pointer cannot be read: %w", cerr)
+ }
+ if !ok {
+ return Manifest{}, nil, errors.New("site setup: this repository has recorded no identity block, and the landing page's hero is composed from it; record one first with `abcd identity init`")
+ }
+ hero := ""
+ for _, p := range []string{"docs/README.md", "docs/index.md"} {
+ if fi, serr := os.Lstat(filepath.Join(root, filepath.FromSlash(p))); serr == nil && fi.Mode().IsRegular() {
+ hero = p
+ break
+ }
+ }
+ if hero == "" {
+ return Manifest{}, nil, errors.New("site setup: the landing page is composed from the documentation, and this repository has none; write docs/README.md (its first heading and paragraph become the hero) and re-run")
+ }
+ name := req.Name
+ if name == "" {
+ name = defaultHostName(root)
+ }
+ m = Manifest{
+ SchemaVersion: 1,
+ Purpose: "Composition manifest for this repository's site, written by `abcd site setup`. It names WHERE each block " +
+ "of the site comes from and carries no prose. `pages` switches pages of the closed set off; `hosting` names " +
+ "where setup puts the site.",
+ Identity: BlockRef{File: cfg.Block.File, Heading: cfg.Block.Heading},
+ UIStrings: "site-src/ui.json",
+ Home: Home{
+ Hero: Hero{Page: hero, Figure: figureFirstImage},
+ Chapters: []Chapter{{Letter: "a", Page: hero, Layout: LayoutProse}},
+ },
+ Hosting: &Hosting{Provider: DefaultProvider, Name: name, Domain: req.Domain},
+ }
+ if err := m.validate(); err != nil {
+ return Manifest{}, nil, err
+ }
+ raw, err := json.MarshalIndent(m, "", " ")
+ if err != nil {
+ return Manifest{}, nil, err
+ }
+ return m, append(raw, '\n'), nil
+}
+
+// hostCharRe is what a repository name keeps on its way to a host name.
+var hostCharRe = regexp.MustCompile(`[^a-z0-9-]+`)
+
+// defaultHostName is the repository's own name, as a host name: the origin's
+// repository segment when it names one, the checkout's directory otherwise.
+func defaultHostName(root string) string {
+ raw := filepath.Base(root)
+ if repo, err := ahoy.GitHubRepo(root); err == nil {
+ if _, name, ok := strings.Cut(repo, "/"); ok {
+ raw = name
+ }
+ }
+ n := strings.Trim(hostCharRe.ReplaceAllString(strings.ToLower(raw), "-"), "-")
+ if len(n) > 63 {
+ n = strings.TrimRight(n[:63], "-")
+ }
+ return n
+}
+
+// plannedFiles is the repository half.
+func plannedFiles(root string, m Manifest, manifestBytes []byte, branch string, adapter hosting.Adapter, s hosting.Site) ([]scaffold.PlannedFile, error) {
+ var planned []scaffold.PlannedFile
+ if manifestBytes != nil {
+ planned = append(planned, scaffold.PlannedFile{Path: ManifestRelPath, Data: manifestBytes, Seed: true})
+ }
+ for _, name := range seedNames {
+ data, err := setupSources.ReadFile("setupsrc/" + name)
+ if err != nil {
+ return nil, err
+ }
+ rel := "site-src/" + name
+ if name == "ui.json" {
+ rel = m.UIStrings
+ }
+ planned = append(planned, scaffold.PlannedFile{Path: rel, Data: data, Seed: true})
+ }
+ wf, err := renderSiteWorkflow(branch, adapter)
+ if err != nil {
+ return nil, err
+ }
+ planned = append(planned, scaffold.PlannedFile{Path: SiteWorkflowRelPath, Data: wf})
+ rel, data := adapter.HostConfig(s)
+ planned = append(planned, scaffold.PlannedFile{Path: rel, Data: data})
+ // The writer resolves each path under the root by name, so a symlinked
+ // directory on the way would carry a write out of the repository. Refuse
+ // the run before any write rather than follow one.
+ for _, p := range planned {
+ if err := refuseSymlinkedAncestor(root, p.Path); err != nil {
+ return nil, err
+ }
+ }
+ return planned, nil
+}
+
+// refuseSymlinkedAncestor refuses rel when a directory between root and its
+// leaf is a symlink.
+func refuseSymlinkedAncestor(root, rel string) error {
+ parts := strings.Split(rel, "/")
+ cur := root
+ for _, part := range parts[:len(parts)-1] {
+ cur = filepath.Join(cur, part)
+ fi, err := os.Lstat(cur)
+ if os.IsNotExist(err) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("site setup: %s could not be examined", rel)
+ }
+ if fi.Mode()&os.ModeSymlink != 0 || !fi.IsDir() {
+ return fmt.Errorf("site setup: a directory on the way to %s is a symlink or not a directory, so nothing is written through it", rel)
+ }
+ }
+ return nil
+}
+
+// renderSiteWorkflow renders the workflow for the repository's default branch
+// and the provider's deploy step.
+func renderSiteWorkflow(branch string, adapter hosting.Adapter) ([]byte, error) {
+ raw, err := setupSources.ReadFile("setupsrc/site.yml.tmpl")
+ if err != nil {
+ return nil, err
+ }
+ tmpl, err := template.New("site.yml").Delims("<%", "%>").Option("missingkey=error").Parse(string(raw))
+ if err != nil {
+ return nil, err
+ }
+ rel, _ := adapter.HostConfig(hosting.Site{Name: "x"})
+ var buf bytes.Buffer
+ err = tmpl.Execute(&buf, map[string]any{
+ // The branch comes from scaffold.DeriveRepoFacts, allowlisted there
+ // against every YAML metacharacter.
+ "DefaultBranch": branch,
+ "Provider": adapter.Name(),
+ "Secrets": adapter.Secrets(),
+ "DeployStep": adapter.DeployStep(),
+ "HostConfig": rel,
+ "AbcdRepo": abcdReleaseRepo,
+ })
+ return buf.Bytes(), err
+}
+
+// desiredPolicies are the refs each environment admits.
+func desiredPolicies(branch string) []BranchPolicy {
+ return []BranchPolicy{{Name: branch, Type: "branch"}, {Name: "v*", Type: "tag"}}
+}
+
+// setupEnvironments reads both environments, asks once for every change, and
+// applies them in order, stopping at the first failure.
+func setupEnvironments(ctx context.Context, forge Forge, branch string, asker Asker) ([]EnvironmentOutcome, string, string) {
+ names := []string{EnvRender, EnvDeploy}
+ outcomes := make([]EnvironmentOutcome, len(names))
+ fail := func(what string, err error) ([]EnvironmentOutcome, string, string) {
+ for i := range outcomes {
+ if outcomes[i].Status == "" || outcomes[i].Status == RemoteNotReached {
+ outcomes[i] = EnvironmentOutcome{Name: names[i], Status: RemoteRefused, Changes: outcomes[i].Changes}
+ }
+ }
+ return outcomes, RemoteRefused, "the forge refused " + what + ", so the remaining forge and host steps were not attempted: " + err.Error()
+ }
+ envs, err := forge.Environments(ctx)
+ if err != nil {
+ return fail("the environment read", err)
+ }
+ type plan struct {
+ put bool
+ missing []BranchPolicy
+ }
+ plans := make([]plan, len(names))
+ var all []string
+ for i, env := range names {
+ outcomes[i] = EnvironmentOutcome{Name: env, Status: RemoteNotReached}
+ st, exists := envs[env]
+ var have []BranchPolicy
+ if exists && st.CustomBranchPolicies {
+ if have, err = forge.Policies(ctx, env); err != nil {
+ return fail("the deployment policy read for "+env, err)
+ }
+ }
+ p := plan{put: !exists || !st.CustomBranchPolicies || st.ProtectedBranches}
+ for _, want := range desiredPolicies(branch) {
+ if !containsPolicy(have, want) {
+ p.missing = append(p.missing, want)
+ }
+ }
+ plans[i] = p
+ if !exists {
+ outcomes[i].Changes = append(outcomes[i].Changes, "create "+env)
+ } else if p.put {
+ outcomes[i].Changes = append(outcomes[i].Changes, "restrict "+env+" to named branches and tags")
+ }
+ for _, m := range p.missing {
+ outcomes[i].Changes = append(outcomes[i].Changes, "admit "+m.Type+" "+m.Name+" to "+env)
+ }
+ all = append(all, outcomes[i].Changes...)
+ if len(outcomes[i].Changes) == 0 {
+ outcomes[i].Status = RemoteCurrent
+ }
+ }
+ if len(all) == 0 {
+ return outcomes, RemoteCurrent, ""
+ }
+ if asker == nil || !asker.Confirm("Change the deployment environments on "+forge.Repo()+"? ("+strings.Join(all, "; ")+")") {
+ for i := range outcomes {
+ if outcomes[i].Status != RemoteCurrent {
+ outcomes[i].Status = RemoteDeclined
+ }
+ }
+ return outcomes, RemoteDeclined, ""
+ }
+ for i, env := range names {
+ if outcomes[i].Status == RemoteCurrent {
+ continue
+ }
+ if plans[i].put {
+ if err := forge.PutEnvironment(ctx, env); err != nil {
+ return fail("the environment write for "+env, err)
+ }
+ }
+ for _, m := range plans[i].missing {
+ if err := forge.AddPolicy(ctx, env, m); err != nil {
+ return fail("the deployment policy write for "+env, err)
+ }
+ }
+ outcomes[i].Status = RemoteWritten
+ }
+ return outcomes, RemoteWritten, ""
+}
+
+func containsPolicy(have []BranchPolicy, want BranchPolicy) bool {
+ for _, h := range have {
+ if h.Name == want.Name && h.Type == want.Type {
+ return true
+ }
+ }
+ return false
+}
+
+// setupHost is the host stage.
+func setupHost(ctx context.Context, adapter hosting.Adapter, s hosting.Site, req SetupRequest) (out HostOutcome, declined, refused bool) {
+ out = HostOutcome{Provider: adapter.Name(), Name: s.Name, Domain: s.Domain}
+ src := req.Credentials
+ if src == nil {
+ src = credential.UserMachine()
+ }
+ token, err := src.Resolve(adapter.CredentialName())
+ if errors.Is(err, credential.ErrNotSet) {
+ out.Status = HostNoCredential
+ out.Detail = "no " + adapter.CredentialName() + " credential on this machine, so the host was not contacted"
+ return out, false, false
+ }
+ if err != nil {
+ out.Status = HostRefused
+ out.Detail = err.Error()
+ return out, false, true
+ }
+ p := adapter.Connect(token)
+ refuse := func(err error) (HostOutcome, bool, bool) {
+ out.Status = HostRefused
+ out.Detail = strings.ReplaceAll(err.Error(), token, "[credential]")
+ return out, false, true
+ }
+ st, err := p.Inspect(ctx, s)
+ if err != nil {
+ return refuse(err)
+ }
+ if !st.Exists {
+ out.Changes = append(out.Changes, "create "+adapter.Name()+" host "+s.Name)
+ }
+ if !st.Routed {
+ out.Changes = append(out.Changes, "route "+s.Domain+" to "+s.Name)
+ }
+ if len(out.Changes) > 0 {
+ if req.Asker == nil || !req.Asker.Confirm("Change the host on "+adapter.Name()+"? ("+strings.Join(out.Changes, "; ")+")") {
+ out.Status = HostDeclined
+ return out, true, false
+ }
+ if !st.Exists {
+ if err := p.Create(ctx, s); err != nil {
+ return refuse(err)
+ }
+ }
+ if !st.Routed {
+ if err := p.Route(ctx, s); err != nil {
+ return refuse(err)
+ }
+ }
+ }
+ addr, err := p.Address(ctx, s)
+ if err != nil {
+ return refuse(err)
+ }
+ out.Address = addr
+ out.Status = HostCurrent
+ if len(out.Changes) > 0 {
+ out.Status = HostWritten
+ }
+ return out, false, false
+}
+
+// secretSteps names the deploy environment's secrets that are not set, as the
+// exact commands that set them. gh reads the value from the terminal, so it
+// never passes through abcd or a shell history.
+func secretSteps(ctx context.Context, forge Forge, envOK bool, adapter hosting.Adapter, res *SetupResult) []string {
+ want := adapter.Secrets()
+ missing := want
+ repo := res.Repo
+ if forge != nil && envOK {
+ names, err := forge.SecretNames(ctx, EnvDeploy)
+ if err != nil {
+ res.Notes = append(res.Notes, "the "+EnvDeploy+" environment's secret names could not be read, so every one is listed: "+err.Error())
+ } else {
+ have := map[string]bool{}
+ for _, n := range names {
+ have[n] = true
+ }
+ missing = nil
+ for _, w := range want {
+ if !have[w] {
+ missing = append(missing, w)
+ }
+ }
+ }
+ }
+ sort.Strings(missing)
+ var steps []string
+ for _, name := range missing {
+ cmd := "gh secret set " + name + " --env " + EnvDeploy
+ if repo != "" {
+ cmd += " --repo " + repo
+ }
+ steps = append(steps, "set the deploy secret: `"+cmd+"`")
+ }
+ return steps
+}
diff --git a/internal/core/site/setup_test.go b/internal/core/site/setup_test.go
new file mode 100644
index 000000000..5cb1c1d1a
--- /dev/null
+++ b/internal/core/site/setup_test.go
@@ -0,0 +1,628 @@
+package site
+
+// `abcd site setup` end to end (itd-2609061543533170, spc-2609212141407459):
+// the writes against a fixture managed repository, the forge against an
+// in-process fake, the provider against cloudflaretest's fake API. Nothing here
+// reaches a network.
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/intentdriven/abcd/internal/adapter/hosting"
+ "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare"
+ "github.com/intentdriven/abcd/internal/adapter/hosting/cloudflare/cloudflaretest"
+ "github.com/intentdriven/abcd/internal/core/credential"
+ "github.com/intentdriven/abcd/internal/gittest"
+)
+
+// --- the fakes -------------------------------------------------------------
+
+type fakeForge struct {
+ envs map[string]EnvironmentState
+ policies map[string][]BranchPolicy
+ secrets map[string][]string
+ fail map[string]error
+ writes []string
+}
+
+func newFakeForge() *fakeForge {
+ return &fakeForge{
+ envs: map[string]EnvironmentState{}, policies: map[string][]BranchPolicy{},
+ secrets: map[string][]string{}, fail: map[string]error{},
+ }
+}
+
+func (f *fakeForge) Repo() string { return "example-owner/example-site" }
+
+func (f *fakeForge) Environments(context.Context) (map[string]EnvironmentState, error) {
+ if err := f.fail["Environments"]; err != nil {
+ return nil, err
+ }
+ out := map[string]EnvironmentState{}
+ for k, v := range f.envs {
+ out[k] = v
+ }
+ return out, nil
+}
+
+func (f *fakeForge) Policies(_ context.Context, env string) ([]BranchPolicy, error) {
+ if err := f.fail["Policies"]; err != nil {
+ return nil, err
+ }
+ return append([]BranchPolicy(nil), f.policies[env]...), nil
+}
+
+func (f *fakeForge) PutEnvironment(_ context.Context, env string) error {
+ if err := f.fail["PutEnvironment"]; err != nil {
+ return err
+ }
+ f.writes = append(f.writes, "put "+env)
+ f.envs[env] = EnvironmentState{CustomBranchPolicies: true}
+ return nil
+}
+
+func (f *fakeForge) AddPolicy(_ context.Context, env string, p BranchPolicy) error {
+ if err := f.fail["AddPolicy"]; err != nil {
+ return err
+ }
+ f.writes = append(f.writes, "policy "+env+" "+p.Type+" "+p.Name)
+ f.policies[env] = append(f.policies[env], p)
+ return nil
+}
+
+func (f *fakeForge) SecretNames(_ context.Context, env string) ([]string, error) {
+ if err := f.fail["SecretNames"]; err != nil {
+ return nil, err
+ }
+ return f.secrets[env], nil
+}
+
+type answer bool
+
+func (a answer) Confirm(string) bool { return bool(a) }
+
+type fixedCredential struct {
+ value string
+ err error
+}
+
+func (c fixedCredential) Resolve(string) (string, error) { return c.value, c.err }
+
+// --- the managed repository --------------------------------------------------
+
+// newManagedRepo builds a small repository abcd manages: the marker block, an
+// identity block and its pointer, one documentation page, a record with a
+// glossary, and a github.com origin.
+func newManagedRepo(t *testing.T) *gittest.Repo {
+ t.Helper()
+ t.Setenv("HOME", t.TempDir())
+ r := gittest.NewRepo(t)
+ r.Write("AGENTS.md", "# Example\n\n\nmanaged\n\n")
+ r.Write(".abcd/positioning.json", `{
+ "schema_version": 1,
+ "block": {"file": ".abcd/development/IDENTITY.md", "heading": "Identity (canonical)"},
+ "severity": "warn",
+ "surfaces": []
+}
+`)
+ r.Write(".abcd/development/IDENTITY.md", "# Identity\n\n## Identity (canonical)\n\n"+
+ "- **Title:** Example Site\n- **Tagline:** An example repository.\n- **Pitch:** It exists to be rendered.\n")
+ r.Write("docs/README.md", "# Example Site\n\nThe example repository's documentation.\n\n## Why\n\nBecause a test needs one.\n")
+ r.Write(".abcd/record-lint.json", `{
+ "roots": [".abcd/development"],
+ "banned_tokens": [],
+ "rules": {"record_schema": {"enabled": true, "severity": "blocker", "record_stores": {
+ "adr": ".abcd/development/decisions/adrs"
+ }}}
+}
+`)
+ r.Write(".abcd/development/decisions/adrs/0001-a-decision.md", `---
+id: adr-1
+slug: a-decision
+status: accepted
+date: 2026-01-02
+supersedes: null
+superseded_by: null
+related_intents: []
+related_rfcs: []
+related_adrs: []
+---
+
+# ADR-1: A decision
+
+The decision's body names a phase.
+`)
+ r.Write(".abcd/development/brief/glossary/README.md", "# Glossary\n\nThe example glossary.\n")
+ r.Write(".abcd/development/brief/glossary/core/README.md", "# core\n\nThe core context.\n")
+ r.Write(".abcd/development/brief/glossary/core/phase.md", glossaryTermFile(
+ "phase", "core", "An ordered stretch of work.", `[]`, "A phase is a stretch of work.\n"))
+ r.Commit("the example repository")
+ r.Git("remote", "add", "origin", "https://github.com/example-owner/example-site.git")
+ return r
+}
+
+type harness struct {
+ repo *gittest.Repo
+ forge *fakeForge
+ host *cloudflaretest.Server
+ cred credential.Source
+ ask Asker
+}
+
+func newHarness(t *testing.T) *harness {
+ t.Helper()
+ return &harness{
+ repo: newManagedRepo(t),
+ forge: newFakeForge(),
+ host: cloudflaretest.New(t),
+ cred: fixedCredential{err: credential.ErrNotSet},
+ ask: answer(true),
+ }
+}
+
+func (h *harness) withCredential() *harness {
+ h.cred = fixedCredential{value: cloudflaretest.Token}
+ return h
+}
+
+func (h *harness) run(t *testing.T, mutate ...func(*SetupRequest)) SetupResult {
+ t.Helper()
+ req := SetupRequest{
+ RepoRoot: h.repo.Root(),
+ Asker: h.ask,
+ Forge: h.forge,
+ Credentials: h.cred,
+ Adapter: cloudflare.Adapter{BaseURL: h.host.URL},
+ Context: context.Background(),
+ }
+ for _, m := range mutate {
+ m(&req)
+ }
+ res, err := Setup(req)
+ if err != nil {
+ t.Fatalf("setup: %v", err)
+ }
+ return res
+}
+
+func withDomain(d string) func(*SetupRequest) { return func(r *SetupRequest) { r.Domain = d } }
+
+// setupFiles is the whole repository half, in the order the verb reports it.
+var setupFiles = []string{
+ ManifestRelPath, "site-src/ui.json", "site-src/site.css", "site-src/site.js", "site-src/record.js",
+ "site-src/headers", SiteWorkflowRelPath, "wrangler.jsonc",
+}
+
+func fileStatuses(res SetupResult) map[string]string {
+ out := map[string]string{}
+ for _, f := range res.Files {
+ out[f.Path] = string(f.Status)
+ }
+ return out
+}
+
+// --- criterion 1: no credential ------------------------------------------------
+
+func TestSetupWithoutACredentialWritesTheRepositoryHalfAndSaysWhatRemains(t *testing.T) {
+ h := newHarness(t)
+ res := h.run(t)
+
+ got := fileStatuses(res)
+ for _, p := range setupFiles {
+ if got[p] != "written" {
+ t.Errorf("%s: %q, want written", p, got[p])
+ }
+ if _, err := os.Stat(filepath.Join(h.repo.Root(), p)); err != nil {
+ t.Errorf("%s reported written and is not on disk: %v", p, err)
+ }
+ }
+ for _, env := range []string{EnvRender, EnvDeploy} {
+ pol := h.forge.policies[env]
+ want := []BranchPolicy{{Name: "main", Type: "branch"}, {Name: "v*", Type: "tag"}}
+ if !reflect.DeepEqual(pol, want) {
+ t.Errorf("environment %s policies = %v, want %v", env, pol, want)
+ }
+ if !h.forge.envs[env].CustomBranchPolicies {
+ t.Errorf("environment %s is not restricted to custom policies", env)
+ }
+ }
+ if res.Host.Status != HostNoCredential {
+ t.Fatalf("host status = %q, want %q", res.Host.Status, HostNoCredential)
+ }
+ if n := len(h.host.CallLog()); n != 0 {
+ t.Fatalf("with no credential the provider was called %d times: %v", n, h.host.CallLog())
+ }
+ remaining := strings.Join(res.Remaining, "\n")
+ for _, want := range []string{
+ cloudflare.CredentialName, credential.StorePath,
+ "gh secret set CLOUDFLARE_API_TOKEN --env site --repo example-owner/example-site",
+ "gh secret set CLOUDFLARE_ACCOUNT_ID --env site --repo example-owner/example-site",
+ "git add",
+ } {
+ if !strings.Contains(remaining, want) {
+ t.Errorf("the remaining steps do not say %q:\n%s", want, remaining)
+ }
+ }
+ if res.Status != StatusChanged {
+ t.Fatalf("status = %q, want %q", res.Status, StatusChanged)
+ }
+}
+
+// --- criterion 2: with a credential ---------------------------------------------
+
+func TestSetupWithACredentialCreatesRoutesAndReportsTheHost(t *testing.T) {
+ h := newHarness(t).withCredential()
+ before := h.repo.Git("status", "--porcelain")
+ if strings.TrimSpace(before) != "" {
+ t.Fatalf("precondition: the fixture is dirty:\n%s", before)
+ }
+ res := h.run(t, withDomain("docs.example.com"))
+
+ if res.Host.Status != HostWritten || res.Host.Address != "https://docs.example.com" {
+ t.Fatalf("host = %+v, want written at https://docs.example.com", res.Host)
+ }
+ h.host.Mu.Lock()
+ worker, routed := h.host.Workers["example-site"], h.host.Domains["docs.example.com"]
+ auth := append([]string(nil), h.host.Auth...)
+ h.host.Mu.Unlock()
+ if !worker || routed != "example-site" {
+ t.Fatalf("the host holds worker=%v domain→%q", worker, routed)
+ }
+ for _, a := range auth {
+ if a != "Bearer "+cloudflaretest.Token {
+ t.Fatalf("a request carried an unexpected Authorization header")
+ }
+ }
+
+ // Nothing is written into the repository but the files above, and none of
+ // them carries the credential.
+ var changed []string
+ for _, line := range strings.Split(strings.TrimRight(h.repo.Git("status", "--porcelain", "-uall"), "\n"), "\n") {
+ if line == "" {
+ continue
+ }
+ changed = append(changed, strings.TrimSpace(line[2:]))
+ }
+ sort.Strings(changed)
+ want := append([]string(nil), setupFiles...)
+ sort.Strings(want)
+ if !reflect.DeepEqual(changed, want) {
+ t.Fatalf("the repository changed in\n %v\nwant exactly\n %v", changed, want)
+ }
+ for _, p := range setupFiles {
+ raw, err := os.ReadFile(filepath.Join(h.repo.Root(), p))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(raw), cloudflaretest.Token) {
+ t.Fatalf("%s carries the credential", p)
+ }
+ }
+ for _, line := range append(append([]string{}, res.Remaining...), res.Notes...) {
+ if strings.Contains(line, cloudflaretest.Token) {
+ t.Fatal("the report carries the credential")
+ }
+ }
+ if !strings.Contains(string(mustRead(t, h.repo.Root(), ManifestRelPath)), `"domain": "docs.example.com"`) {
+ t.Fatal("the composition does not record the domain the host was routed for")
+ }
+}
+
+func mustRead(t *testing.T, root, rel string) []byte {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join(root, rel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+
+// --- criterion 3: the seam -------------------------------------------------------
+
+func TestTheProviderListHasOneAdapterBehindTheSeam(t *testing.T) {
+ if got := Providers(); !reflect.DeepEqual(got, []string{"cloudflare"}) {
+ t.Fatalf("providers = %v, want exactly [cloudflare]", got)
+ }
+ a, ok := adapterNamed("cloudflare")
+ if !ok {
+ t.Fatal("the listed provider does not resolve")
+ }
+ var _ hosting.Adapter = a
+}
+
+// --- criterion 4: the page set for any managed repository ------------------------
+
+func TestSetupGivesAManagedRepositoryTheWholePageSet(t *testing.T) {
+ h := newHarness(t)
+ h.run(t)
+ out := t.TempDir()
+ if _, err := Build(Request{RepoRoot: h.repo.Root(), OutDir: out, Stamp: fixtureStamp}); err != nil {
+ t.Fatalf("the composition setup wrote does not build: %v", err)
+ }
+ pages := htmlPages(t, out)
+ for _, want := range []string{
+ "index.html", "record/index.html", "record/adr/adr-1/index.html", "record/graph/index.html",
+ "record/glossary/index.html", "record/health/index.html",
+ } {
+ if _, ok := pages[want]; !ok {
+ t.Errorf("the managed repository's site lacks %s", want)
+ }
+ }
+ if !strings.Contains(pages["record/index.html"], `class="panel tl"`) {
+ t.Error("the managed repository's dashboard lacks the timeline")
+ }
+ if !strings.Contains(pages["index.html"], "The example repository") {
+ t.Error("the landing page is not composed from the repository's own text")
+ }
+}
+
+// --- criterion 5: re-runnable ------------------------------------------------------
+
+func TestASecondRunWritesNothingAndSaysSo(t *testing.T) {
+ h := newHarness(t).withCredential()
+ h.run(t, withDomain("docs.example.com"))
+ forgeWrites, hostWrites := len(h.forge.writes), h.host.Writes()
+
+ res := h.run(t)
+ if res.Status != StatusNoChange {
+ t.Fatalf("second run status = %q, want %q", res.Status, StatusNoChange)
+ }
+ for _, f := range res.Files {
+ if f.Status != "current" && f.Status != "kept" {
+ t.Errorf("second run: %s is %q", f.Path, f.Status)
+ }
+ }
+ if len(h.forge.writes) != forgeWrites {
+ t.Errorf("second run wrote to the forge: %v", h.forge.writes[forgeWrites:])
+ }
+ if h.host.Writes() != hostWrites {
+ t.Errorf("second run wrote to the host: %v", h.host.CallLog())
+ }
+ if res.Host.Status != HostCurrent || res.Host.Address != "https://docs.example.com" {
+ t.Errorf("second run host = %+v", res.Host)
+ }
+}
+
+// --- the confirmation and the failures -------------------------------------------
+
+func TestADeclinedRunWritesNothingRemote(t *testing.T) {
+ h := newHarness(t).withCredential()
+ h.ask = answer(false)
+ res := h.run(t)
+ if len(h.forge.writes) != 0 || h.host.Writes() != 0 {
+ t.Fatalf("a declined run wrote remotely: forge %v host %v", h.forge.writes, h.host.CallLog())
+ }
+ if res.Status != StatusDeclined || res.Host.Status != HostDeclined {
+ t.Fatalf("status %q host %q, want declined", res.Status, res.Host.Status)
+ }
+ for _, e := range res.Environments {
+ if e.Status != RemoteDeclined {
+ t.Errorf("environment %s is %q, want declined", e.Name, e.Status)
+ }
+ }
+}
+
+func TestEveryForgeFailureIsReportedAndStopsTheRemoteWrites(t *testing.T) {
+ for _, call := range []string{"Environments", "Policies", "PutEnvironment", "AddPolicy"} {
+ t.Run(call, func(t *testing.T) {
+ h := newHarness(t).withCredential()
+ if call == "Policies" {
+ h.forge.envs[EnvRender] = EnvironmentState{CustomBranchPolicies: true}
+ }
+ h.forge.fail[call] = errors.New("forge said no")
+ res := h.run(t)
+ if res.Status != StatusRefused {
+ t.Fatalf("status = %q, want refused", res.Status)
+ }
+ if !strings.Contains(strings.Join(res.Notes, "\n"), "forge said no") {
+ t.Fatalf("the failure is not in the notes: %v", res.Notes)
+ }
+ if h.host.Writes() != 0 {
+ t.Fatalf("the host was written after the forge failed: %v", h.host.CallLog())
+ }
+ })
+ }
+ t.Run("SecretNames", func(t *testing.T) {
+ h := newHarness(t)
+ h.forge.fail["SecretNames"] = errors.New("forge said no")
+ res := h.run(t)
+ if !strings.Contains(strings.Join(res.Remaining, "\n"), "gh secret set CLOUDFLARE_API_TOKEN") {
+ t.Fatalf("an unreadable secret list must still name the secret step: %v", res.Remaining)
+ }
+ if !strings.Contains(strings.Join(res.Notes, "\n"), "forge said no") {
+ t.Fatalf("the failure is not in the notes: %v", res.Notes)
+ }
+ })
+}
+
+func TestEveryProviderFailureIsReported(t *testing.T) {
+ for _, route := range []string{
+ cloudflaretest.ListAccounts, cloudflaretest.ListWorkers, cloudflaretest.ListDomains,
+ cloudflaretest.CreateWorker, cloudflaretest.AttachDomain,
+ } {
+ t.Run(route, func(t *testing.T) {
+ h := newHarness(t).withCredential()
+ h.host.Fail[route] = 500
+ res := h.run(t, withDomain("docs.example.com"))
+ if res.Status != StatusRefused || res.Host.Status != HostRefused {
+ t.Fatalf("status %q host %q, want refused", res.Status, res.Host.Status)
+ }
+ if !strings.Contains(res.Host.Detail, "500") {
+ t.Fatalf("the host's failure is not reported: %q", res.Host.Detail)
+ }
+ if strings.Contains(res.Host.Detail, cloudflaretest.Token) {
+ t.Fatal("the failure carries the credential")
+ }
+ })
+ }
+ t.Run(cloudflaretest.GetSubdomain, func(t *testing.T) {
+ h := newHarness(t).withCredential()
+ h.host.Fail[cloudflaretest.GetSubdomain] = 500
+ res := h.run(t)
+ if res.Status != StatusRefused || !strings.Contains(res.Host.Detail, "500") {
+ t.Fatalf("status %q host %+v, want the address failure refused", res.Status, res.Host)
+ }
+ })
+}
+
+func TestAnUnreadableCredentialIsRefusedNotSkipped(t *testing.T) {
+ h := newHarness(t)
+ h.cred = fixedCredential{err: errors.New("credential: the store is readable by others")}
+ res := h.run(t)
+ if res.Host.Status != HostRefused || res.Status != StatusRefused {
+ t.Fatalf("status %q host %q, want refused", res.Status, res.Host.Status)
+ }
+ if len(h.host.CallLog()) != 0 {
+ t.Fatalf("the provider was called without a credential: %v", h.host.CallLog())
+ }
+}
+
+func TestSetupRefusesAFolderAbcdDoesNotManage(t *testing.T) {
+ h := newHarness(t)
+ h.repo.Write("AGENTS.md", "# Example\n")
+ _, err := Setup(SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()})
+ if !errors.Is(err, ErrNotManaged) {
+ t.Fatalf("err = %v, want ErrNotManaged", err)
+ }
+}
+
+func TestDriftedMachineryRefusesTheWholeRun(t *testing.T) {
+ h := newHarness(t).withCredential()
+ h.repo.Write(SiteWorkflowRelPath, "name: site\n# hand-made\n")
+ res := h.run(t)
+ if res.Status != StatusRefused {
+ t.Fatalf("status = %q, want refused", res.Status)
+ }
+ if _, err := os.Stat(filepath.Join(h.repo.Root(), ManifestRelPath)); !os.IsNotExist(err) {
+ t.Fatal("a refused run wrote the composition")
+ }
+ if len(h.forge.writes) != 0 || len(h.host.CallLog()) != 0 {
+ t.Fatalf("a refused run reached the forge %v or the host %v", h.forge.writes, h.host.CallLog())
+ }
+ res = h.run(t, func(r *SetupRequest) { r.Confirm = true })
+ if fileStatuses(res)[SiteWorkflowRelPath] != "written" {
+ t.Fatalf("--confirm did not replace the drifted workflow: %v", res.Files)
+ }
+}
+
+func TestSetupRefusesWithoutAnIdentityOrAPage(t *testing.T) {
+ h := newHarness(t)
+ h.repo.Remove(".abcd/positioning.json")
+ if _, err := Setup(SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()}); err == nil ||
+ !strings.Contains(err.Error(), "abcd identity init") {
+ t.Fatalf("no identity block: err = %v, want one naming `abcd identity init`", err)
+ }
+ h = newHarness(t)
+ h.repo.Remove("docs/README.md")
+ if _, err := Setup(SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()}); err == nil ||
+ !strings.Contains(err.Error(), "docs/README.md") {
+ t.Fatalf("no page: err = %v, want one naming docs/README.md", err)
+ }
+}
+
+func TestAnUnsafeNameOrDomainIsRefused(t *testing.T) {
+ h := newHarness(t)
+ for _, m := range []func(*SetupRequest){
+ func(r *SetupRequest) { r.Name = "Not_A_Name" },
+ func(r *SetupRequest) { r.Domain = "https://example.com/x" },
+ } {
+ req := SetupRequest{RepoRoot: h.repo.Root(), Forge: h.forge, Credentials: h.cred, Context: context.Background()}
+ m(&req)
+ if _, err := Setup(req); err == nil {
+ t.Fatalf("setup accepted %+v", req)
+ }
+ }
+}
+
+// --- the workflow ------------------------------------------------------------------
+
+// TestTheWorkflowInterpolatesNothingIntoAShell: a `${{ … }}` expression inside a
+// run block is spliced into the script before the shell parses it, which is
+// the injection shape; every value reaches the shell through env instead.
+func TestTheWorkflowInterpolatesNothingIntoAShell(t *testing.T) {
+ wf, err := renderSiteWorkflow("main", cloudflare.Adapter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ inRun, runIndent := false, 0
+ for i, line := range strings.Split(string(wf), "\n") {
+ indent := len(line) - len(strings.TrimLeft(line, " "))
+ trimmed := strings.TrimSpace(line)
+ if inRun && trimmed != "" && indent <= runIndent {
+ inRun = false
+ }
+ if strings.HasPrefix(trimmed, "run:") || strings.HasPrefix(trimmed, "- run:") {
+ if strings.Contains(trimmed, "${{") {
+ t.Errorf("line %d interpolates into a shell: %s", i+1, trimmed)
+ }
+ inRun, runIndent = true, indent
+ continue
+ }
+ if inRun && strings.Contains(line, "${{") {
+ t.Errorf("line %d interpolates into a shell: %s", i+1, trimmed)
+ }
+ }
+ for _, want := range []string{"environment: site-render", "environment: site\n", "branches: [main]", "persist-credentials: false"} {
+ if !strings.Contains(string(wf), want) {
+ t.Errorf("the workflow lacks %q", want)
+ }
+ }
+}
+
+// TestTheWorkflowPinsFollowAbcdsOwn: every action the setup workflow uses is
+// pinned to the commit abcd's own site workflow uses, so a pin bump there is a
+// failing test here rather than a quietly stale copy in every managed repo.
+func TestTheWorkflowPinsFollowAbcdsOwn(t *testing.T) {
+ own, err := os.ReadFile(filepath.Join("..", "..", "..", ".github", "workflows", "site.yml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ wf, err := renderSiteWorkflow("main", cloudflare.Adapter{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ usesRe := regexp.MustCompile(`uses: ([^@\s]+)@([0-9a-f]{40})`)
+ pins := map[string]string{}
+ for _, m := range usesRe.FindAllStringSubmatch(string(own), -1) {
+ pins[m[1]] = m[2]
+ }
+ found := usesRe.FindAllStringSubmatch(string(wf), -1)
+ if len(found) == 0 {
+ t.Fatal("the setup workflow pins no action")
+ }
+ for _, m := range found {
+ if pins[m[1]] != m[2] {
+ t.Errorf("%s is pinned to %s here and %q in abcd's own site workflow", m[1], m[2], pins[m[1]])
+ }
+ }
+ if v := regexp.MustCompile(`wranglerVersion: '([^']+)'`).FindStringSubmatch(string(own)); v == nil ||
+ !strings.Contains(string(wf), "wranglerVersion: '"+v[1]+"'") {
+ t.Errorf("the wrangler version differs from abcd's own site workflow")
+ }
+}
+
+// TestTheSeedSourcesAreAbcdsOwn: the static inputs setup seeds a managed
+// repository with are byte-identical to the ones abcd's own site renders from.
+func TestTheSeedSourcesAreAbcdsOwn(t *testing.T) {
+ for _, name := range seedNames {
+ own, err := os.ReadFile(filepath.Join("..", "..", "..", "site-src", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ seed, err := setupSources.ReadFile("setupsrc/" + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(own) != string(seed) {
+ t.Errorf("setupsrc/%s differs from site-src/%s; copy it across", name, name)
+ }
+ }
+}
diff --git a/internal/core/site/setupsrc/headers b/internal/core/site/setupsrc/headers
new file mode 100644
index 000000000..e78179290
--- /dev/null
+++ b/internal/core/site/setupsrc/headers
@@ -0,0 +1,133 @@
+# Source of the Cloudflare _headers file. `abcd site build` copies it into the
+# output tree as `_headers`, comments and all.
+#
+# Every file the build emits must match a block here.
+# `TestEveryRouteHasASecurityHeaderBlock` keeps it two ways: it walks a whole
+# emitted tree and fails on any file matching no block, and it holds THIS file to
+# the route families the committed build emits, with the asset routes read off
+# the build's own copy list rather than restated — so a new copied source cannot
+# ship unprotected by being forgotten here.
+#
+# What a block must carry depends on what the host serves it as:
+#
+# a DOCUMENT a content policy, `nosniff` and a referrer policy.
+# an ASSET `nosniff` and a referrer policy. A content policy governs what
+# a document may LOAD, and a stylesheet, a script file, an image
+# or a JSON export loads nothing — so it would be a directive
+# with no subject, not a tighter lock.
+#
+# Three emitted files are not routes and have no block. `_headers` and
+# `_redirects` are the host's own configuration: it reads them and serves
+# neither. `.abcd-site-build` is the build's own marker — it is what lets a
+# rebuild clear this directory instead of refusing it, and it describes nothing
+# but itself. The walk excludes those three by name rather than by a pattern, so
+# a new file cannot slip past the check by resembling one of them.
+#
+# /install.sh is read before it is run, so it is served as text, never as a
+# download.
+/install.sh
+ Content-Type: text/plain; charset=utf-8
+ X-Content-Type-Options: nosniff
+ Referrer-Policy: strict-origin-when-cross-origin
+
+# The landing page's content policy. The build already refuses a script or an
+# event handler inside a committed SVG and refuses an executable link scheme, so
+# this is the second lock, not the first: if a way past those is ever found, the
+# injected code still has no origin to run in.
+#
+# It is scoped to the generated pages rather than the whole site because /docs/
+# is rendered by a different generator whose own inline scripts this would break.
+#
+# What the page legitimately does: load its own stylesheet and script, the
+# Google Fonts stylesheet and the font files it points at, and its own copied
+# rasters. It fetches nothing, embeds nothing, and submits nothing — so
+# connect-src, frame-src and form-action are closed, and default-src 'none'
+# closes everything not named. style-src-attr allows the layout custom
+# properties the build writes as style="--stack:22px".
+/
+ Content-Security-Policy: default-src 'none'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; style-src-attr 'unsafe-inline'; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'none'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'
+ X-Content-Type-Options: nosniff
+ Referrer-Policy: strict-origin-when-cross-origin
+
+# The record explorer. One policy for the whole family, directive by directive:
+#
+# default-src 'none' nothing not named below may load at all.
+# script-src 'self' site.js on every route; record.js on /record/graph/.
+# No 'unsafe-inline' and no 'unsafe-eval': every script
+# the explorer runs is a file this build wrote.
+# style-src 'self' … site.css, plus the Google Fonts stylesheet.
+# style-src-attr the build writes per-datum style attributes that no
+# 'unsafe-inline' stylesheet can carry: a lifecycle bar's segment
+# widths (style="width:42.86%") and an assistance bar's
+# length are numbers derived per record. The narrowest
+# directive that permits them is this one — it governs
+# ATTRIBUTES only, so a