diff --git a/.changeset/deploy-workers-builds.md b/.changeset/deploy-workers-builds.md new file mode 100644 index 00000000..de207ff4 --- /dev/null +++ b/.changeset/deploy-workers-builds.md @@ -0,0 +1,6 @@ +--- +"@cloudhal/cli": minor +"@cloudhal/core-ts": minor +--- + +`cloudhal deploy` runs in Cloudflare Workers Builds (`WORKERS_CI=1`), so a Deploy to Cloudflare button can set up a whole installation (docs/guides/deploy-button.md). Names come from the committed `wrangler.jsonc`, and a database id there is used without any D1 call. The starter site is seeded under `cloudhal.json`'s `siteId` when the checkout has none. Secrets stay on the Workers: only missing ones are generated, and the deployment token is new on every build. The setup link is never printed; the first build explains how the owner sets their own setup token. `seedStarterSite` takes an optional `siteId`. diff --git a/docs/guides/deploy-button.md b/docs/guides/deploy-button.md new file mode 100644 index 00000000..6035d74b --- /dev/null +++ b/docs/guides/deploy-button.md @@ -0,0 +1,45 @@ +# Deploy to Cloudflare button + +Cloudflare's [Deploy to Cloudflare button](https://developers.cloudflare.com/workers/configuration/deploy-button/) copies a public repository into your GitHub account, creates the resources its root `wrangler.jsonc` declares and connects the repository to [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), which then runs the repository's `deploy` script. A button deploys one Worker; a Cloudhal installation is two (the site Worker and the admin Worker with Studio, API and MCP), one D1 database and one R2 bucket. So the button points at [`cloudhal-dev/cloudhal-template`](https://github.com/cloudhal-dev/cloudhal-template), a blog project whose `deploy` script is `cloudhal deploy`: the button provisions the site Worker's database and bucket, and `cloudhal deploy` makes the rest. + +## What one click creates + +- a copy of the template in your GitHub account; every push to its default branch deploys again +- the site Worker (the name you choose in the form, `cloudhal-site` by default) and `-admin`, where `` is that name without `-site` +- the D1 database and R2 bucket named in the form, used by both Workers +- the starter blog, pushed to the deployment on the first build only + +## Creating the owner account + +A local `cloudhal deploy` prints a one-time setup link. In Workers Builds the build log is kept, so the link is never printed. The first build ends with these steps instead: + +1. In the Cloudflare dashboard, open **Workers & Pages › `-admin` › Settings › Variables and Secrets**, edit `CLOUDHAL_SETUP_TOKEN` and enter a random value of your own (32 characters or more). Saving deploys it. Or run `npx wrangler secret put CLOUDHAL_SETUP_TOKEN --name -admin`. +2. Open `https://-admin..workers.dev/login#setup=` and create the owner account. + +Until an owner exists, sign-up needs that token. Once the owner exists, sign-up is closed whatever the token is. + +## How `cloudhal deploy` behaves in Workers Builds + +Workers Builds sets `WORKERS_CI=1`. Every build starts from a fresh clone, with no `.cloudhal/` folder: no local site, no `secrets.json`, no `state.json`. With `WORKERS_CI=1`, `cloudhal deploy`: + +- **takes its names from the committed `wrangler.jsonc`.** It reads the Worker name (Workers Builds requires the config to name the connected Worker), the `DB` database's name and id, and the `BLOBS` bucket's name. A database id in that file is used without any D1 call, because the Workers Builds token may not have D1 permission. An all-zero placeholder id counts as no id. The deploy rewrites the file in the build's checkout and never commits it. +- **seeds the starter site** under `cloudhal.json`'s `siteId` when the checkout has none. It is pushed only when the deployment does not have that site yet. After that, the deployment is the source of truth, as it is for any deploy. +- **leaves the secrets on the Workers.** It lists each Worker's secrets (`wrangler secret list`). Any secret a Worker already has is kept, since wrangler keeps every secret a deploy does not set. A missing secret is generated and set. The secrets both Workers share are set on both together. If listing fails for any reason other than "Worker not found", the deploy stops, because generating new secrets would make data they encrypt unreadable (for example commenters' email addresses). +- **creates a new deployment token on every build.** The CLI uses it for the first push and nothing else. + +## Limits + +- **Existing deployments are not checked before new code goes out.** Workers Builds has no record of the previous deploy, so the schedules downgrade check (RFC-0008) and the cache purge after analytics or comments are switched on or off do not run. Cached pages pick up such a switch as they expire. +- **The scheduler starts on cron in every build.** If the account has no free cron slot, the build falls back to the Durable Object alarm each time (one extra admin upload). The Workers plan is `free` unless `cloudhal.json` sets `schedules.plan`. +- **Deploy through Git, work through Studio.** A clone of your copy has no `.cloudhal/` folder and no deployment token, because every build replaces the token. So a local `cloudhal deploy` stops with "site … not found in the local store", before it touches any secret, and `cloudhal push`, `pull` and `owner transfer` have no token to use. Edit content in Studio, connect agents with keys created in Studio (remote MCP), and deploy code changes by pushing to the default branch. +- **Custom domains** need zone permissions the default Workers Builds token lacks. Set them with `cloudhal domain set` and give the build token *Zone › Workers Routes* and *DNS* edit permissions on that zone, or add the domain in the dashboard. +- **Preview branches**: Workers Builds runs `wrangler versions upload` for non-default branches, which uploads only the site Worker. Keep deploying from the default branch. +- **`--jurisdiction eu`** has no effect: the button creates the database before `cloudhal deploy` runs. + +## Making your own template + +Start from `npm create cloudhal@latest`, then: + +- commit `cloudhal.json` and a root `wrangler.jsonc` that names the site Worker, a `DB` D1 database (placeholder id `00000000-0000-0000-0000-000000000000`) and a `BLOBS` R2 bucket +- remove the `build` script (Workers Builds would run it before `deploy`), keep `"deploy": "cloudhal deploy"` +- commit a lockfile and a `.node-version` of `24`. The CLI runs on Node 24. diff --git a/packages/cli/README.md b/packages/cli/README.md index ee6afc27..eb1d0567 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -161,6 +161,8 @@ Locally, `cloudhal studio` reads the project's SQLite (`analytics_events`), roll `cloudhal deploy` uses **your** wrangler login (`npx wrangler login`, or `CLOUDFLARE_API_TOKEN`); Cloudhal never reads those credentials. It creates the D1 database and R2 bucket if needed (with a lifecycle rule that expires the snapshot bundles under `bundles/` after 30 days, RFC-0010), generates the deployment secrets once into `.cloudhal/deploy/secrets.json` (0600 — keep it out of git), builds and deploys the **site Worker** (Astro on demand, edge-cached) and the **admin Worker** (Studio, API, auth, MCP), and on the first deploy pushes your local site. +**From Git (Workers Builds).** With `WORKERS_CI=1`, which Cloudflare sets in [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), `cloudhal deploy` works from a fresh clone. It takes the names from the committed `wrangler.jsonc`, seeds the starter site when the checkout has none, keeps the secrets on the Workers and does not print the setup link. This is what the Deploy to Cloudflare button runs: see [docs/guides/deploy-button.md](../../docs/guides/deploy-button.md). + **Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname, so preview links use it (canonical URLs in the starter are not rendered yet). The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it. **Email (RFC-0011).** Add `"mail": { "from": "studio@example.com", "fromName": "Example Studio" }` to `cloudhal.json` and the next `cloudhal deploy` gives the admin Worker a [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) `send_email` binding locked to that address. Invites can then be emailed, and staff can reset a forgotten password from the sign-in screen. Onboard the address's domain in Email Service first. Sending to any recipient needs Workers Paid (3,000 emails a month included); on Free, Email Service delivers only to verified destination addresses of your account. Locally, `cloudhal studio` prints these emails in the terminal instead of sending them. diff --git a/packages/cli/src/cli/commands-remote.ts b/packages/cli/src/cli/commands-remote.ts index b75cf09d..dfd9ac92 100644 --- a/packages/cli/src/cli/commands-remote.ts +++ b/packages/cli/src/cli/commands-remote.ts @@ -53,6 +53,7 @@ const deployCommand: CliCommand = async (_rest, values, { projectDir }) => { ); if (r.setupUrl) console.log(`\nLocal site pushed. Create the owner account (once) at:\n ${r.setupUrl}`); + if (r.setupInstructions) console.log(`\nStarter site pushed. ${r.setupInstructions}`); return 0; }; diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts index bc379295..f5e74805 100644 --- a/packages/cli/src/commands/deploy.ts +++ b/packages/cli/src/commands/deploy.ts @@ -39,6 +39,9 @@ * `apps/studio/src`, `apps/studio/index.html` or `packages/ui/src`, the deploy runs * `pnpm --filter @cloudhal/studio build` first; `--no-build-studio` fails instead. A published * install ships Studio prebuilt and is never checked. + * 13. Workers Builds (`WORKERS_CI=1`, the Deploy to Cloudflare button): names from the committed + * `wrangler.jsonc`, the starter seeded when the checkout has none, secrets kept on the Workers + * and no setup link printed (`deploy/workers-builds.ts`, docs/guides/deploy-button.md). * * `--dry-run` runs everything up to wrangler's own `--dry-run` (bundles, configs) and skips * provisioning, deployment and push — no Cloudflare account needed. @@ -64,7 +67,8 @@ import { downgradeGuard } from "./deploy/schedules-guard.ts"; import { attachedWorkers } from "./deploy/served-sites.ts"; import { commentsResult } from "./deploy/settings.ts"; import { afterDeploy, purgeIfSwitched, shipWorkers } from "./deploy/ship.ts"; -import { loadOrCreateSecrets, writeDeployState } from "./deploy/state.ts"; +import { loadOrCreateSecrets, SITE_SECRET_KEYS, writeDeployState } from "./deploy/state.ts"; +import { buildsSecrets, setupInstructions } from "./deploy/workers-builds.ts"; import { resolveSkillsDir } from "./mcp.ts"; export type { DeployOptions, DeployResult } from "./deploy/options.ts"; @@ -90,6 +94,15 @@ export { SECRET_KEYS, } from "./deploy/state.ts"; +/** What this deploy sets on each Worker, and the values it knows (all of them, outside Workers Builds). */ +async function deploySecrets(ctx: Awaited>) { + if (ctx.workersBuilds && !ctx.options.dryRun) + return buildsSecrets(ctx.run, { cwd: ctx.options.projectDir, dir: ctx.dir, names: ctx.names }); + const all = await loadOrCreateSecrets(ctx.dir); + const site = Object.fromEntries(SITE_SECRET_KEYS.map((k) => [k, all[k]])); + return { site, admin: all, known: all }; +} + export async function deploy(options: DeployOptions): Promise { checkOptions(options); // RFC-0034 §4: a site Worker that joins another installation deploys only itself. @@ -99,10 +112,11 @@ export async function deploy(options: DeployOptions): Promise { const { analytics, comments } = await analyticsAndComments(ctx); const studioDir = await studioDirFor(ctx); const { withIds, databaseCreated } = await provision(ctx); - // 2. secrets; 2b. RFC-0008: never deploy code that ignores holds and gates over open releases - const secrets = await loadOrCreateSecrets(ctx.dir); - if (!options.dryRun && previous?.adminUrl) - await downgradeGuard(options, previous.adminUrl, secrets.CLOUDHAL_DEPLOY_TOKEN, log); + // 2. secrets (Workers Builds: kept on the Workers); 2b. RFC-0008: never deploy code that + // ignores holds and gates over open releases + const secrets = await deploySecrets(ctx); + if (!options.dryRun && previous?.adminUrl && !ctx.workersBuilds) + await downgradeGuard(options, previous.adminUrl, secrets.known.CLOUDHAL_DEPLOY_TOKEN, log); const settings = await deploySettings(ctx); const { plan, schedules, observability } = settings; const site = await buildSite(ctx, { withIds, analytics, comments, observability }); @@ -163,7 +177,8 @@ export async function deploy(options: DeployOptions): Promise { } const { siteUrl, adminUrl, scheduler } = await shipWorkers(ctx, { - secrets, + siteSecrets: secrets.site, + adminSecrets: secrets.admin, admin, prepareAdmin, scheduler: settings.scheduler, @@ -184,18 +199,21 @@ export async function deploy(options: DeployOptions): Promise { plan, }); const pushed = await afterDeploy(ctx, { siteUrl, adminUrl }); - const { purged, analyticsChanged, commentsChanged } = await purgeIfSwitched(ctx, { - siteUrl, - analytics, - comments, - purgeToken: secrets.CLOUDHAL_PURGE_TOKEN, - }); + // Workers Builds knows no previous deploy, nor (after the first) the purge token: no purge + const purgeToken = secrets.known.CLOUDHAL_PURGE_TOKEN; + const { purged, analyticsChanged, commentsChanged } = purgeToken + ? await purgeIfSwitched(ctx, { siteUrl, analytics, comments, purgeToken }) + : { purged: false, analyticsChanged: false, commentsChanged: false }; // First deploy: the owner account can only be created through this one-time link. + const setupToken = secrets.known.CLOUDHAL_SETUP_TOKEN; + const first = pushed && adminUrl; const setupUrl = - pushed && adminUrl - ? `${adminUrl.replace(/\/$/, "")}/login#setup=${secrets.CLOUDHAL_SETUP_TOKEN}` + first && setupToken && !ctx.workersBuilds + ? `${adminUrl.replace(/\/$/, "")}/login#setup=${setupToken}` : undefined; + const instructions = + first && ctx.workersBuilds ? setupInstructions(adminUrl, names.admin) : undefined; return { dryRun: false, names, @@ -205,6 +223,7 @@ export async function deploy(options: DeployOptions): Promise { adminUrl, pushed, ...(setupUrl ? { setupUrl } : {}), + ...(instructions ? { setupInstructions: instructions } : {}), ...(cfg.domains?.site ? { customSiteUrl: `https://${cfg.domains.site}` } : {}), ...(cfg.domains?.studio ? { customStudioUrl: `https://${cfg.domains.studio}` } : {}), scheduler, diff --git a/packages/cli/src/commands/deploy/attach.ts b/packages/cli/src/commands/deploy/attach.ts index 7a3d25e7..7db9d036 100644 --- a/packages/cli/src/commands/deploy/attach.ts +++ b/packages/cli/src/commands/deploy/attach.ts @@ -52,7 +52,8 @@ async function installationOf(projectDir: string, folder: string) { export async function deployAttached(options: DeployOptions): Promise { const own = await readProjectConfig(options.projectDir); const inst = await installationOf(options.projectDir, own.installation as string); - const loaded = await loadDeploy({ ...options, name: inst.state.name }); + // the installation lives in another local project, never in a Workers Builds checkout + const loaded = await loadDeploy({ ...options, name: inst.state.name, workersBuilds: false }); const base = { ...loaded.base, siteName: `${inst.state.name}-site-${loaded.site.slug}` }; const previous = { ...(loaded.previous ?? inst.state), diff --git a/packages/cli/src/commands/deploy/context.ts b/packages/cli/src/commands/deploy/context.ts index d3311800..902d2691 100644 --- a/packages/cli/src/commands/deploy/context.ts +++ b/packages/cli/src/commands/deploy/context.ts @@ -19,4 +19,6 @@ export interface DeployContext { readonly previous: DeployState | undefined; readonly base: DeploymentNames; readonly names: ReturnType; + /** Running in Cloudflare Workers Builds (`workers-builds.ts`). */ + readonly workersBuilds: boolean; } diff --git a/packages/cli/src/commands/deploy/options.ts b/packages/cli/src/commands/deploy/options.ts index 9959d5b0..a878962f 100644 --- a/packages/cli/src/commands/deploy/options.ts +++ b/packages/cli/src/commands/deploy/options.ts @@ -63,6 +63,12 @@ export interface DeployOptions { readonly buildStudio?: boolean; /** Injected in tests: runs the Studio build. */ readonly studioBuilder?: StudioBuilder; + /** + * Deploying from Cloudflare Workers Builds (the Deploy to Cloudflare button): names from the + * committed `wrangler.jsonc`, the starter seeded when missing, secrets kept on the Workers. + * Default: `WORKERS_CI=1` in the environment. + */ + readonly workersBuilds?: boolean; } export interface DeployResult { @@ -75,6 +81,8 @@ export interface DeployResult { pushed?: boolean; /** First deploy only: open it to create the owner account. */ setupUrl?: string; + /** First deploy from Workers Builds: how to create the owner account (no link is printed). */ + setupInstructions?: string; /** Custom domains from cloudhal.json, routed by this deploy. */ customSiteUrl?: string; customStudioUrl?: string; diff --git a/packages/cli/src/commands/deploy/prepare.ts b/packages/cli/src/commands/deploy/prepare.ts index 39b4839b..0747bd13 100644 --- a/packages/cli/src/commands/deploy/prepare.ts +++ b/packages/cli/src/commands/deploy/prepare.ts @@ -63,6 +63,7 @@ import { rateLimitDeployment, } from "./settings.ts"; import { deployDir, exists, type Jurisdiction, readDeployState } from "./state.ts"; +import { committedNames, inWorkersBuilds, seedIfMissing } from "./workers-builds.ts"; export function checkOptions(options: DeployOptions): void { if (options.jurisdiction !== undefined && !["auto", "eu"].includes(options.jurisdiction)) @@ -80,16 +81,22 @@ export async function loadDeploy(options: DeployOptions): Promise const dir = deployDir(options.projectDir, cfg.dataDir); await mkdir(dir, { recursive: true }); const run = options.wrangler ?? wranglerRunner(options.projectDir, log); + // Workers Builds: the button's names, and the starter site in a checkout that has none + const workersBuilds = inWorkersBuilds(options.workersBuilds); + const committed = workersBuilds + ? await committedNames(siteConfigPath(options.projectDir)) + : undefined; + if (committed) await seedIfMissing(options.projectDir, cfg, committed.name, log); const local = await openLocalStore(path.join(options.projectDir, cfg.dataDir)); const site = await local.store.getSite(cfg.siteId); await local.close(); if (!site) throw new Error(`site ${cfg.siteId} not found in the local store`); const previous = await readDeployState(options.projectDir, cfg.dataDir); - const name = options.name ?? previous?.name ?? site.slug; + const name = options.name ?? committed?.name ?? previous?.name ?? site.slug; // RFC-0029 §3: the search provider's instance and bucket, bound by both Workers. const search = searchDeploymentOf(cfg.search, name); - const base: DeploymentNames = { name, ...(search ? { search } : {}) }; + const base: DeploymentNames = { ...committed, name, ...(search ? { search } : {}) }; if (search) log( `search: Cloudflare AI Search instance ${search.instance} over R2 bucket ${search.bucket} (the instance must exist, read that bucket and declare the text metadata fields locale and kind); ${search.monthlyLimit} semantic searches a month for the deployment (Cloudflare includes 1,000 per account), then FTS5 alone until the month ends`, @@ -97,7 +104,7 @@ export async function loadDeploy(options: DeployOptions): Promise const names = workerNames(base); // 0. analytics: decided before anything is generated; the wrangler version gates the configs const decided = await decideAnalytics(options, cfg, site.name, log); - return { options, log, cfg: decided, dir, run, site, previous, base, names }; + return { options, log, cfg: decided, dir, run, site, previous, base, names, workersBuilds }; } /** 0. analytics bindings (wrangler ≥ 4.145); 0b. reader comments (RFC-0007 §15.4). */ @@ -159,8 +166,11 @@ export async function provision( ctx: DeployContext, ): Promise<{ withIds: DeploymentNames; databaseCreated: Jurisdiction | undefined }> { const { options, names, log, base } = ctx; + // Workers Builds: the database the button provisioned (its token may not read D1) + const known = ctx.workersBuilds ? base.databaseId : undefined; const existing = await readExistingIds(siteConfigPath(options.projectDir)); - let databaseId = existing.databaseId; + // (a placeholder id in the committed file is no id: never carried into the config) + let databaseId = ctx.workersBuilds ? known : existing.databaseId; let databaseCreated: Jurisdiction | undefined; if (!options.dryRun) { const r = await ensureResources(ctx.run, { @@ -168,6 +178,7 @@ export async function provision( names, log, where: jurisdictionChooser(options, log), + ...(known ? { databaseId: known } : {}), }); databaseId = r.databaseId; databaseCreated = r.created; diff --git a/packages/cli/src/commands/deploy/resources.ts b/packages/cli/src/commands/deploy/resources.ts index c521acc0..5f1d46af 100644 --- a/packages/cli/src/commands/deploy/resources.ts +++ b/packages/cli/src/commands/deploy/resources.ts @@ -12,12 +12,15 @@ export async function ensureResources( names, log, where, + databaseId: known, }: { cwd: string; names: ReturnType; log: (l: string) => void; /** Asked only when the database is created: where it lives. */ where: () => Promise; + /** A database already provisioned (Workers Builds): no D1 call at all. */ + databaseId?: string; }, ): Promise<{ databaseId: string; created?: Jurisdiction }> { const info = async () => { @@ -29,7 +32,7 @@ export async function ensureResources( return undefined; } }; - let databaseId = await info(); + let databaseId = known ?? (await info()); let made: Jurisdiction | undefined; if (!databaseId) { made = await where(); diff --git a/packages/cli/src/commands/deploy/ship.ts b/packages/cli/src/commands/deploy/ship.ts index a5fe616c..7db233cf 100644 --- a/packages/cli/src/commands/deploy/ship.ts +++ b/packages/cli/src/commands/deploy/ship.ts @@ -28,7 +28,9 @@ type PreparedAdmin = Awaited>; export async function shipWorkers( ctx: DeployContext, input: { - secrets: DeploySecrets; + /** What this deploy sets on each Worker (the site Worker's share of the admin Worker's). */ + siteSecrets: Partial; + adminSecrets: Partial; admin: PreparedAdmin; prepareAdmin: (ticker: SchedulerTicker) => Promise; scheduler: SchedulerTicker; @@ -39,29 +41,27 @@ export async function shipWorkers( scheduler: SchedulerTicker; }> { const { options, run, names, log, dir } = ctx; - const { secrets, admin } = input; + const { admin } = input; let scheduler = input.scheduler; - // Secrets go to wrangler through files that exist only while it runs (0600, then removed). + // Secrets go to wrangler through files that exist only while it runs (0600, then removed); a + // Worker keeps every secret a deploy does not set. const siteSecretsFile = path.join(dir, "site-secrets.json"); const adminSecretsFile = path.join(dir, "admin-secrets.json"); - const writeSecret = async (file: string, value: object) => { + const secretsArgs = async (file: string, value: object) => { + if (Object.keys(value).length === 0) return []; await writeFile(file, JSON.stringify(value), { mode: 0o600 }); await chmod(file, 0o600); + return ["--secrets-file", file]; }; let siteDeploy: Awaited>; let adminDeploy: Awaited>; try { - await writeSecret(siteSecretsFile, { - CLOUDHAL_PREVIEW_SECRET: secrets.CLOUDHAL_PREVIEW_SECRET, - CLOUDHAL_PURGE_TOKEN: secrets.CLOUDHAL_PURGE_TOKEN, - // RFC-0007: the same value as the admin Worker's (proof of work, emails, stored secrets) - CLOUDHAL_COMMENTS_KEY: secrets.CLOUDHAL_COMMENTS_KEY, - }); - await writeSecret(adminSecretsFile, secrets); + const siteArgs = await secretsArgs(siteSecretsFile, input.siteSecrets); + const adminSecrets = await secretsArgs(adminSecretsFile, input.adminSecrets); log(`deploying ${names.site}`); - siteDeploy = await must(run, ["deploy", "--secrets-file", siteSecretsFile], options.projectDir); + siteDeploy = await must(run, ["deploy", ...siteArgs], options.projectDir); log(`deploying ${names.admin}`); - const adminArgs = ["deploy", "-c", admin.configPath, "--secrets-file", adminSecretsFile]; + const adminArgs = ["deploy", "-c", admin.configPath, ...adminSecrets]; adminDeploy = await run(adminArgs, { cwd: admin.dir }); if ( adminDeploy.code !== 0 && diff --git a/packages/cli/src/commands/deploy/state.ts b/packages/cli/src/commands/deploy/state.ts index 14012c46..8d133281 100644 --- a/packages/cli/src/commands/deploy/state.ts +++ b/packages/cli/src/commands/deploy/state.ts @@ -14,6 +14,12 @@ export const SECRET_KEYS = [ "CLOUDHAL_COMMENTS_KEY", ] as const; export type DeploySecrets = Record<(typeof SECRET_KEYS)[number], string>; +/** The secrets the site Worker has too, with the same values as the admin Worker's. */ +export const SITE_SECRET_KEYS = [ + "CLOUDHAL_PREVIEW_SECRET", + "CLOUDHAL_PURGE_TOKEN", + "CLOUDHAL_COMMENTS_KEY", +] as const satisfies readonly (typeof SECRET_KEYS)[number][]; export interface DeployState { name: string; diff --git a/packages/cli/src/commands/deploy/workers-builds.ts b/packages/cli/src/commands/deploy/workers-builds.ts new file mode 100644 index 00000000..60dc7be2 --- /dev/null +++ b/packages/cli/src/commands/deploy/workers-builds.ts @@ -0,0 +1,146 @@ +/** + * `cloudhal deploy` inside Cloudflare Workers Builds (`WORKERS_CI=1`), which the Deploy to + * Cloudflare button sets up (docs/guides/deploy-button.md). Every build starts from a fresh clone: + * no local store, no `secrets.json`, no `state.json`. So: + * + * - names: the committed `wrangler.jsonc` is the button's — the Worker it connected (which + * Workers Builds requires the config to name) and the D1 database and R2 bucket it provisioned, + * possibly renamed by the person who clicked. The deployment takes those names; the admin Worker + * is `-admin`, where `` is the site Worker's name without `-site`. + * - the site: when the local store lacks `cloudhal.json`'s `siteId`, the starter site is seeded + * under that id. Only the first deploy pushes it; afterwards the deployment is the source of + * truth, as always. + * - secrets: they live only on the Workers. A secret either Worker already has is kept (wrangler + * keeps every secret a deploy does not set); a missing one is generated and set. The secrets + * both Workers share are set together, so they never differ. The deployment token, which only + * this deploy uses, is new on every build. + * - the setup link is never printed (build logs are kept): the owner sets their own setup token + * on the admin Worker and opens the link themselves. + */ +import { chmod, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { openLocalStore } from "@cloudhal/core-ts/local"; +import type { DeploymentNames, workerNames } from "../../cloudflare/config.ts"; +import type { WranglerRunner } from "../../cloudflare/wrangler.ts"; +import type { ProjectConfig } from "../../config.ts"; +import { seedProjectSite } from "../../seed-site.ts"; +import { type DeploySecrets, randomSecret, SECRET_KEYS, SITE_SECRET_KEYS } from "./state.ts"; + +/** Whether this deploy runs in Workers Builds (`WORKERS_CI=1`, set by Cloudflare). */ +export const inWorkersBuilds = (flag: boolean | undefined): boolean => + flag ?? process.env.WORKERS_CI === "1"; + +/** A placeholder D1 id a template commits before the button provisions the real database. */ +const PLACEHOLDER_ID = /^0{8}-0{4}-0{4}-0{4}-0{12}$/; + +/** The committed `wrangler.jsonc`'s names, read before this deploy rewrites it. */ +export async function committedNames(file: string): Promise> { + let cfg: { + name?: string; + d1_databases?: { binding?: string; database_name?: string; database_id?: string }[]; + r2_buckets?: { binding?: string; bucket_name?: string }[]; + }; + try { + cfg = JSON.parse((await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "")); + } catch { + throw new Error( + `Workers Builds: ${path.basename(file)} is missing or not JSON; it must name the site Worker, its D1 database and R2 bucket (docs/guides/deploy-button.md)`, + ); + } + if (!cfg.name) throw new Error(`Workers Builds: ${path.basename(file)} has no "name"`); + const db = cfg.d1_databases?.find((d) => d.binding === "DB"); + const bucket = cfg.r2_buckets?.find((b) => b.binding === "BLOBS"); + const base = cfg.name.replace(/-site$/, "") || cfg.name; + return { + name: base, + ...(cfg.name !== `${base}-site` ? { siteName: cfg.name } : {}), + ...(db?.database_name ? { databaseName: db.database_name } : {}), + ...(db?.database_id && !PLACEHOLDER_ID.test(db.database_id) + ? { databaseId: db.database_id } + : {}), + ...(bucket?.bucket_name ? { bucketName: bucket.bucket_name } : {}), + }; +} + +/** Seeds the starter site under `cloudhal.json`'s `siteId` when this checkout has none. */ +export async function seedIfMissing( + projectDir: string, + cfg: ProjectConfig, + name: string, + log: (line: string) => void, +): Promise { + const local = await openLocalStore(path.join(projectDir, cfg.dataDir)); + const site = await local.store.getSite(cfg.siteId); + await local.close(); + if (site) return; + await seedProjectSite(projectDir, { dataDir: cfg.dataDir, name, slug: name, siteId: cfg.siteId }); + log( + `Workers Builds: seeded the starter site ${cfg.siteId}; it is pushed only if the deployment does not have it yet`, + ); +} + +async function secretNames(run: WranglerRunner, worker: string, cwd: string): Promise> { + const r = await run(["secret", "list", "--name", worker, "--format", "json"], { + cwd, + quiet: true, + }); + if (r.code !== 0) { + // A Worker that does not exist yet has no secrets; any other failure must stop the deploy, + // or the secrets would be replaced and what they encrypt lost. + if (/not found|10007/i.test(r.stderr + r.stdout)) return new Set(); + throw new Error( + `Workers Builds: could not list the secrets of ${worker}:\n${(r.stderr || r.stdout).slice(-2000)}`, + ); + } + try { + return new Set((JSON.parse(r.stdout) as { name: string }[]).map((s) => s.name)); + } catch { + throw new Error(`Workers Builds: could not read the secret list of ${worker}`); + } +} + +export interface BuildsSecrets { + /** What this deploy sets on each Worker; everything else the Workers keep. */ + readonly site: Partial; + readonly admin: Partial; + /** Every value this deploy knows (always the new deployment token). */ + readonly known: Partial & { CLOUDHAL_DEPLOY_TOKEN: string }; +} + +/** + * The secrets to set: the missing ones and a new deployment token, written to the checkout's + * `secrets.json` (0600, gone with the build) for `cloudhal push`. + */ +export async function buildsSecrets( + run: WranglerRunner, + input: { cwd: string; dir: string; names: ReturnType }, +): Promise { + const site = await secretNames(run, input.names.site, input.cwd); + const admin = await secretNames(run, input.names.admin, input.cwd); + const shared: readonly string[] = SITE_SECRET_KEYS; + const known: Partial & { CLOUDHAL_DEPLOY_TOKEN: string } = { + CLOUDHAL_DEPLOY_TOKEN: randomSecret(), + }; + for (const key of SECRET_KEYS) { + const missing = shared.includes(key) ? !site.has(key) || !admin.has(key) : !admin.has(key); + if (missing && !known[key]) known[key] = randomSecret(); + } + const pick = (keys: readonly string[]) => + Object.fromEntries(Object.entries(known).filter(([k]) => keys.includes(k))); + const file = path.join(input.dir, "secrets.json"); + await writeFile(file, `${JSON.stringify(known, null, 2)}\n`, { mode: 0o600 }); + await chmod(file, 0o600); + return { site: pick(shared), admin: known, known }; +} + +/** What the owner does once, instead of a printed setup link. */ +export function setupInstructions(adminUrl: string, adminWorker: string): string { + const login = `${adminUrl.replace(/\/$/, "")}/login#setup=`; + return [ + "Create the owner account (once). The setup link is not printed, because build logs are kept:", + ` 1. Cloudflare dashboard › Workers & Pages › ${adminWorker} › Settings › Variables and Secrets:`, + " edit CLOUDHAL_SETUP_TOKEN and set a random value of your own (32 characters or more),", + ` or run \`npx wrangler secret put CLOUDHAL_SETUP_TOKEN --name ${adminWorker}\``, + ` 2. open ${login}`, + ].join("\n"); +} diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f82951ab..8d495741 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -9,12 +9,10 @@ import { writeFile, } from "node:fs/promises"; import path from "node:path"; -import { seedStarterSite } from "@cloudhal/core-ts"; -import { openLocalStore } from "@cloudhal/core-ts/local"; import { assetDir, materializeDependencies, monorepoRoot, monorepoVersions } from "../assets.ts"; -import { loadThemeSettings } from "../blocks.ts"; import { writeProjectConfig } from "../config.ts"; import { pnpmWorkspaceYaml } from "../project-setup.ts"; +import { seedProjectSite } from "../seed-site.ts"; import { starterFilter } from "../starter-files.ts"; export interface InitOptions { @@ -109,34 +107,16 @@ export async function init(options: InitOptions): Promise { await writeFile(path.join(projectDir, "pnpm-workspace.yaml"), pnpmWorkspaceYaml()); const dataDir = ".cloudhal"; - const local = await openLocalStore(path.join(projectDir, dataDir)); - try { - const seeded = await seedStarterSite(local.store, { name, slug }); - // RFC-0024 decision 4: new sites start with standard motion when the theme offers it; readers - // who ask for reduced motion still get less, and existing sites keep theirs. - const settings = await loadThemeSettings(projectDir); - const site = - settings && "motion" in settings.properties - ? await local.store.updateSite({ - ...seeded, - theme: { - ...seeded.theme, - config: { ...(seeded.theme.config ?? {}), motion: "standard" }, - }, - }) - : seeded; - // RFC-0006 owner decision 4: new projects record cookie-less analytics by default. - // RFC-0007 §15.4: reader comments are available to the deployment, but stay off for the site - // until someone turns them on in Studio (D4). - await writeProjectConfig(projectDir, { - siteId: site.id, - dataDir, - analytics: { enabled: true }, - comments: { available: true }, - }); - log(`seeded site ${site.id} (${name}) in ${dataDir}/`); - return { projectDir, siteId: site.id, linked: repo !== undefined }; - } finally { - await local.close(); - } + const site = await seedProjectSite(projectDir, { dataDir, name, slug }); + // RFC-0006 owner decision 4: new projects record cookie-less analytics by default. + // RFC-0007 §15.4: reader comments are available to the deployment, but stay off for the site + // until someone turns them on in Studio (D4). + await writeProjectConfig(projectDir, { + siteId: site.id, + dataDir, + analytics: { enabled: true }, + comments: { available: true }, + }); + log(`seeded site ${site.id} (${name}) in ${dataDir}/`); + return { projectDir, siteId: site.id, linked: repo !== undefined }; } diff --git a/packages/cli/src/seed-site.ts b/packages/cli/src/seed-site.ts new file mode 100644 index 00000000..fe9db394 --- /dev/null +++ b/packages/cli/src/seed-site.ts @@ -0,0 +1,34 @@ +/** The starter site a new project starts with (`cloudhal init`, and a deploy from Workers Builds). */ +import path from "node:path"; +import type { Site } from "@cloudhal/core-contracts"; +import { seedStarterSite } from "@cloudhal/core-ts"; +import { openLocalStore } from "@cloudhal/core-ts/local"; +import { loadThemeSettings } from "./blocks.ts"; + +export async function seedProjectSite( + projectDir: string, + input: { dataDir: string; name: string; slug: string; siteId?: string }, +): Promise { + const local = await openLocalStore(path.join(projectDir, input.dataDir)); + try { + const seeded = await seedStarterSite(local.store, { + name: input.name, + slug: input.slug, + ...(input.siteId ? { siteId: input.siteId } : {}), + }); + // RFC-0024 decision 4: new sites start with standard motion when the theme offers it; readers + // who ask for reduced motion still get less, and existing sites keep theirs. + const settings = await loadThemeSettings(projectDir); + return settings && "motion" in settings.properties + ? await local.store.updateSite({ + ...seeded, + theme: { + ...seeded.theme, + config: { ...(seeded.theme.config ?? {}), motion: "standard" }, + }, + }) + : seeded; + } finally { + await local.close(); + } +} diff --git a/packages/cli/test/deploy-workers-builds.test.ts b/packages/cli/test/deploy-workers-builds.test.ts new file mode 100644 index 00000000..5fc096cb --- /dev/null +++ b/packages/cli/test/deploy-workers-builds.test.ts @@ -0,0 +1,245 @@ +/** + * `cloudhal deploy` in Cloudflare Workers Builds (the Deploy to Cloudflare button): every build is a + * fresh clone with no local store and no secrets, so the names come from the committed + * `wrangler.jsonc`, the starter is seeded under `cloudhal.json`'s `siteId`, the secrets stay on the + * Workers and the setup link is never printed. A recording wrangler stand-in plays Cloudflare. + */ +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createApi } from "@cloudhal/api"; +import { SiteGraphStore } from "@cloudhal/core-ts"; +import { MemoryBlobStore } from "@cloudhal/storage"; +import { BetterSqlite3Driver } from "@cloudhal/storage/sqlite"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { WranglerRunner } from "../src/cloudflare/wrangler.ts"; +import { committedNames } from "../src/commands/deploy/workers-builds.ts"; +import { deploy, init, readProjectConfig } from "../src/index.ts"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const STARTER_NODE_MODULES = path.resolve(here, "../../../starters/blog/node_modules"); +const DB_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + +/** The root config as the button leaves it: the Worker and resources renamed by the person. */ +const BUTTON_CONFIG = `// committed by the Deploy to Cloudflare button +{ + "name": "my-blog", + "main": "@cloudhal/astro-renderer/cloudflare-entry", + "compatibility_date": "2026-10-01", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [{ "binding": "DB", "database_name": "my-blog-db", "database_id": "${DB_ID}" }], + "r2_buckets": [{ "binding": "BLOBS", "bucket_name": "my-blog-files" }] +} +`; + +describe("cloudhal deploy in Workers Builds", () => { + let tmp: string; + let projectDir: string; + let studioDir: string; + + beforeAll(async () => { + tmp = await mkdtemp(path.join(os.tmpdir(), "cloudhal-builds-")); + projectDir = path.join(tmp, "site"); + await init({ dir: projectDir, name: "Template" }); + await symlink(STARTER_NODE_MODULES, path.join(projectDir, "node_modules"), "dir"); + studioDir = path.join(tmp, "studio"); + await mkdir(studioDir); + await writeFile(path.join(studioDir, "index.html"), "Studio"); + }); + + afterAll(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it("reads the button's names; a placeholder id is no id, `-site` names the base", async () => { + const file = path.join(tmp, "names.jsonc"); + await writeFile(file, BUTTON_CONFIG); + expect(await committedNames(file)).toEqual({ + name: "my-blog", + siteName: "my-blog", + databaseName: "my-blog-db", + databaseId: DB_ID, + bucketName: "my-blog-files", + }); + await writeFile( + file, + JSON.stringify({ + name: "cloudhal-site", + d1_databases: [ + { + binding: "DB", + database_name: "cloudhal-db", + database_id: "00000000-0000-0000-0000-000000000000", + }, + ], + }), + ); + expect(await committedNames(file)).toEqual({ name: "cloudhal", databaseName: "cloudhal-db" }); + await rm(file); + await expect(committedNames(file)).rejects.toThrow(/missing or not JSON/); + }); + + it("a dry run from a fresh clone seeds the site and never carries a placeholder id", async () => { + const { dataDir } = await readProjectConfig(projectDir); + await rm(path.join(projectDir, dataDir), { recursive: true, force: true }); + await writeFile( + path.join(projectDir, "wrangler.jsonc"), + BUTTON_CONFIG.replace(DB_ID, "00000000-0000-0000-0000-000000000000"), + ); + const r = await deploy({ projectDir, studioDir, dryRun: true, workersBuilds: true }); + expect(r.names.site).toBe("my-blog"); + const generated = (await readFile(path.join(projectDir, "wrangler.jsonc"), "utf8")).replace( + /^\s*\/\/.*$/gm, + "", + ); + expect(JSON.parse(generated).d1_databases).toEqual([ + { binding: "DB", database_name: "my-blog-db" }, + ]); + }); + + it("seeds, deploys with the button's names, keeps the Workers' secrets and never prints the setup link", async () => { + const { siteId, dataDir } = await readProjectConfig(projectDir); + // a fresh clone: the committed config, no local store + const freshClone = async () => { + await rm(path.join(projectDir, dataDir), { recursive: true, force: true }); + await writeFile(path.join(projectDir, "wrangler.jsonc"), BUTTON_CONFIG); + }; + + const sql = await BetterSqlite3Driver.open(":memory:"); + const blobs = new MemoryBlobStore(); + const remote = new SiteGraphStore({ sql, blobs }); + await remote.init(); + // the secrets each "Worker" holds; the admin API answers to its current deployment token + const workers = new Map>(); + const fakeFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const deployToken = workers.get("my-blog-admin")?.CLOUDHAL_DEPLOY_TOKEN ?? "none"; + const app = createApi({ store: remote, admin: { deployToken, storage: { sql, blobs } } }); + return app.fetch(new Request(input, init)); + }) as typeof fetch; + + const calls: string[] = []; + const sent: Record = {}; + let listFailure: string | undefined; + const wrangler: WranglerRunner = async (args) => { + calls.push(args.filter((a) => !a.includes("/")).join(" ")); + const ok = (stdout = "", records: Record[] = []) => ({ + code: 0, + stdout, + stderr: "", + records, + }); + if (args[0] === "secret" && args[1] === "list") { + if (listFailure) return { code: 1, stdout: "", stderr: listFailure, records: [] }; + const held = workers.get(args[3] as string); + if (!held) + return { code: 1, stdout: "", stderr: `Worker "${args[3]}" not found.`, records: [] }; + return ok(JSON.stringify(Object.keys(held).map((name) => ({ name })))); + } + if (args[0] === "deploy") { + const worker = args.includes("-c") ? "my-blog-admin" : "my-blog"; + const file = args.includes("--secrets-file") + ? (args[args.indexOf("--secrets-file") + 1] as string) + : undefined; + const values = file + ? (JSON.parse(await readFile(file, "utf8")) as Record) + : {}; + sent[worker] = Object.keys(values).sort(); + workers.set(worker, { ...workers.get(worker), ...values }); + return ok("", [{ type: "deploy", targets: [`${worker}.acme.workers.dev`] }]); + } + return ok(); + }; + const run = (lines: string[] = []) => + deploy({ + projectDir, + studioDir, + wrangler, + fetch: fakeFetch, + healthTimeoutMs: 5000, + workersBuilds: true, + log: (l) => lines.push(l), + }); + + await freshClone(); + const lines: string[] = []; + const first = await run(lines); + expect(first.names).toEqual({ + site: "my-blog", + admin: "my-blog-admin", + database: "my-blog-db", + bucket: "my-blog-files", + }); + // the button provisioned the database: no D1 call (its build token may not read D1) + expect(calls).toEqual([ + "r2 bucket create my-blog-files", + "r2 bucket lifecycle list my-blog-files", + "r2 bucket lifecycle add my-blog-files cloudhal-bundles --expire-days 30 --force", + "secret list --name my-blog --format json", + "secret list --name my-blog-admin --format json", + "deploy --secrets-file", + "deploy -c --secrets-file", + ]); + expect(sent).toEqual({ + "my-blog": ["CLOUDHAL_COMMENTS_KEY", "CLOUDHAL_PREVIEW_SECRET", "CLOUDHAL_PURGE_TOKEN"], + "my-blog-admin": [ + "BETTER_AUTH_SECRET", + "CLOUDHAL_COMMENTS_KEY", + "CLOUDHAL_DEPLOY_TOKEN", + "CLOUDHAL_PREVIEW_SECRET", + "CLOUDHAL_PURGE_TOKEN", + "CLOUDHAL_SETUP_TOKEN", + "MCP_STATE_KEY", + ], + }); + const site = workers.get("my-blog") as Record; + const admin = workers.get("my-blog-admin") as Record; + expect(site.CLOUDHAL_COMMENTS_KEY).toBe(admin.CLOUDHAL_COMMENTS_KEY); + // the starter was seeded under cloudhal.json's id and pushed + expect(first.pushed).toBe(true); + expect((await remote.getSite(siteId))?.name).toBe("my-blog"); + const generated = JSON.parse( + (await readFile(path.join(projectDir, "wrangler.jsonc"), "utf8")).replace( + /^\s*\/\/.*$/gm, + "", + ), + ) as { name: string; d1_databases: { database_id: string }[] }; + expect(generated.name).toBe("my-blog"); + expect(generated.d1_databases[0]?.database_id).toBe(DB_ID); + // the owner sets their own setup token: the generated one appears nowhere + expect(first.setupUrl).toBeUndefined(); + expect(first.setupInstructions).toContain("my-blog-admin"); + expect(first.setupInstructions).toContain( + "https://my-blog-admin.acme.workers.dev/login#setup=", + ); + expect(`${lines.join("\n")}${first.setupInstructions}`).not.toContain( + admin.CLOUDHAL_SETUP_TOKEN, + ); + + // the next build: another fresh clone; the deployment keeps its content and its secrets + const page = (await remote.listPages(siteId)).find((p) => p.path === "/about"); + await remote.updatePage({ ...(page as NonNullable), title: "Edited live" }); + const before = { ...admin }; + await freshClone(); + calls.length = 0; + const second = await run(); + expect(sent).toEqual({ "my-blog": [], "my-blog-admin": ["CLOUDHAL_DEPLOY_TOKEN"] }); + expect(calls).toContain("deploy"); + expect(second.pushed).toBe(false); + expect(second.setupInstructions).toBeUndefined(); + const after = workers.get("my-blog-admin") as Record; + expect(after.CLOUDHAL_DEPLOY_TOKEN).not.toBe(before.CLOUDHAL_DEPLOY_TOKEN); + expect({ ...after, CLOUDHAL_DEPLOY_TOKEN: "" }).toEqual({ + ...before, + CLOUDHAL_DEPLOY_TOKEN: "", + }); + expect((await remote.getPage(page?.id as string))?.title).toBe("Edited live"); + + // a secret list that fails for another reason stops the deploy before anything is replaced + await freshClone(); + listFailure = "Authentication error [code: 10000]"; + calls.length = 0; + await expect(run()).rejects.toThrow(/could not list the secrets of my-blog/); + expect(calls.some((c) => c.startsWith("deploy"))).toBe(false); + }); +}); diff --git a/packages/core-ts/src/seed.ts b/packages/core-ts/src/seed.ts index 8c4833dc..3acf255d 100644 --- a/packages/core-ts/src/seed.ts +++ b/packages/core-ts/src/seed.ts @@ -10,6 +10,8 @@ export interface SeedOptions { readonly projectId?: string; /** The theme the site is designed for (default `blog`). */ readonly theme?: string; + /** The site's id (default: a new one); a project's `cloudhal.json` `siteId` re-seeded elsewhere. */ + readonly siteId?: string; } /** @@ -32,6 +34,7 @@ export async function seedStarterSite(store: SiteGraphStore, options: SeedOption siteIds: [], })); let site = await store.createSite({ + ...(options.siteId ? { id: options.siteId } : {}), projectId: project.id, name: options.name, slug: options.slug,