// Cloudflare Pages deploy argv and preview-branch rules. // // WHY THIS IS PURE. The deploy itself is server-only (it spawns wrangler), but // the RULES about it are needed in three places at once: the server action that // refuses a bad name, the ops route that must 400 before any job starts, and the // browser control that greys its button out as you type. Keeping the rules in // one node-free module is what makes those three agree by construction instead // of by three copies of a regex. No `node:` imports belong here. // // WHAT A PREVIEW IS. Cloudflare treats a deploy to the project's PRODUCTION // branch as production and anything else as a preview. A preview gets its own // branch alias, https://..pages.dev, plus an immutable // per-deployment https://..pages.dev. Every deploy names its // branch (release 18): production is `--branch main`, never inferred — with no // `--branch` wrangler took the branch from the git checkout it ran in, so a // "production" deploy from a feature-branch checkout silently became a preview, // and a container has no checkout at all. // // THE BINARY IS PINNED. wrangler is an exact devDependency of `common` // (WRANGLER_MAJOR below; one pin for the host and the image) and is spawned as // `wranglerBin(paths)`, never fetched by `pnpm dlx` at deploy time. import type { Paths } from "./paths"; // The branch names Cloudflare Pages projects use as their production branch. // Deploying to one of these is not a preview — it is the live site — so the // preview path refuses them rather than quietly shipping production. const PRODUCTION_BRANCHES = new Set(["main", "master", "production"]); // The maximum length of a branch name we accept. Cloudflare sanitizes a branch // name into the alias hostname label; keeping to what the sanitizer preserves // verbatim means the alias we PREDICT (previewAliasUrl, shown in the UI before // the job ends) is the alias that actually resolves. export const MAX_PREVIEW_BRANCH = 28; // Exactly the shape Cloudflare's alias sanitizer keeps unchanged: lowercase // alphanumerics and inner dashes, 1..MAX_PREVIEW_BRANCH chars, no leading or // trailing dash. Uppercase is refused rather than lowercased for us, because a // silently-rewritten name makes the printed alias a guess. const PREVIEW_BRANCH_RE = /^[a-z0-9](?:[a-z0-9-]{0,26}[a-z0-9])?$/; /** * Why `name` may not be used as a preview branch, as ONE sentence — or null * when it is fine. The value is trimmed first, so trailing whitespace from a * text input is never itself the complaint. */ export function previewBranchProblem(name: unknown): string | null { if (typeof name !== "string") { return "A preview branch name must be a string."; } const branch = name.trim(); if (!branch) { return 'A preview needs a branch name (for example "preview").'; } if (PRODUCTION_BRANCHES.has(branch)) { return `"${branch}" is the production branch; a preview needs another name.`; } if (branch.length > MAX_PREVIEW_BRANCH) { return `A preview branch name is at most ${MAX_PREVIEW_BRANCH} characters ("${branch}" is ${branch.length}).`; } if (!PREVIEW_BRANCH_RE.test(branch)) { return `"${branch}" is not a valid preview branch name — use lowercase letters, digits and dashes, starting and ending with a letter or digit.`; } return null; } /** The branch every Pages project here deploys PRODUCTION to. */ export const PRODUCTION_BRANCH = "main"; /** * The wrangler argv (everything AFTER the wrangler binary — `wranglerBin`) for * one Pages deploy: production is `--branch main`, a preview `--branch `. * A blank `previewBranch` is no preview. */ export function pagesDeployArgs(opts: { outDir: string; project: string; previewBranch?: string; }): string[] { const branch = opts.previewBranch?.trim() || PRODUCTION_BRANCH; return [ "pages", "deploy", opts.outDir, "--project-name", opts.project, "--branch", branch, ]; } // The wrangler major the pin in common/package.json belongs to — what // `archilyzer doctor` expects the binary to report (pagesDeploy.test.ts holds // the pin to it). export const WRANGLER_MAJOR = 4; /** * The wrangler binary a deploy spawns: `WRANGLER_BIN` when set (the e2e fake, * or an operator's own), else the pinned devDependency of `common`. */ export function wranglerBin( paths: Pick, env: Record = typeof process === "undefined" ? {} : process.env, ): string { return env.WRANGLER_BIN?.trim() || `${paths.monorepoRoot}/common/node_modules/.bin/wrangler`; } // --------------------------------------------------------------------------- // Credentials. A deploy with no credential at all is refused BEFORE wrangler // (wrangler would otherwise try to open a browser for OAuth, or fail in its own // words); a credential Cloudflare rejects is read off wrangler's output and // said in ours. // --------------------------------------------------------------------------- /** The sentence a deploy ends on when Cloudflare refused its credential. */ export const CLOUDFLARE_AUTH_REFUSED = "[deploy] REFUSED by Cloudflare — the API token was not accepted"; /** The sentence a deploy is refused with when no credential is configured. */ export const CLOUDFLARE_NO_CREDENTIALS = "[deploy] REFUSED — no Cloudflare credentials: set CLOUDFLARE_API_TOKEN in .env " + "(or run `wrangler login` on this machine). Nothing was sent to Cloudflare"; // What wrangler 4 prints when Cloudflare rejects, or it cannot find, a // credential: the API's own error codes (10000 "Authentication error", 9109 // "Invalid access token" — a well-formed token that is wrong — 10001 "Unable to // authenticate request", 6003 "Invalid request headers" and 6111 "Invalid format // for Authorization header" — a malformed one) and wrangler's own sentences for // a missing login in a non-interactive run. const AUTH_FAILURE_RES: readonly RegExp[] = [ /Authentication error \[code: 10000\]/, /Invalid access token \[code: 9109\]/, /Unable to authenticate request \[code: 10001\]/, // A malformed token (not a token's shape at all): Cloudflare rejects the // header before it reads the credential (seen with CLOUDFLARE_API_TOKEN=bogus). /Invalid request headers \[code: 6003\]/, /Invalid format for Authorization header \[code: 6111\]/, /necessary to set a CLOUDFLARE_API_TOKEN environment variable/, /You are not authenticated\. Please run `wrangler login`/, /Failed to refresh (?:the )?OAuth token/i, ]; /** Whether one line of wrangler's output says the credential was not accepted. */ export function wranglerAuthFailureIn(line: string): boolean { return AUTH_FAILURE_RES.some((re) => re.test(line)); } /** * Where wrangler keeps an OAuth login (`wrangler login`), for a home dir and an * environment: the legacy `~/.wrangler`, the XDG config dir (Linux), and macOS's * Preferences. Pure; the caller stats them. */ export function wranglerOAuthConfigFiles( home: string, env: Record, ): string[] { const xdg = env.XDG_CONFIG_HOME?.trim() || `${home}/.config`; return [ `${home}/.wrangler/config/default.toml`, `${xdg}/.wrangler/config/default.toml`, `${home}/Library/Preferences/.wrangler/config/default.toml`, ]; } /** * Why a deploy has no credential to offer Cloudflare, as the refusal sentence — * or null when it has one: `CLOUDFLARE_API_TOKEN` (what `.env` carries, the one * way in the container), or a wrangler OAuth login on disk * (`oauthLoginPresent`, a host's `wrangler login`). Never reads or prints a * value: set or not. */ export function cloudflareCredentialProblem( env: Record, oauthLoginPresent: boolean, ): string | null { if (env.CLOUDFLARE_API_TOKEN?.trim()) return null; if (oauthLoginPresent) return null; return CLOUDFLARE_NO_CREDENTIALS; } /** * The stable branch alias a preview deploy lands on. Knowable BEFORE the deploy * finishes (that is the point — the UI links it while the log still streams), * because the alias is a function of the project and the branch alone. */ export function previewAliasUrl(project: string, branch: string): string { return `https://${branch.trim()}.${project.trim()}.pages.dev`; } /** * The per-deployment URL wrangler prints ("Take a peek over at https://…"), * pulled out of one log line — or null when the line carries none. * * Matched against THIS project's hostname suffix so an unrelated pages.dev URL * in the output (a doc link, another project) is never mistaken for the * deployment we just made. * * TIGHT AT BOTH ENDS, deliberately. The prefix is ONE hostname label — no dots * — because that is all Cloudflare ever puts there (`.` or `.`); * a dot-swallowing prefix can reach back across an earlier, unrelated * `pages.dev` on the same line and return a host that is not ours. And the * match must END at the TLD, or `https://x.anilyzer.pages.devil.com` yields a * URL that reads exactly right and points somewhere else. */ export function deploymentUrlIn(line: string, project: string): string | null { const p = project.trim(); if (!p) return null; const re = new RegExp( `https://[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\\.${escapeRe(p)}\\.pages\\.dev(?![A-Za-z0-9.-])`, ); const m = re.exec(line); return m ? m[0] : null; } function escapeRe(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }