From 0fec1b79de54c60e61d52f5335a52b9c9175ee6a Mon Sep 17 00:00:00 2001 From: Sina Meraji Date: Fri, 29 May 2026 15:05:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20complete=20v0.2=20CD=20=E2=80=94=20bind?= =?UTF-8?q?ings,=20Pages,=20D1=20migrations,=20live=20logs,=20run/list/rol?= =?UTF-8?q?lback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes v0.2 to the full PLAN §4 spec, on top of the MVP slice. - Worker bindings: a real YAML-subset parser (deploy/yaml.ts, no heavy dep) lets deploy.yml declare vars/KV/R2/D1/durable_objects/services, emitted into the Workers Scripts metadata.bindings. - Pages: kind: pages deploys via Direct Upload (upload-token → check-missing → upload → create-deployment) with per-branch previews (production_branch). - D1 migrations: applied in order via the D1 query API, opt-in (apply: true), idempotent (applied set tracked per database in the DeployDO). - Live logs: DeployDO streams log lines over a hibernatable WebSocket; the Deployments page subscribes at /r/:name/deployments/stream. - Manual / GitHub-down trigger: `gitflare deploy run` + /control/deploy/run, authed by a CONTROL_SECRET (outside Access, like the webhook). - `gitflare deploy list` + `gitflare deploy rollback [--to ]`; rollback redeploys a previous successful commit via a full clone (migrations skipped). 56 unit tests (yaml/workflow/cf-deploy/highlight/access). Worker bundle 939 KB. Still needs one live run against a real Cloudflare account to confirm the Scripts multipart, Pages Direct Upload, and D1 query wire shapes. Co-Authored-By: Claude Opus 4.8 (1M context) --- PLAN.md | 3 +- packages/cli/src/commands/deploy.ts | 155 +++++- packages/cli/src/config.ts | 2 + packages/cli/src/index.ts | 24 +- packages/worker/src/artifacts/content.ts | 126 +++++ packages/worker/src/deploy/cf-deploy.ts | 263 ++++++++-- packages/worker/src/deploy/workflow.ts | 253 ++++++---- packages/worker/src/deploy/yaml.ts | 164 +++++++ packages/worker/src/durable-objects/deploy.ts | 457 +++++++++++++++--- packages/worker/src/env.ts | 3 + packages/worker/src/index.tsx | 72 +++ packages/worker/src/ui/deployments.tsx | 152 ++++-- packages/worker/test/cf-deploy.test.ts | 98 ++-- packages/worker/test/workflow.test.ts | 106 ++-- packages/worker/test/yaml.test.ts | 61 +++ 15 files changed, 1624 insertions(+), 315 deletions(-) create mode 100644 packages/worker/src/deploy/yaml.ts create mode 100644 packages/worker/test/yaml.test.ts diff --git a/PLAN.md b/PLAN.md index 49ddb9b..bf8966d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -552,7 +552,8 @@ The roadmap in §4 is what we're shipping. This section is *where we are right n | M4.7 | Publish to npm | ✅ done | esbuild bundles the worker (~678 KB) into the CLI's `dist/`. CLI invokes wrangler directly via `require.resolve` (no `pnpm exec` runtime dep). Published as [`gitflare`](https://www.npmjs.com/package/gitflare). Anyone can now `npx gitflare init `. | | M5 | Privacy via Cloudflare Access | 🧪 implemented, not yet live-validated | Worker: `accessGuard` middleware verifies the `Cf-Access-Jwt-Assertion` JWT (RS256 via WebCrypto + JWKS cache, no `jose`) and gates `/` + `/r/*` + `/api/*`; enforces only when `ACCESS_AUD` var is set, so public mirrors stay open. `/webhooks/github` + `/health` left unauth. 8 unit tests. CLI: opt-in `gitflare access enable/disable` creates/deletes a self-hosted Access app + allow-list policy and redeploys with `ACCESS_AUD`/`ACCESS_TEAM_DOMAIN` vars. **Caveat:** this gates the dashboard/API only — `git clone` hits Artifacts directly (`*.artifacts.cloudflare.net`), so it is NOT Access-gated; private-clone is a later (v0.4+) item per §6/§11. **TODO before ✅:** verify the live Access apps/policies API shapes + `aud` field, and that `*.workers.dev` hosts are accepted, against a real account. | | M5.5 | v0.1 polish | 🧪 implemented | Syntax highlighting in the blob viewer (highlight.js core + ~20 curated grammars, server-side; 512 KB cap with `
` fallback; bundle 681→901 KB). README image proxy: new `GET /r/:name/raw/*` serves blob bytes from the Artifacts mirror so images render for private repos and survive GitHub outages — README rewriting now points there instead of `raw.githubusercontent.com`. Styled empty/error states (`ui/states.tsx`): home shows the `npx gitflare init` command, browse 404/500 render through the layout with a way back. |
-| M6 | v0.2 CD (MVP slice) | 🧪 implemented, not yet live-validated | **Self-deploy model (user chose Worker-Secret path).** On push, after sync, the webhook fires `DeployDO` (`waitUntil`), which clones the repo, reads + parses `.gitflare/deploy.yml` (minimal fixed-schema parser, `deploy/workflow.ts`), and for each `cloudflare/deploy { kind: worker }` step uploads the **pre-built** `entry` file to the user's account via the Workers Scripts multipart API (`deploy/cf-deploy.ts`). History persisted in `DeployDO`; Deployments UI at `/r/:name/deployments` (Access-gated, polls DO `/state`). CLI `gitflare deploy enable/disable` stores a `CF_DEPLOY_TOKEN` Worker Secret + sets `ACCOUNT_ID`/`CD_ENABLED` vars (CD gated on `CD_ENABLED` so disable is a clean redeploy). New toml emits a `DEPLOY` DO binding + migration `v2`. 13 new unit tests (parser + upload-form/API). **Out of MVP:** build steps (need Sandbox → v0.3), Pages, bindings/D1 migrations, rollback, Artifacts-push trigger. **TODO before ✅:** verify the live Workers Scripts multipart shape end-to-end against a real account. |
+| M6 | v0.2 CD (MVP slice) | 🧪 implemented | **Self-deploy model (user chose Worker-Secret path).** On push, after sync, the webhook fires `DeployDO` (`waitUntil`), which clones the repo, parses `.gitflare/deploy.yml`, and uploads the pre-built `cloudflare/deploy { kind: worker }` entry via the Workers Scripts multipart API. History in `DeployDO`; Deployments UI; CLI `gitflare deploy enable/disable` stores `CF_DEPLOY_TOKEN` + `CD_ENABLED`. Superseded by M7. |
+| M7 | v0.2 CD — complete (per §4) | 🧪 implemented, not yet live-validated | Everything in §4 v0.2: **(1)** real YAML-subset parser (`deploy/yaml.ts`, no heavyweight dep) → **worker bindings** (vars/KV/R2/D1/DO/services) in the Scripts metadata; **(2)** **Pages** deploys via Direct Upload (upload-token → check-missing → upload → create-deployment), with per-branch **previews** (`production_branch`); **(3)** **D1 migrations** applied in order via the D1 query API, opt-in (`apply: true`), idempotent (applied set tracked per-DB in the DO); **(4)** **live deploy-log streaming** over a hibernatable WebSocket (`/r/:name/deployments/stream`) with the Deployments page subscribing; **(5)** **manual / GitHub-down trigger** `gitflare deploy run` + `/control/deploy/run` (auth'd by a `CONTROL_SECRET`, outside Access); **(6)** `gitflare deploy list` + `gitflare deploy rollback [--to ]` (rollback redeploys a previous successful commit via a full clone; migrations are forward-only and skipped). 56 unit tests across yaml/workflow/cf-deploy/highlight/access. **TODO before ✅:** one live run against a real Cloudflare account to confirm the Scripts multipart, Pages Direct Upload, and D1 query wire shapes. |
 
 ### What's in the repo right now (as of M0)
 
diff --git a/packages/cli/src/commands/deploy.ts b/packages/cli/src/commands/deploy.ts
index 16ce3c6..4585aa4 100644
--- a/packages/cli/src/commands/deploy.ts
+++ b/packages/cli/src/commands/deploy.ts
@@ -2,10 +2,10 @@ import * as p from "@clack/prompts";
 import kleur from "kleur";
 import { CloudflareClient } from "../cloudflare.js";
 import { loadConfig, saveConfig } from "../config.js";
-import { orange } from "../util.js";
+import { orange, randomHex } from "../util.js";
 import { redeployWorker } from "../redeploy.js";
 import { wranglerSecret } from "../wrangler.js";
-import { pickRepo, getCfToken } from "../repo-select.js";
+import { pickRepo, getCfToken, type RepoEntry } from "../repo-select.js";
 
 const TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
 
@@ -83,13 +83,15 @@ export async function runDeployEnable(repoArg: string | undefined): Promise {
+  return fetch(`${entry.workerUrl}${path}`, {
+    method: init?.method ?? "GET",
+    headers: {
+      Authorization: `Bearer ${secret}`,
+      ...(init?.body ? { "Content-Type": "application/json" } : {}),
+    },
+    ...(init?.body ? { body: JSON.stringify(init.body) } : {}),
+  });
+}
+
+export async function runDeployRun(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare deploy run")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  const secret = cdSecret(entry);
+  if (!secret) return;
+
+  const sp = p.spinner();
+  sp.start("Triggering a deploy of the current Artifacts HEAD");
+  try {
+    const res = await controlFetch(entry, secret, "/control/deploy/run", {
+      method: "POST",
+      body: { repo: entry.artifactsRepoName },
+    });
+    if (res.status !== 202) {
+      sp.stop("Trigger failed");
+      p.log.error(`${res.status}: ${await res.text()}`);
+      return;
+    }
+    sp.stop("Deploy triggered (runs in your Worker — works even when GitHub is down)");
+  } catch (e) {
+    sp.stop("Trigger failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+  p.outro(`Watch it at ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/deployments`)}`);
+}
+
+interface DeployRow {
+  id: number;
+  branch: string;
+  sha: string;
+  mode: string;
+  status: string;
+  message?: string;
+  steps: Array<{ project: string; ok: boolean; url?: string }>;
+}
+
+export async function runDeploysList(repoArg: string | undefined): Promise {
+  p.intro(kleur.bold(orange("GitFlare deploys")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  const secret = cdSecret(entry);
+  if (!secret) return;
+
+  const sp = p.spinner();
+  sp.start("Fetching deploy history");
+  let deploys: DeployRow[] = [];
+  try {
+    const res = await controlFetch(entry, secret, `/control/deployments?repo=${encodeURIComponent(entry.artifactsRepoName)}`);
+    if (!res.ok) {
+      sp.stop("Fetch failed");
+      p.log.error(`${res.status}: ${await res.text()}`);
+      return;
+    }
+    ({ deploys } = (await res.json()) as { deploys: DeployRow[] });
+    sp.stop(`${deploys.length} deploy(s)`);
+  } catch (e) {
+    sp.stop("Fetch failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+
+  if (deploys.length === 0) {
+    p.outro("No deploys yet.");
+    return;
+  }
+  for (const d of deploys.slice(0, 20)) {
+    const dot = d.status === "success" ? kleur.green("●") : d.status === "failed" ? kleur.red("●") : kleur.yellow("●");
+    const steps = d.steps.map((s) => `${s.project}${s.ok ? "✓" : "✗"}`).join(" ") || "—";
+    p.log.message(
+      `${dot} #${d.id} ${kleur.cyan(d.branch)} ${kleur.gray(d.sha.slice(0, 8))} ${d.mode}  ${steps}  ${d.status}${d.message ? kleur.gray(` — ${d.message}`) : ""}`,
+    );
+  }
+  p.outro(`Full view: ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/deployments`)}`);
+}
+
+export async function runDeployRollback(
+  repoArg: string | undefined,
+  opts: { to?: string },
+): Promise {
+  p.intro(kleur.bold(orange("GitFlare deploy rollback")));
+  const cfg = await loadConfig();
+  const entry = await pickRepo(cfg, repoArg);
+  if (!entry) return;
+  const secret = cdSecret(entry);
+  if (!secret) return;
+
+  const toDeployId = opts.to ? Number(opts.to) : undefined;
+  if (opts.to && !Number.isInteger(toDeployId)) {
+    p.log.error(`--to must be a deploy id (integer), got "${opts.to}".`);
+    return;
+  }
+
+  const sp = p.spinner();
+  sp.start(toDeployId ? `Rolling back to deploy #${toDeployId}` : "Rolling back to the last successful deploy");
+  try {
+    const res = await controlFetch(entry, secret, "/control/deploy/rollback", {
+      method: "POST",
+      body: { repo: entry.artifactsRepoName, ...(toDeployId ? { toDeployId } : {}) },
+    });
+    if (res.status !== 202) {
+      sp.stop("Rollback failed");
+      p.log.error(`${res.status}: ${await res.text()}`);
+      return;
+    }
+    sp.stop("Rollback triggered");
+  } catch (e) {
+    sp.stop("Rollback failed");
+    p.log.error((e as Error).message);
+    return;
+  }
+  p.outro(`Watch it at ${kleur.cyan(`${entry.workerUrl}/r/${entry.artifactsRepoName}/deployments`)}`);
+}
diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts
index 9defb6d..c5d32c5 100644
--- a/packages/cli/src/config.ts
+++ b/packages/cli/src/config.ts
@@ -22,6 +22,8 @@ export interface LocalConfig {
     // Set by `gitflare deploy enable`; cleared by `disable`.
     deploy?: {
       enabledAt: string;
+      // Bearer secret the CLI presents to the Worker's /control/* endpoints.
+      controlSecret: string;
     };
   }>;
   // Tokens — kept local, never sent to gitflare servers.
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 33b3126..86a6ebc 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -4,7 +4,13 @@ import { createRequire } from "node:module";
 import { runInit } from "./commands/init.js";
 import { runStatus } from "./commands/status.js";
 import { runAccessEnable, runAccessDisable } from "./commands/access.js";
-import { runDeployEnable, runDeployDisable } from "./commands/deploy.js";
+import {
+  runDeployEnable,
+  runDeployDisable,
+  runDeployRun,
+  runDeploysList,
+  runDeployRollback,
+} from "./commands/deploy.js";
 
 const require = createRequire(import.meta.url);
 const pkg = require("../package.json") as { version?: string };
@@ -55,5 +61,21 @@ deploy
   .description("Disable CD for a repo")
   .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
   .action(runDeployDisable);
+deploy
+  .command("run")
+  .description("Deploy the current Artifacts HEAD now (the GitHub-down escape hatch)")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runDeployRun);
+deploy
+  .command("list")
+  .description("List recent deploys for a repo")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .action(runDeploysList);
+deploy
+  .command("rollback")
+  .description("Roll back to a previous deploy (default: last successful)")
+  .argument("[repo]", "github full name or artifacts repo name; prompts if omitted")
+  .option("--to ", "deploy id to roll back to")
+  .action(runDeployRollback);
 
 program.parseAsync(process.argv);
diff --git a/packages/worker/src/artifacts/content.ts b/packages/worker/src/artifacts/content.ts
index 7801bee..c0e2dc2 100644
--- a/packages/worker/src/artifacts/content.ts
+++ b/packages/worker/src/artifacts/content.ts
@@ -178,6 +178,132 @@ function looksBinary(bytes: Uint8Array): boolean {
   return false;
 }
 
+// ---------------------------------------------------------------------------
+// Deploy helpers (v0.2): read a directory of files, and read at a given commit.
+// ---------------------------------------------------------------------------
+
+export interface FileAtPath {
+  path: string; // relative to the requested dir
+  bytes: Uint8Array;
+}
+
+async function treeOidAtPath(
+  shallow: ShallowRepo,
+  commitOid: string,
+  path: string,
+): Promise {
+  const commit = await git.readCommit({ fs: shallow.fs, dir: shallow.dir, oid: commitOid });
+  let treeOid = commit.commit.tree;
+  for (const seg of path.split("/").filter(Boolean)) {
+    const tree = await git.readTree({ fs: shallow.fs, dir: shallow.dir, oid: treeOid });
+    const next = tree.tree.find((e) => e.path === seg);
+    if (!next || next.type !== "tree") return null;
+    treeOid = next.oid;
+  }
+  return treeOid;
+}
+
+/**
+ * Recursively list every blob under `dirPath` (default branch tip), with paths
+ * relative to `dirPath`. Used to upload a Pages static directory. Bounded by
+ * `maxFiles` to avoid unbounded work.
+ */
+export async function listFilesUnder(
+  shallow: ShallowRepo,
+  commitOid: string,
+  dirPath: string,
+  maxFiles = 5000,
+): Promise {
+  const rootTreeOid = await treeOidAtPath(shallow, commitOid, dirPath);
+  if (rootTreeOid === null) return null;
+  const out: FileAtPath[] = [];
+  const walk = async (treeOid: string, prefix: string): Promise => {
+    const tree = await git.readTree({ fs: shallow.fs, dir: shallow.dir, oid: treeOid });
+    for (const e of tree.tree) {
+      if (out.length >= maxFiles) return;
+      const rel = prefix ? `${prefix}/${e.path}` : e.path;
+      if (e.type === "tree") await walk(e.oid, rel);
+      else if (e.type === "blob") {
+        const blob = await git.readBlob({ fs: shallow.fs, dir: shallow.dir, oid: e.oid });
+        out.push({ path: rel, bytes: blob.blob });
+      }
+    }
+  };
+  await walk(rootTreeOid, "");
+  return out;
+}
+
+/** Read a blob at an explicit commit oid (used by rollback). */
+export async function readBlobAtCommit(
+  shallow: ShallowRepo,
+  commitOid: string,
+  path: string,
+): Promise {
+  const segments = path.split("/").filter(Boolean);
+  if (segments.length === 0) return null;
+  const parent = segments.slice(0, -1).join("/");
+  const leaf = segments[segments.length - 1]!;
+  const parentTreeOid = await treeOidAtPath(shallow, commitOid, parent);
+  if (parentTreeOid === null) return null;
+  const parentTree = await git.readTree({ fs: shallow.fs, dir: shallow.dir, oid: parentTreeOid });
+  const entry = parentTree.tree.find((e) => e.path === leaf);
+  if (!entry || entry.type !== "blob") return null;
+  const blob = await git.readBlob({ fs: shallow.fs, dir: shallow.dir, oid: entry.oid });
+  const bytes = blob.blob;
+  const isBinary = looksBinary(bytes);
+  return {
+    path: segments.join("/"),
+    bytes,
+    size: bytes.byteLength,
+    isBinary,
+    ...(isBinary ? {} : { text: new TextDecoder().decode(bytes) }),
+  };
+}
+
+/**
+ * Full clone (no depth limit) of the default branch, so any historical commit
+ * is reachable. Heavier than cloneRepoShallow — used only for rollback.
+ */
+export async function cloneRepoFull(
+  repo: ArtifactsRepo,
+  remote: string,
+): Promise {
+  const tokenResult = (await repo.createToken("read", 180)) as {
+    plaintext?: string;
+    token?: string;
+  };
+  const rawToken = tokenResult.plaintext ?? tokenResult.token;
+  if (!rawToken) throw new Error("createToken returned no token");
+  const password = tokenSecret(rawToken);
+
+  const refs = await git.listServerRefs({
+    http,
+    url: remote,
+    onAuth: () => ({ username: "x", password }),
+  });
+  const head = refs.find((r) => r.ref === "HEAD");
+  if (!head) throw new Error("remote has no HEAD ref");
+  const defaultRef = refs.find(
+    (r) => r.ref !== "HEAD" && r.ref.startsWith("refs/heads/") && r.oid === head.oid,
+  );
+  const branchName = defaultRef ? defaultRef.ref.replace(/^refs\/heads\//, "") : "main";
+
+  const fs = new MemFs();
+  const dir = "/repo";
+  await git.clone({
+    fs,
+    http,
+    dir,
+    url: remote,
+    ref: branchName,
+    singleBranch: true,
+    noCheckout: true,
+    noTags: true,
+    onAuth: () => ({ username: "x", password }),
+  });
+  return { fs, dir, headSha: head.oid, branchName };
+}
+
 /**
  * Top-level tree + README. Convenience wrapper for the home dashboard.
  */
diff --git a/packages/worker/src/deploy/cf-deploy.ts b/packages/worker/src/deploy/cf-deploy.ts
index f4d3101..d2a48c7 100644
--- a/packages/worker/src/deploy/cf-deploy.ts
+++ b/packages/worker/src/deploy/cf-deploy.ts
@@ -1,35 +1,66 @@
-// Uploads a pre-built single-file Worker to the user's own Cloudflare account
-// via the Workers Scripts API — the same multipart PUT `wrangler deploy` makes
-// under the hood. v0.2 MVP: no build step (Workers can't spawn processes), so
-// the repo must commit a built ES-module entry; arbitrary build lands in v0.3
-// (generic CI on Sandboxes).
+// Cloudflare deploy primitives, called from inside the Worker (DeployDO):
+//  - Workers Scripts multipart upload (with bindings)
+//  - Pages Direct Upload (production + preview deploys)
+//  - D1 query execution (for migrations)
 //
-// NOTE: the exact multipart shape is verified against wrangler's behaviour but
-// should be re-checked live before relying on it in production.
+// These mirror what `wrangler` does over the API. The exact wire shapes are
+// per the Cloudflare API docs and SHOULD be re-checked against a live account
+// before being relied on.
+
+import type { WorkerBindings } from "./workflow";
+
+const API = "https://api.cloudflare.com/client/v4";
+
+// ---------------------------------------------------------------------------
+// Workers Scripts upload
+// ---------------------------------------------------------------------------
 
 export interface ScriptUpload {
   scriptName: string;
-  /** The module file name referenced by metadata.main_module. */
-  moduleFileName: string;
-  /** ES-module source. */
+  moduleFileName: string; // referenced by metadata.main_module
   code: string;
   compatibilityDate?: string;
+  bindings?: WorkerBindings;
 }
 
-/**
- * Build the multipart body for `PUT /accounts/{id}/workers/scripts/{name}`.
- * Pure + synchronous so it can be unit-tested without a network.
- */
+/** Translate our binding model into the Workers metadata.bindings array. */
+export function bindingsArray(b?: WorkerBindings): unknown[] {
+  if (!b) return [];
+  const out: unknown[] = [];
+  for (const [name, text] of Object.entries(b.vars))
+    out.push({ type: "plain_text", name, text });
+  for (const kv of b.kv)
+    out.push({ type: "kv_namespace", name: kv.binding, namespace_id: kv.id });
+  for (const r2 of b.r2)
+    out.push({ type: "r2_bucket", name: r2.binding, bucket_name: r2.bucket_name });
+  for (const d1 of b.d1)
+    out.push({ type: "d1", name: d1.binding, id: d1.database_id });
+  for (const dobj of b.durable_objects)
+    out.push({
+      type: "durable_object_namespace",
+      name: dobj.name,
+      class_name: dobj.class_name,
+      ...(dobj.script_name ? { script_name: dobj.script_name } : {}),
+    });
+  for (const svc of b.services)
+    out.push({
+      type: "service",
+      name: svc.binding,
+      service: svc.service,
+      ...(svc.environment ? { environment: svc.environment } : {}),
+    });
+  return out;
+}
+
+/** Build the multipart body for `PUT /workers/scripts/{name}`. Pure + testable. */
 export function buildScriptUploadForm(u: ScriptUpload): FormData {
   const form = new FormData();
   const metadata = {
     main_module: u.moduleFileName,
     compatibility_date: u.compatibilityDate ?? "2026-05-01",
+    bindings: bindingsArray(u.bindings),
   };
-  form.append(
-    "metadata",
-    new Blob([JSON.stringify(metadata)], { type: "application/json" }),
-  );
+  form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }));
   form.append(
     u.moduleFileName,
     new Blob([u.code], { type: "application/javascript+module" }),
@@ -38,41 +69,199 @@ export function buildScriptUploadForm(u: ScriptUpload): FormData {
   return form;
 }
 
-export interface DeployApiParams {
-  accountId: string;
-  apiToken: string;
-  upload: ScriptUpload;
-  /** Injectable for tests. */
-  fetchImpl?: typeof fetch;
-}
-
 export interface DeployApiResult {
   ok: boolean;
   status: number;
   detail?: string;
+  url?: string;
+}
+
+export interface UploadWorkerParams {
+  accountId: string;
+  apiToken: string;
+  upload: ScriptUpload;
+  fetchImpl?: typeof fetch;
 }
 
-export async function uploadWorkerScript(
-  p: DeployApiParams,
-): Promise {
+export async function uploadWorkerScript(p: UploadWorkerParams): Promise {
   const doFetch = p.fetchImpl ?? fetch;
-  const url = `https://api.cloudflare.com/client/v4/accounts/${p.accountId}/workers/scripts/${p.upload.scriptName}`;
+  const url = `${API}/accounts/${p.accountId}/workers/scripts/${p.upload.scriptName}`;
   const res = await doFetch(url, {
     method: "PUT",
     headers: { Authorization: `Bearer ${p.apiToken}` },
     body: buildScriptUploadForm(p.upload),
   });
-  let detail: string | undefined;
+  return envelope(res);
+}
+
+// ---------------------------------------------------------------------------
+// Pages Direct Upload
+// ---------------------------------------------------------------------------
+
+export interface PagesFile {
+  path: string; // path within the site, no leading slash
+  bytes: Uint8Array;
+  contentType: string;
+}
+
+/**
+ * Cloudflare Pages addresses assets by the hex digest of (blake3 normally, but
+ * the API accepts the file's content hash). We use a sha-256 hex digest of the
+ * bytes — the Direct Upload API keys files by this digest; mismatches simply
+ * mean a file re-uploads, never a wrong file. Returns a manifest mapping the
+ * site path to its digest.
+ */
+export async function hashPagesFiles(
+  files: PagesFile[],
+): Promise<{ manifest: Record; byHash: Map }> {
+  const manifest: Record = {};
+  const byHash = new Map();
+  for (const f of files) {
+    const digest = await sha256Hex(f.bytes);
+    manifest["/" + f.path.replace(/^\/+/, "")] = digest;
+    byHash.set(digest, f);
+  }
+  return { manifest, byHash };
+}
+
+export interface PagesDeployParams {
+  accountId: string;
+  apiToken: string;
+  project: string;
+  files: PagesFile[];
+  /** Omit (or set production branch) for a production deploy; any other branch → preview. */
+  branch?: string;
+  fetchImpl?: typeof fetch;
+}
+
+/**
+ * Pages Direct Upload flow:
+ *   1. mint an upload JWT (`/pages/projects/:p/upload-token`)
+ *   2. ask which hashes are missing (`/pages/assets/check-missing`)
+ *   3. upload missing files (`/pages/assets/upload`)
+ *   4. create the deployment (multipart with the manifest)
+ */
+export async function deployPages(p: PagesDeployParams): Promise {
+  const doFetch = p.fetchImpl ?? fetch;
+  const auth = { Authorization: `Bearer ${p.apiToken}` };
+  const base = `${API}/accounts/${p.accountId}/pages/projects/${p.project}`;
+
+  const { manifest, byHash } = await hashPagesFiles(p.files);
+
+  // 1. upload token
+  const tokRes = await doFetch(`${base}/upload-token`, { headers: auth });
+  const tok = await json<{ result?: { jwt?: string } }>(tokRes);
+  const jwt = tok?.result?.jwt;
+  if (!jwt) return envelope(tokRes, "could not mint Pages upload token");
+  const jwtAuth = { Authorization: `Bearer ${jwt}` };
+
+  // 2. which hashes are missing
+  const allHashes = [...byHash.keys()];
+  const missRes = await doFetch(`${API}/pages/assets/check-missing`, {
+    method: "POST",
+    headers: { ...jwtAuth, "Content-Type": "application/json" },
+    body: JSON.stringify({ hashes: allHashes }),
+  });
+  const miss = await json<{ result?: string[] }>(missRes);
+  const missing = miss?.result ?? allHashes;
+
+  // 3. upload missing files (base64 payload, batched)
+  if (missing.length) {
+    const payload = missing.map((h) => {
+      const f = byHash.get(h)!;
+      return {
+        key: h,
+        value: base64(f.bytes),
+        metadata: { contentType: f.contentType },
+        base64: true,
+      };
+    });
+    const upRes = await doFetch(`${API}/pages/assets/upload`, {
+      method: "POST",
+      headers: { ...jwtAuth, "Content-Type": "application/json" },
+      body: JSON.stringify(payload),
+    });
+    if (!upRes.ok) return envelope(upRes, "asset upload failed");
+  }
+
+  // 4. create the deployment
+  const form = new FormData();
+  form.append("manifest", JSON.stringify(manifest));
+  if (p.branch) form.append("branch", p.branch);
+  const depRes = await doFetch(`${base}/deployments`, {
+    method: "POST",
+    headers: auth,
+    body: form,
+  });
+  const env = await envelope(depRes);
+  if (env.ok) {
+    const dep = await json<{ result?: { url?: string } }>(depRes.clone?.() ?? depRes).catch(() => null);
+    if (dep?.result?.url) env.url = dep.result.url;
+  }
+  return env;
+}
+
+// ---------------------------------------------------------------------------
+// D1 query (migrations)
+// ---------------------------------------------------------------------------
+
+export interface D1QueryParams {
+  accountId: string;
+  apiToken: string;
+  databaseId: string;
+  sql: string;
+  fetchImpl?: typeof fetch;
+}
+
+export async function d1Query(p: D1QueryParams): Promise {
+  const doFetch = p.fetchImpl ?? fetch;
+  const res = await doFetch(
+    `${API}/accounts/${p.accountId}/d1/database/${p.databaseId}/query`,
+    {
+      method: "POST",
+      headers: { Authorization: `Bearer ${p.apiToken}`, "Content-Type": "application/json" },
+      body: JSON.stringify({ sql: p.sql }),
+    },
+  );
+  return envelope(res);
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+async function envelope(res: Response, fallbackDetail?: string): Promise {
   try {
-    const json = (await res.json()) as {
+    const j = (await res.clone().json()) as {
       success?: boolean;
       errors?: Array<{ code: number; message: string }>;
     };
-    if (json.errors?.length) {
-      detail = json.errors.map((e) => `[${e.code}] ${e.message}`).join("; ");
-    }
-    return { ok: res.ok && json.success !== false, status: res.status, ...(detail ? { detail } : {}) };
+    const detail = j.errors?.length
+      ? j.errors.map((e) => `[${e.code}] ${e.message}`).join("; ")
+      : fallbackDetail;
+    return { ok: res.ok && j.success !== false, status: res.status, ...(detail ? { detail } : {}) };
   } catch {
-    return { ok: res.ok, status: res.status };
+    return { ok: res.ok, status: res.status, ...(fallbackDetail ? { detail: fallbackDetail } : {}) };
   }
 }
+
+async function json(res: Response): Promise {
+  try {
+    return (await res.clone().json()) as T;
+  } catch {
+    return null;
+  }
+}
+
+async function sha256Hex(bytes: Uint8Array): Promise {
+  const buf = await crypto.subtle.digest("SHA-256", bytes as unknown as BufferSource);
+  let hex = "";
+  for (const b of new Uint8Array(buf)) hex += b.toString(16).padStart(2, "0");
+  return hex;
+}
+
+function base64(bytes: Uint8Array): string {
+  let bin = "";
+  for (const b of bytes) bin += String.fromCharCode(b);
+  return btoa(bin);
+}
diff --git a/packages/worker/src/deploy/workflow.ts b/packages/worker/src/deploy/workflow.ts
index 76c0b39..4960fef 100644
--- a/packages/worker/src/deploy/workflow.ts
+++ b/packages/worker/src/deploy/workflow.ts
@@ -1,26 +1,59 @@
-// Minimal parser for `.gitflare/deploy.yml`. We intentionally do NOT pull a
-// full YAML library — the schema is fixed and small, and the worker bundle
-// ships inside the CLI. This understands exactly the v0.2 shape:
+// Parses + validates `.gitflare/deploy.yml` into a typed workflow. Uses the
+// tiny YAML-subset parser in ./yaml.ts so nested bindings/migrations work
+// without a heavyweight dependency.
 //
 //   on: push
 //   branches: [main]
 //   steps:
 //     - cloudflare/deploy:
 //         project: my-worker
-//         kind: worker
-//         entry: dist/worker.js
-//
-// Anything outside this shape is reported as an error rather than guessed at.
+//         kind: worker            # or "pages"
+//         entry: dist/worker.js   # worker: a file; pages: a directory
+//         compatibility_date: "2026-05-01"
+//         production_branch: main # pages: pushes to other branches → previews
+//         vars:
+//           API_BASE: https://example.com
+//         kv:
+//           - { binding: CACHE, id: "abc123" }
+//         r2:
+//           - { binding: BUCKET, bucket_name: my-bucket }
+//         d1:
+//           - { binding: DB, database_id: "xyz" }
+//         migrations:
+//           dir: migrations
+//           database_id: "xyz"
+//           apply: true           # opt-in gate; without it migrations are listed, not run
+
+import { parseYaml, type YamlValue } from "./yaml";
+
+export interface WorkerBindings {
+  vars: Record;
+  kv: Array<{ binding: string; id: string }>;
+  r2: Array<{ binding: string; bucket_name: string }>;
+  d1: Array<{ binding: string; database_id: string }>;
+  durable_objects: Array<{ name: string; class_name: string; script_name?: string }>;
+  services: Array<{ binding: string; service: string; environment?: string }>;
+}
+
+export interface MigrationsConfig {
+  dir: string;
+  database_id: string;
+  apply: boolean;
+}
 
 export interface DeployStep {
   type: "cloudflare/deploy";
   project: string;
   kind: "worker" | "pages";
   entry: string;
+  compatibility_date?: string;
+  production_branch?: string;
+  bindings: WorkerBindings;
+  migrations?: MigrationsConfig;
 }
 
 export interface DeployWorkflow {
-  on: string[]; // e.g. ["push"]
+  on: string[];
   branches: string[]; // empty = every branch
   steps: DeployStep[];
 }
@@ -30,123 +63,139 @@ export interface ParseResult {
   error?: string;
 }
 
-function stripComment(line: string): string {
-  // Naive: drop a trailing " # ..." comment. Good enough for this schema —
-  // values here are identifiers/paths, not strings containing '#'.
-  const i = line.indexOf(" #");
-  return i === -1 ? line : line.slice(0, i);
+function asArray(v: YamlValue | undefined): YamlValue[] {
+  if (v == null) return [];
+  return Array.isArray(v) ? v : [v];
 }
 
-function parseInlineList(value: string): string[] | null {
-  const m = value.match(/^\[(.*)\]$/);
-  if (!m) return null;
-  return m[1]!
-    .split(",")
-    .map((s) => s.trim().replace(/^["']|["']$/g, ""))
-    .filter(Boolean);
+function asString(v: YamlValue | undefined): string | undefined {
+  if (v == null) return undefined;
+  if (typeof v === "string") return v;
+  if (typeof v === "number" || typeof v === "boolean") return String(v);
+  return undefined;
 }
 
-function unquote(s: string): string {
-  return s.trim().replace(/^["']|["']$/g, "");
+function isObj(v: YamlValue | undefined): v is { [k: string]: YamlValue } {
+  return typeof v === "object" && v !== null && !Array.isArray(v);
 }
 
-export function parseDeployWorkflow(src: string): ParseResult {
-  const rawLines = src.split(/\r?\n/);
-  const on: string[] = [];
-  let branches: string[] = [];
-  const steps: DeployStep[] = [];
+function emptyBindings(): WorkerBindings {
+  return { vars: {}, kv: [], r2: [], d1: [], durable_objects: [], services: [] };
+}
 
-  // Index lines with their indentation, skipping blanks/comment-only lines.
-  const lines: Array<{ indent: number; text: string }> = [];
-  for (const raw of rawLines) {
-    if (raw.trim().startsWith("#")) continue; // full-line comment
-    const noComment = stripComment(raw);
-    if (!noComment.trim()) continue;
-    const indent = noComment.length - noComment.trimStart().length;
-    lines.push({ indent, text: noComment.trim() });
+function parseBindings(step: { [k: string]: YamlValue }): WorkerBindings {
+  const b = emptyBindings();
+  if (isObj(step.vars)) {
+    for (const [k, v] of Object.entries(step.vars)) {
+      const s = asString(v);
+      if (s !== undefined) b.vars[k] = s;
+    }
   }
-
-  let i = 0;
-  while (i < lines.length) {
-    const { indent, text } = lines[i]!;
-    if (indent !== 0) {
-      return { error: `unexpected indentation at: "${text}"` };
+  for (const e of asArray(step.kv)) {
+    if (isObj(e) && asString(e.binding) && asString(e.id))
+      b.kv.push({ binding: asString(e.binding)!, id: asString(e.id)! });
+  }
+  for (const e of asArray(step.r2)) {
+    if (isObj(e) && asString(e.binding) && asString(e.bucket_name))
+      b.r2.push({ binding: asString(e.binding)!, bucket_name: asString(e.bucket_name)! });
+  }
+  for (const e of asArray(step.d1)) {
+    if (isObj(e) && asString(e.binding) && asString(e.database_id))
+      b.d1.push({ binding: asString(e.binding)!, database_id: asString(e.database_id)! });
+  }
+  for (const e of asArray(step.durable_objects)) {
+    if (isObj(e) && asString(e.name) && asString(e.class_name)) {
+      const dobj: WorkerBindings["durable_objects"][number] = {
+        name: asString(e.name)!,
+        class_name: asString(e.class_name)!,
+      };
+      const script = asString(e.script_name);
+      if (script) dobj.script_name = script;
+      b.durable_objects.push(dobj);
     }
-
-    if (text.startsWith("on:")) {
-      const v = text.slice(3).trim();
-      const inline = parseInlineList(v);
-      if (inline) on.push(...inline);
-      else if (v) on.push(unquote(v));
-      i++;
-    } else if (text.startsWith("branches:")) {
-      const v = text.slice("branches:".length).trim();
-      const inline = parseInlineList(v);
-      if (inline) branches = inline;
-      else if (v) branches = [unquote(v)];
-      else {
-        // Block list form.
-        i++;
-        while (i < lines.length && lines[i]!.indent > 0 && lines[i]!.text.startsWith("- ")) {
-          branches.push(unquote(lines[i]!.text.slice(2)));
-          i++;
-        }
-        continue;
-      }
-      i++;
-    } else if (text === "steps:") {
-      i++;
-      const r = parseSteps(lines, i, steps);
-      if (r.error) return { error: r.error };
-      i = r.next;
-    } else {
-      return { error: `unknown top-level key: "${text}"` };
+  }
+  for (const e of asArray(step.services)) {
+    if (isObj(e) && asString(e.binding) && asString(e.service)) {
+      const svc: WorkerBindings["services"][number] = {
+        binding: asString(e.binding)!,
+        service: asString(e.service)!,
+      };
+      const env = asString(e.environment);
+      if (env) svc.environment = env;
+      b.services.push(svc);
     }
   }
+  return b;
+}
 
+export function parseDeployWorkflow(src: string): ParseResult {
+  let root: YamlValue;
+  try {
+    root = parseYaml(src);
+  } catch (e) {
+    return { error: `YAML parse error: ${(e as Error).message}` };
+  }
+  if (!isObj(root)) return { error: "deploy.yml must be a mapping" };
+
+  const on = asArray(root.on).map(asString).filter((s): s is string => !!s);
   if (on.length === 0) return { error: "missing `on:`" };
-  if (steps.length === 0) return { error: "no steps defined" };
 
-  return { workflow: { on, branches, steps } };
-}
+  const branches = asArray(root.branches).map(asString).filter((s): s is string => !!s);
 
-function parseSteps(
-  lines: Array<{ indent: number; text: string }>,
-  start: number,
-  out: DeployStep[],
-): { next: number; error?: string } {
-  let i = start;
-  while (i < lines.length && lines[i]!.indent > 0) {
-    const line = lines[i]!;
-    const m = line.text.match(/^-\s*cloudflare\/deploy:\s*$/);
-    if (!m) {
-      return { next: i, error: `unsupported step: "${line.text}" (only cloudflare/deploy)` };
-    }
-    const stepIndent = line.indent;
-    i++;
-    const fields: Record = {};
-    while (i < lines.length && lines[i]!.indent > stepIndent) {
-      const kv = lines[i]!.text.match(/^([a-zA-Z_]+):\s*(.*)$/);
-      if (kv) fields[kv[1]!] = unquote(kv[2]!);
-      i++;
+  const steps: DeployStep[] = [];
+  for (const raw of asArray(root.steps)) {
+    if (!isObj(raw)) return { error: "each step must be a mapping" };
+    const cfg = raw["cloudflare/deploy"];
+    if (!isObj(cfg)) {
+      const key = Object.keys(raw)[0] ?? "?";
+      return { error: `unsupported step "${key}" (only cloudflare/deploy in v0.2)` };
     }
-    const project = fields.project;
-    const kind = (fields.kind ?? "worker") as DeployStep["kind"];
-    const entry = fields.entry;
-    if (!project) return { next: i, error: "step missing `project`" };
-    if (!entry) return { next: i, error: "step missing `entry`" };
+    const project = asString(cfg.project);
+    const entry = asString(cfg.entry);
+    const kind = (asString(cfg.kind) ?? "worker") as DeployStep["kind"];
+    if (!project) return { error: "step missing `project`" };
+    if (!entry) return { error: "step missing `entry`" };
     if (kind !== "worker" && kind !== "pages") {
-      return { next: i, error: `unsupported kind: "${kind}" (worker only in v0.2 MVP)` };
+      return { error: `unsupported kind "${kind}" (worker | pages)` };
     }
-    out.push({ type: "cloudflare/deploy", project, kind, entry });
+
+    const step: DeployStep = {
+      type: "cloudflare/deploy",
+      project,
+      kind,
+      entry,
+      bindings: parseBindings(cfg),
+    };
+    const compat = asString(cfg.compatibility_date);
+    if (compat) step.compatibility_date = compat;
+    const prodBranch = asString(cfg.production_branch);
+    if (prodBranch) step.production_branch = prodBranch;
+
+    if (isObj(cfg.migrations)) {
+      const dir = asString(cfg.migrations.dir);
+      const databaseId = asString(cfg.migrations.database_id);
+      if (dir && databaseId) {
+        step.migrations = {
+          dir,
+          database_id: databaseId,
+          apply: cfg.migrations.apply === true,
+        };
+      }
+    }
+    steps.push(step);
   }
-  return { next: i };
+
+  if (steps.length === 0) return { error: "no steps defined" };
+  return { workflow: { on, branches, steps } };
 }
 
 /** Does this workflow run for a push to `ref` (e.g. "refs/heads/main")? */
 export function matchesPush(wf: DeployWorkflow, ref: string): boolean {
   if (!wf.on.includes("push")) return false;
   if (wf.branches.length === 0) return true;
-  const branch = ref.replace(/^refs\/heads\//, "");
-  return wf.branches.includes(branch);
+  return wf.branches.includes(branchOf(ref));
+}
+
+export function branchOf(ref: string): string {
+  return ref.replace(/^refs\/heads\//, "");
 }
diff --git a/packages/worker/src/deploy/yaml.ts b/packages/worker/src/deploy/yaml.ts
new file mode 100644
index 0000000..cd3a6d8
--- /dev/null
+++ b/packages/worker/src/deploy/yaml.ts
@@ -0,0 +1,164 @@
+// A tiny YAML-subset parser — just enough for `.gitflare/deploy.yml`. We avoid
+// a full YAML library (~200KB) to keep the worker bundle lean. Supports:
+//   - nested maps (`key:` then indented children)
+//   - block lists (`- item`), including lists of maps
+//   - scalars: strings, numbers, booleans, null
+//   - inline lists: `[a, b, c]`
+//   - quoted strings, `#` comments, blank lines
+// NOT supported (and not needed here): anchors, multi-line scalars, flow maps,
+// multiple documents. Anything outside the subset parses on a best-effort basis.
+
+export type YamlValue =
+  | string
+  | number
+  | boolean
+  | null
+  | YamlValue[]
+  | { [key: string]: YamlValue };
+
+interface Line {
+  indent: number;
+  text: string;
+}
+
+export function parseYaml(src: string): YamlValue {
+  const lines: Line[] = [];
+  for (const raw of src.split(/\r?\n/)) {
+    if (raw.trim().startsWith("#")) continue;
+    const noComment = stripComment(raw);
+    if (!noComment.trim()) continue;
+    lines.push({
+      indent: noComment.length - noComment.trimStart().length,
+      text: noComment.trimEnd(),
+    });
+  }
+  if (lines.length === 0) return null;
+  const [value] = parseBlock(lines, 0, lines[0]!.indent);
+  return value;
+}
+
+// Returns [parsed value, next line index].
+function parseBlock(lines: Line[], start: number, indent: number): [YamlValue, number] {
+  if (start >= lines.length) return [null, start];
+  const first = lines[start]!;
+  if (first.indent < indent) return [null, start];
+  return first.text.trimStart().startsWith("- ")
+    ? parseList(lines, start, indent)
+    : parseMap(lines, start, indent);
+}
+
+function parseList(lines: Line[], start: number, indent: number): [YamlValue[], number] {
+  const out: YamlValue[] = [];
+  let i = start;
+  while (i < lines.length && lines[i]!.indent === indent && lines[i]!.text.trimStart().startsWith("- ")) {
+    const rest = lines[i]!.text.trimStart().slice(2).trim();
+    // The content after "- " sits at a virtual indent past the dash.
+    const itemIndent = lines[i]!.indent + 2;
+    if (rest === "") {
+      // Nested block on following lines.
+      const [val, next] = parseBlock(lines, i + 1, lines[i + 1]?.indent ?? itemIndent);
+      out.push(val);
+      i = next;
+    } else if (isMapEntry(rest)) {
+      // List item is a map whose first key is inline with the dash.
+      const synthetic: Line[] = [{ indent: itemIndent, text: rest }];
+      // Pull in subsequent lines indented under the item.
+      let j = i + 1;
+      while (j < lines.length && lines[j]!.indent >= itemIndent) {
+        synthetic.push(lines[j]!);
+        j++;
+      }
+      const [val] = parseMap(synthetic, 0, itemIndent);
+      out.push(val);
+      i = j;
+    } else {
+      out.push(scalar(rest));
+      i++;
+    }
+  }
+  return [out, i];
+}
+
+function parseMap(lines: Line[], start: number, indent: number): [{ [k: string]: YamlValue }, number] {
+  const out: { [k: string]: YamlValue } = {};
+  let i = start;
+  while (i < lines.length && lines[i]!.indent === indent) {
+    const line = lines[i]!.text.trim();
+    const colon = splitKey(line);
+    if (!colon) break;
+    const { key, value } = colon;
+    if (value === "") {
+      // Value is a nested block on the following more-indented lines.
+      const childIndent = lines[i + 1]?.indent ?? indent + 1;
+      if (i + 1 < lines.length && childIndent > indent) {
+        const [val, next] = parseBlock(lines, i + 1, childIndent);
+        out[key] = val;
+        i = next;
+      } else {
+        out[key] = null;
+        i++;
+      }
+    } else {
+      out[key] = scalar(value);
+      i++;
+    }
+  }
+  return [out, i];
+}
+
+function isMapEntry(s: string): boolean {
+  return splitKey(s) !== null;
+}
+
+// Split "key: value" on the first ": " (or trailing ":"). Returns null if the
+// line isn't a map entry (e.g. an inline list or bare scalar).
+function splitKey(s: string): { key: string; value: string } | null {
+  const m = s.match(/^([A-Za-z0-9_./-]+):(?:\s+(.*))?$/);
+  if (!m) return null;
+  return { key: m[1]!, value: (m[2] ?? "").trim() };
+}
+
+function scalar(raw: string): YamlValue {
+  const s = raw.trim();
+  if (s.startsWith("[") && s.endsWith("]")) {
+    const inner = s.slice(1, -1).trim();
+    if (!inner) return [];
+    return inner.split(",").map((x) => scalar(x));
+  }
+  if (s.startsWith("{") && s.endsWith("}")) {
+    // Inline flow map: { key: value, key2: value2 } (flat — no nested commas).
+    const inner = s.slice(1, -1).trim();
+    const obj: { [k: string]: YamlValue } = {};
+    if (!inner) return obj;
+    for (const pair of inner.split(",")) {
+      const idx = pair.indexOf(":");
+      if (idx === -1) continue;
+      const key = pair.slice(0, idx).trim().replace(/^["']|["']$/g, "");
+      obj[key] = scalar(pair.slice(idx + 1));
+    }
+    return obj;
+  }
+  if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
+    return s.slice(1, -1);
+  }
+  if (s === "true") return true;
+  if (s === "false") return false;
+  if (s === "null" || s === "~") return null;
+  if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
+  return s;
+}
+
+function stripComment(line: string): string {
+  // Drop a trailing comment that isn't inside quotes. Simple scan.
+  let inS = false;
+  let inD = false;
+  for (let i = 0; i < line.length; i++) {
+    const c = line[i]!;
+    if (c === "'" && !inD) inS = !inS;
+    else if (c === '"' && !inS) inD = !inD;
+    else if (c === "#" && !inS && !inD && (i === 0 || line[i - 1] === " " || line[i - 1] === "\t")) {
+      return line.slice(0, i);
+    }
+  }
+  return line;
+}
diff --git a/packages/worker/src/durable-objects/deploy.ts b/packages/worker/src/durable-objects/deploy.ts
index b0b0474..5a4f3c7 100644
--- a/packages/worker/src/durable-objects/deploy.ts
+++ b/packages/worker/src/durable-objects/deploy.ts
@@ -1,16 +1,45 @@
 import type { Env } from "../env";
-import { cloneRepoShallow, readBlobAt } from "../artifacts/content";
-import { parseDeployWorkflow, matchesPush } from "../deploy/workflow";
-import { uploadWorkerScript } from "../deploy/cf-deploy";
+import {
+  cloneRepoShallow,
+  cloneRepoFull,
+  readBlobAtCommit,
+  listFilesUnder,
+} from "../artifacts/content";
+import {
+  parseDeployWorkflow,
+  matchesPush,
+  branchOf,
+  type DeployStep,
+  type DeployWorkflow,
+} from "../deploy/workflow";
+import {
+  uploadWorkerScript,
+  deployPages,
+  d1Query,
+  type PagesFile,
+  type DeployApiResult,
+} from "../deploy/cf-deploy";
+import type { ShallowRepo } from "../artifacts/content";
+
+export interface DeployStepResult {
+  project: string;
+  kind: string;
+  ok: boolean;
+  detail?: string;
+  url?: string;
+}
 
 export interface DeployRecord {
   id: number;
   ref: string;
+  branch: string;
   sha: string;
+  mode: "push" | "manual" | "rollback";
   startedAt: number;
   finishedAt?: number;
   status: "running" | "success" | "failed" | "skipped";
-  steps: Array<{ project: string; kind: string; ok: boolean; detail?: string }>;
+  steps: DeployStepResult[];
+  logs: string[];
   message?: string;
 }
 
@@ -19,18 +48,47 @@ interface DeployRequest {
   remote: string;
   ref: string;
   sha: string;
+  mode?: "push" | "manual";
+}
+
+interface RollbackRequest {
+  artifactsRepoName: string;
+  remote: string;
+  toDeployId?: number; // omitted → most recent successful deploy
 }
 
 const WORKFLOW_PATH = ".gitflare/deploy.yml";
+const MAX_LOG_LINES = 500;
+const CONTENT_TYPES: Record = {
+  html: "text/html",
+  css: "text/css",
+  js: "application/javascript",
+  mjs: "application/javascript",
+  json: "application/json",
+  svg: "image/svg+xml",
+  png: "image/png",
+  jpg: "image/jpeg",
+  jpeg: "image/jpeg",
+  gif: "image/gif",
+  webp: "image/webp",
+  ico: "image/x-icon",
+  woff: "font/woff",
+  woff2: "font/woff2",
+  txt: "text/plain",
+  wasm: "application/wasm",
+  map: "application/json",
+};
 
 /**
- * Per-repo deploy stream. Serializes deploys, records history, and runs the
- * Workers Scripts upload. Mirrors RepoDO's shape (idFromName per repo).
+ * Per-repo deploy stream. Serializes deploys, records history + logs, runs the
+ * Workers Scripts / Pages / D1 calls, and live-streams logs over a hibernatable
+ * WebSocket. Mirrors RepoDO's idFromName-per-repo shape.
  */
 export class DeployDO {
   private state: DurableObjectState;
   private env: Env;
   private inFlight: Promise | null = null;
+  private current: DeployRecord | null = null;
 
   constructor(state: DurableObjectState, env: Env) {
     this.state = state;
@@ -39,16 +97,25 @@ export class DeployDO {
 
   async fetch(request: Request): Promise {
     const url = new URL(request.url);
+
+    if (url.pathname === "/stream") {
+      const pair = new WebSocketPair();
+      const [client, server] = [pair[0], pair[1]];
+      this.state.acceptWebSocket(server);
+      // Replay the current run's logs so a late subscriber catches up.
+      if (this.current) {
+        server.send(JSON.stringify({ type: "snapshot", record: this.current }));
+      }
+      return new Response(null, { status: 101, webSocket: client });
+    }
+
     if (request.method === "POST" && url.pathname === "/deploy") {
       const body = (await request.json()) as DeployRequest;
-      const prior = this.inFlight ?? Promise.resolve();
-      const run = prior.then(() => this.runDeploy(body));
-      this.inFlight = run.catch(() => undefined);
-      try {
-        return Response.json(await run);
-      } catch (err) {
-        return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
-      }
+      return this.serialize(() => this.runDeploy(body));
+    }
+    if (request.method === "POST" && url.pathname === "/rollback") {
+      const body = (await request.json()) as RollbackRequest;
+      return this.serialize(() => this.runRollback(body));
     }
     if (request.method === "GET" && url.pathname === "/state") {
       return Response.json({ deploys: await this.history() });
@@ -56,6 +123,41 @@ export class DeployDO {
     return new Response("not found", { status: 404 });
   }
 
+  // Hibernatable WebSocket handlers (no inbound messages expected).
+  webSocketMessage(): void {}
+  webSocketClose(): void {}
+  webSocketError(): void {}
+
+  private async serialize(run: () => Promise): Promise {
+    const prior = this.inFlight ?? Promise.resolve();
+    const next = prior.then(run);
+    this.inFlight = next.catch(() => undefined);
+    try {
+      return Response.json(await next);
+    } catch (err) {
+      return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
+    }
+  }
+
+  private broadcast(msg: unknown): void {
+    const data = JSON.stringify(msg);
+    for (const ws of this.state.getWebSockets()) {
+      try {
+        ws.send(data);
+      } catch {
+        // socket gone — ignore
+      }
+    }
+  }
+
+  private log(line: string): void {
+    if (!this.current) return;
+    const stamped = `${new Date().toISOString()}  ${line}`;
+    this.current.logs.push(stamped);
+    if (this.current.logs.length > MAX_LOG_LINES) this.current.logs.shift();
+    this.broadcast({ type: "log", line: stamped });
+  }
+
   private async nextId(): Promise {
     const last = (await this.state.storage.get("lastId")) ?? 0;
     const id = last + 1;
@@ -67,97 +169,310 @@ export class DeployDO {
     await this.state.storage.put(`deploy:${String(r.id).padStart(10, "0")}`, r);
   }
 
-  private async runDeploy(req: DeployRequest): Promise {
+  private async begin(
+    ref: string,
+    sha: string,
+    mode: DeployRecord["mode"],
+  ): Promise {
     const id = await this.nextId();
     const rec: DeployRecord = {
       id,
-      ref: req.ref,
-      sha: req.sha,
+      ref,
+      branch: branchOf(ref),
+      sha,
+      mode,
       startedAt: Date.now(),
       status: "running",
       steps: [],
+      logs: [],
     };
+    this.current = rec;
     await this.record(rec);
+    this.broadcast({ type: "start", record: rec });
+    this.log(`deploy #${id} started (${mode}, ${rec.branch} @ ${sha.slice(0, 8)})`);
+    return rec;
+  }
 
-    const finish = async (
-      status: DeployRecord["status"],
-      message?: string,
-    ): Promise => {
-      rec.status = status;
-      rec.finishedAt = Date.now();
-      if (message) rec.message = message;
-      await this.record(rec);
-      return rec;
-    };
+  private async finish(
+    rec: DeployRecord,
+    status: DeployRecord["status"],
+    message?: string,
+  ): Promise {
+    rec.status = status;
+    rec.finishedAt = Date.now();
+    if (message) rec.message = message;
+    this.log(`deploy #${rec.id} ${status}${message ? `: ${message}` : ""}`);
+    await this.record(rec);
+    this.broadcast({ type: "done", record: rec });
+    this.current = null;
+    return rec;
+  }
 
+  private creds(): { token: string; accountId: string } | null {
     const token = this.env.CF_DEPLOY_TOKEN;
     const accountId = this.env.ACCOUNT_ID;
-    if (this.env.CD_ENABLED !== "1" || !token || !accountId) {
-      return finish("skipped", "CD not enabled. Run `gitflare deploy enable`.");
-    }
+    if (this.env.CD_ENABLED !== "1" || !token || !accountId) return null;
+    return { token, accountId };
+  }
+
+  private async loadWorkflow(
+    shallow: ShallowRepo,
+    commitOid: string,
+  ): Promise {
+    const blob = await readBlobAtCommit(shallow, commitOid, WORKFLOW_PATH).catch(() => null);
+    if (!blob || blob.isBinary || !blob.text) return { error: `no ${WORKFLOW_PATH}` };
+    const parsed = parseDeployWorkflow(blob.text);
+    if (parsed.error || !parsed.workflow) return { error: `invalid ${WORKFLOW_PATH}: ${parsed.error}` };
+    return parsed.workflow;
+  }
+
+  // -- push / manual deploy -------------------------------------------------
+
+  private async runDeploy(req: DeployRequest): Promise {
+    const mode = req.mode ?? "push";
 
-    let shallow;
+    // Clone first so a manual run (empty ref/sha — the GitHub-down escape hatch)
+    // can learn the current default branch + tip from Artifacts directly.
+    let shallow: ShallowRepo;
     try {
-      const handle = await this.env.ARTIFACTS.get(req.artifactsRepoName);
-      shallow = await cloneRepoShallow(handle, req.remote);
+      shallow = await cloneRepoShallow(await this.env.ARTIFACTS.get(req.artifactsRepoName), req.remote);
     } catch (e) {
-      return finish("failed", `clone failed: ${(e as Error).message}`);
+      const rec = await this.begin(req.ref || "refs/heads/?", req.sha || "0".repeat(40), mode);
+      return this.finish(rec, "failed", `clone failed: ${(e as Error).message}`);
     }
 
-    const wfBlob = await readBlobAt(shallow, WORKFLOW_PATH).catch(() => null);
-    if (!wfBlob || wfBlob.isBinary || !wfBlob.text) {
-      return finish("skipped", `no ${WORKFLOW_PATH}`);
+    const ref = req.ref || `refs/heads/${shallow.branchName}`;
+    const sha = req.sha || shallow.headSha;
+    const rec = await this.begin(ref, sha, mode);
+
+    const creds = this.creds();
+    if (!creds) return this.finish(rec, "skipped", "CD not enabled — run `gitflare deploy enable`");
+
+    const wf = await this.loadWorkflow(shallow, shallow.headSha);
+    if ("error" in wf) return this.finish(rec, "skipped", wf.error);
+    // A manual run is an explicit "deploy now" and bypasses branch matching.
+    if (mode !== "manual" && !matchesPush(wf, ref)) {
+      return this.finish(rec, "skipped", `${rec.branch} not matched by workflow branches`);
     }
 
-    const parsed = parseDeployWorkflow(wfBlob.text);
-    if (parsed.error || !parsed.workflow) {
-      return finish("failed", `invalid ${WORKFLOW_PATH}: ${parsed.error}`);
+    const anyFailed = await this.runSteps(rec, wf.steps, shallow, shallow.headSha, creds, rec.branch);
+    return this.finish(rec, anyFailed ? "failed" : "success");
+  }
+
+  // -- rollback -------------------------------------------------------------
+
+  private async runRollback(req: RollbackRequest): Promise {
+    let target: DeployRecord | undefined;
+    if (req.toDeployId) {
+      target = await this.state.storage.get(
+        `deploy:${String(req.toDeployId).padStart(10, "0")}`,
+      );
+    } else {
+      // Default: the most recent successful, non-rollback deploy.
+      const hist = await this.history();
+      target = hist.find((d) => d.status === "success" && d.mode !== "rollback");
     }
-    if (!matchesPush(parsed.workflow, req.ref)) {
-      return finish("skipped", `${req.ref} doesn't match workflow branches`);
+    if (!target) {
+      const rec = await this.begin("refs/heads/?", "0".repeat(40), "rollback");
+      return this.finish(
+        rec,
+        "failed",
+        req.toDeployId ? `deploy #${req.toDeployId} not found` : "no prior successful deploy to roll back to",
+      );
     }
+    const rec = await this.begin(target.ref, target.sha, "rollback");
+    const creds = this.creds();
+    if (!creds) return this.finish(rec, "skipped", "CD not enabled");
+
+    this.log(`rolling back to deploy #${target.id} (${target.sha.slice(0, 8)})`);
+    let full: ShallowRepo;
+    try {
+      full = await cloneRepoFull(await this.env.ARTIFACTS.get(req.artifactsRepoName), req.remote);
+    } catch (e) {
+      return this.finish(rec, "failed", `full clone failed: ${(e as Error).message}`);
+    }
+
+    const wf = await this.loadWorkflow(full, target.sha);
+    if ("error" in wf) return this.finish(rec, "failed", `cannot read workflow at target: ${wf.error}`);
 
+    // Rollback never re-runs migrations (they're forward-only).
+    const steps = wf.steps.map((s) => {
+      const { migrations, ...rest } = s;
+      void migrations;
+      return rest as DeployStep;
+    });
+    const anyFailed = await this.runSteps(rec, steps, full, target.sha, creds, target.branch);
+    return this.finish(rec, anyFailed ? "failed" : "success");
+  }
+
+  // -- shared step runner ---------------------------------------------------
+
+  private async runSteps(
+    rec: DeployRecord,
+    steps: DeployStep[],
+    shallow: ShallowRepo,
+    commitOid: string,
+    creds: { token: string; accountId: string },
+    branch: string,
+  ): Promise {
     let anyFailed = false;
-    for (const step of parsed.workflow.steps) {
-      const entryBlob = await readBlobAt(shallow, step.entry).catch(() => null);
-      if (!entryBlob || entryBlob.isBinary || !entryBlob.text) {
-        rec.steps.push({ project: step.project, kind: step.kind, ok: false, detail: `entry not found: ${step.entry}` });
-        anyFailed = true;
-        continue;
-      }
-      if (step.kind !== "worker") {
-        rec.steps.push({ project: step.project, kind: step.kind, ok: false, detail: "only kind: worker in v0.2 MVP" });
+    for (const step of steps) {
+      this.log(`▸ ${step.kind} deploy: ${step.project}`);
+      try {
+        if (step.migrations?.apply) {
+          const mig = await this.runMigrations(step, shallow, commitOid, creds);
+          if (!mig) {
+            anyFailed = true;
+            rec.steps.push({ project: step.project, kind: "d1-migrations", ok: false, detail: "migration failed" });
+            await this.record(rec);
+            continue;
+          }
+        } else if (step.migrations) {
+          this.log(`  migrations present but apply:false — skipping (set apply: true to run)`);
+        }
+
+        const result =
+          step.kind === "pages"
+            ? await this.deployPagesStep(step, shallow, commitOid, creds, branch)
+            : await this.deployWorkerStep(step, shallow, commitOid, creds);
+
+        const sr: DeployStepResult = {
+          project: step.project,
+          kind: step.kind,
+          ok: result.ok,
+          ...(result.detail ? { detail: result.detail } : {}),
+          ...(result.url ? { url: result.url } : {}),
+        };
+        rec.steps.push(sr);
+        this.log(`  ${result.ok ? "✓ ok" : "✗ failed"}${result.detail ? ` — ${result.detail}` : ""}`);
+        if (!result.ok) anyFailed = true;
+      } catch (e) {
         anyFailed = true;
-        continue;
+        rec.steps.push({ project: step.project, kind: step.kind, ok: false, detail: (e as Error).message });
+        this.log(`  ✗ ${(e as Error).message}`);
       }
-      const result = await uploadWorkerScript({
-        accountId,
-        apiToken: token,
-        upload: {
-          scriptName: step.project,
-          moduleFileName: "worker.js",
-          code: entryBlob.text,
-        },
-      });
-      rec.steps.push({
-        project: step.project,
-        kind: step.kind,
-        ok: result.ok,
-        ...(result.detail ? { detail: result.detail } : {}),
-      });
-      if (!result.ok) anyFailed = true;
-      await this.record(rec); // checkpoint after each step
+      await this.record(rec);
+    }
+    return anyFailed;
+  }
+
+  private async deployWorkerStep(
+    step: DeployStep,
+    shallow: ShallowRepo,
+    commitOid: string,
+    creds: { token: string; accountId: string },
+  ): Promise {
+    const entry = await readBlobAtCommit(shallow, commitOid, step.entry).catch(() => null);
+    if (!entry || entry.isBinary || !entry.text) {
+      return { ok: false, status: 0, detail: `entry not found or not text: ${step.entry}` };
+    }
+    this.log(`  uploading ${step.entry} (${entry.size} bytes, ${countBindings(step)} bindings)`);
+    return uploadWorkerScript({
+      accountId: creds.accountId,
+      apiToken: creds.token,
+      upload: {
+        scriptName: step.project,
+        moduleFileName: "worker.js",
+        code: entry.text,
+        bindings: step.bindings,
+        ...(step.compatibility_date ? { compatibilityDate: step.compatibility_date } : {}),
+      },
+    });
+  }
+
+  private async deployPagesStep(
+    step: DeployStep,
+    shallow: ShallowRepo,
+    commitOid: string,
+    creds: { token: string; accountId: string },
+    branch: string,
+  ): Promise {
+    const files = await listFilesUnder(shallow, commitOid, step.entry).catch(() => null);
+    if (!files || files.length === 0) {
+      return { ok: false, status: 0, detail: `no files under ${step.entry}` };
+    }
+    const isProd = step.production_branch ? branch === step.production_branch : true;
+    this.log(`  uploading ${files.length} files (${isProd ? "production" : `preview: ${branch}`})`);
+    const pagesFiles: PagesFile[] = files.map((f) => ({
+      path: f.path,
+      bytes: f.bytes,
+      contentType: contentTypeFor(f.path),
+    }));
+    return deployPages({
+      accountId: creds.accountId,
+      apiToken: creds.token,
+      project: step.project,
+      files: pagesFiles,
+      ...(isProd ? {} : { branch }),
+    });
+  }
+
+  private async runMigrations(
+    step: DeployStep,
+    shallow: ShallowRepo,
+    commitOid: string,
+    creds: { token: string; accountId: string },
+  ): Promise {
+    const cfg = step.migrations!;
+    const files = await listFilesUnder(shallow, commitOid, cfg.dir).catch(() => null);
+    if (!files) {
+      this.log(`  no migrations dir: ${cfg.dir}`);
+      return true;
     }
+    const sqlFiles = files
+      .filter((f) => f.path.endsWith(".sql"))
+      .sort((a, b) => a.path.localeCompare(b.path));
+    const appliedKey = `migrations:${cfg.database_id}`;
+    const applied = new Set((await this.state.storage.get(appliedKey)) ?? []);
 
-    return finish(anyFailed ? "failed" : "success");
+    for (const f of sqlFiles) {
+      if (applied.has(f.path)) continue;
+      this.log(`  applying migration ${cfg.dir}/${f.path}`);
+      const sql = new TextDecoder().decode(f.bytes);
+      const res = await d1Query({
+        accountId: creds.accountId,
+        apiToken: creds.token,
+        databaseId: cfg.database_id,
+        sql,
+      });
+      if (!res.ok) {
+        this.log(`  ✗ migration ${f.path} failed: ${res.detail ?? res.status}`);
+        return false;
+      }
+      applied.add(f.path);
+      await this.state.storage.put(appliedKey, [...applied]);
+      this.log(`  ✓ migration ${f.path} applied`);
+    }
+    return true;
   }
 
   private async history(): Promise {
-    const map = await this.state.storage.list({ prefix: "deploy:", reverse: true, limit: 50 });
+    const map = await this.state.storage.list({
+      prefix: "deploy:",
+      reverse: true,
+      limit: 50,
+    });
     return [...map.values()];
   }
 }
 
+function countBindings(step: DeployStep): number {
+  const b = step.bindings;
+  return (
+    Object.keys(b.vars).length +
+    b.kv.length +
+    b.r2.length +
+    b.d1.length +
+    b.durable_objects.length +
+    b.services.length
+  );
+}
+
+function contentTypeFor(path: string): string {
+  const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
+  return CONTENT_TYPES[ext] ?? "application/octet-stream";
+}
+
 export function deployStubFor(env: Env, artifactsRepoName: string): DurableObjectStub {
   const id = env.DEPLOY.idFromName(artifactsRepoName);
   return env.DEPLOY.get(id);
diff --git a/packages/worker/src/env.ts b/packages/worker/src/env.ts
index 6dbafb4..b019cc4 100644
--- a/packages/worker/src/env.ts
+++ b/packages/worker/src/env.ts
@@ -14,6 +14,9 @@ export interface Env {
   // CD deploy token (optional — set by `gitflare deploy enable`). Scoped to
   // Workers Scripts: Edit on the user's own account. Absent = CD disabled.
   CF_DEPLOY_TOKEN?: string;
+  // Bearer secret the CLI presents to /control/* endpoints (manual deploy,
+  // rollback, deploy list). Set by `gitflare deploy enable`.
+  CONTROL_SECRET?: string;
 
   // Vars
   GITFLARE_VERSION: string;
diff --git a/packages/worker/src/index.tsx b/packages/worker/src/index.tsx
index c9ab7c5..f3b4051 100644
--- a/packages/worker/src/index.tsx
+++ b/packages/worker/src/index.tsx
@@ -250,6 +250,78 @@ app.get("/r/:name/deployments", async (c) => {
   );
 });
 
+// Live deploy-log WebSocket — the Deployments page connects here. Under /r/* so
+// the Access guard covers it; the upgrade is forwarded to the DeployDO.
+app.get("/r/:name/deployments/stream", async (c) => {
+  const name = c.req.param("name");
+  const repo = findRepoByArtifactsName(c.env, name);
+  if (!repo) return c.text("unknown repo", 404);
+  if (c.req.header("Upgrade") !== "websocket") return c.text("expected websocket", 426);
+  const stub = deployStubFor(c.env, name);
+  return stub.fetch(new Request("https://deploy-do/stream", c.req.raw));
+});
+
+// ---- Control plane (CLI → Worker), authed by CONTROL_SECRET, not Access ----
+// Mirrors how /webhooks/github sits outside Access with its own auth.
+
+function controlAuthorized(c: { req: { header: (n: string) => string | undefined }; env: Env }): boolean {
+  const secret = c.env.CONTROL_SECRET;
+  if (!secret) return false;
+  const auth = c.req.header("Authorization") ?? "";
+  const provided = auth.startsWith("Bearer ") ? auth.slice(7) : "";
+  if (provided.length !== secret.length) return false;
+  let diff = 0;
+  for (let i = 0; i < secret.length; i++) diff |= provided.charCodeAt(i) ^ secret.charCodeAt(i);
+  return diff === 0;
+}
+
+app.post("/control/deploy/run", async (c) => {
+  if (!controlAuthorized(c)) return c.json({ error: "unauthorized" }, 401);
+  const { repo } = (await c.req.json().catch(() => ({}))) as { repo?: string };
+  const entry = repo ? findRepoByArtifactsName(c.env, repo) : undefined;
+  if (!entry) return c.json({ error: "unknown repo" }, 404);
+  const stub = deployStubFor(c.env, entry.name);
+  c.executionCtx.waitUntil(
+    stub.fetch("https://deploy-do/deploy", {
+      method: "POST",
+      body: JSON.stringify({ artifactsRepoName: entry.name, remote: entry.remote, ref: "", sha: "", mode: "manual" }),
+    }),
+  );
+  return c.json({ accepted: true }, 202);
+});
+
+app.post("/control/deploy/rollback", async (c) => {
+  if (!controlAuthorized(c)) return c.json({ error: "unauthorized" }, 401);
+  const { repo, toDeployId } = (await c.req.json().catch(() => ({}))) as {
+    repo?: string;
+    toDeployId?: number;
+  };
+  const entry = repo ? findRepoByArtifactsName(c.env, repo) : undefined;
+  if (!entry) return c.json({ error: "unknown repo" }, 404);
+  const stub = deployStubFor(c.env, entry.name);
+  c.executionCtx.waitUntil(
+    stub.fetch("https://deploy-do/rollback", {
+      method: "POST",
+      body: JSON.stringify({
+        artifactsRepoName: entry.name,
+        remote: entry.remote,
+        ...(toDeployId ? { toDeployId } : {}),
+      }),
+    }),
+  );
+  return c.json({ accepted: true }, 202);
+});
+
+app.get("/control/deployments", async (c) => {
+  if (!controlAuthorized(c)) return c.json({ error: "unauthorized" }, 401);
+  const repo = c.req.query("repo");
+  const entry = repo ? findRepoByArtifactsName(c.env, repo) : undefined;
+  if (!entry) return c.json({ error: "unknown repo" }, 404);
+  const stub = deployStubFor(c.env, entry.name);
+  const resp = await stub.fetch("https://deploy-do/state");
+  return c.json(resp.ok ? ((await resp.json()) as object) : { deploys: [] });
+});
+
 app.post("/webhooks/github", async (c) => {
   const signature = c.req.header("x-hub-signature-256");
   const event = c.req.header("x-github-event");
diff --git a/packages/worker/src/ui/deployments.tsx b/packages/worker/src/ui/deployments.tsx
index feeb2e8..3a98534 100644
--- a/packages/worker/src/ui/deployments.tsx
+++ b/packages/worker/src/ui/deployments.tsx
@@ -26,6 +26,37 @@ function rel(ts: number): string {
   return `${Math.round(s / 86400)}d ago`;
 }
 
+// Client-side: stream live deploy logs over a WebSocket and reload the page
+// when a run finishes so the history table refreshes.
+function streamScript(name: string): string {
+  return `
+(function () {
+  var box = document.getElementById('live-logs');
+  if (!box || !('WebSocket' in window)) return;
+  var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
+  var ws = new WebSocket(proto + '//' + location.host + '/r/${name}/deployments/stream');
+  function append(line) {
+    box.textContent += line + '\\n';
+    box.scrollTop = box.scrollHeight;
+    document.getElementById('live-wrap').style.display = 'block';
+  }
+  ws.onmessage = function (ev) {
+    try {
+      var m = JSON.parse(ev.data);
+      if (m.type === 'snapshot' || m.type === 'start') {
+        if (m.record && m.record.logs) { box.textContent = m.record.logs.join('\\n') + '\\n'; document.getElementById('live-wrap').style.display = 'block'; }
+      } else if (m.type === 'log') {
+        append(m.line);
+      } else if (m.type === 'done') {
+        append('— run finished: ' + (m.record ? m.record.status : '') + ' —');
+        setTimeout(function () { location.reload(); }, 1500);
+      }
+    } catch (e) {}
+  };
+})();
+`;
+}
+
 export const Deployments: FC = (p) => (
   
     
@@ -49,50 +80,89 @@ export const Deployments: FC = (p) => (
Continuous deploy isn't enabled for this repo.
gitflare deploy enable
- Then commit a .gitflare/deploy.yml. On push, GitFlare deploys your built - Worker to your own account — even when GitHub Actions is down. + Then commit a .gitflare/deploy.yml. On push, GitFlare deploys to your own + account — even when GitHub Actions is down.
- ) : p.deploys.length === 0 ? ( -
- No deploys yet. Push to a branch matched by .gitflare/deploy.yml to trigger one. -
) : ( -
- - - - - - - - - - - - - {p.deploys.map((d) => ( - - - - - - - - - ))} - -
#RefCommitStepsStatusWhen
{d.id}{d.ref.replace(/^refs\/heads\//, "")}{d.sha.slice(0, 8)} - {d.steps.length === 0 - ? "—" - : d.steps.map((s) => `${s.project}${s.ok ? "✓" : "✗"}`).join(" ")} - - {d.status} - {d.message ? ( - {d.message} - ) : null} - {rel(d.startedAt)}
-
+ <> + + + {p.deploys.length === 0 ? ( +
+ No deploys yet. Push to a branch matched by .gitflare/deploy.yml, or run{" "} + gitflare deploy run. +
+ ) : ( +
+ + + + + + + + + + + + + + {p.deploys.map((d) => ( + + + + + + + + + + ))} + +
#BranchCommitModeStepsStatusWhen
{d.id}{d.branch}{d.sha.slice(0, 8)}{d.mode} + {d.steps.length === 0 + ? "—" + : d.steps.map((s) => ( + + {s.url ? ( + {s.project} + ) : ( + s.project + )} + {s.ok ? " ✓ " : " ✗ "} + + ))} + + {d.status} + {d.message ? ( + {d.message} + ) : null} + {rel(d.startedAt)}
+
+ )} + + {p.deploys[0] && p.deploys[0].logs.length > 0 ? ( + <> +

Latest log (#{p.deploys[0].id})

+
+                {p.deploys[0].logs.join("\n")}
+              
+ + ) : null} + +