Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <repo>`. |
| 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 `<pre>` 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 <id>]` (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)

Expand Down
155 changes: 150 additions & 5 deletions packages/cli/src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -83,13 +83,15 @@ export async function runDeployEnable(repoArg: string | undefined): Promise<void
if (!remote) return;

// Mark CD on so redeploy emits CD_ENABLED + the DeployDO binding/migration,
// then set the deploy-token secret.
entry.deploy = { enabledAt: new Date().toISOString() };
// then set the deploy-token + control-plane secrets.
const controlSecret = randomHex(32);
entry.deploy = { enabledAt: new Date().toISOString(), controlSecret };
sp.start("Redeploying Worker with CD enabled");
try {
const res = await redeployWorker(entry, cfToken, remote);
sp.message("Setting deploy-token secret");
sp.message("Setting deploy secrets");
await wranglerSecret(res.workDir, cfToken, "CF_DEPLOY_TOKEN", deployToken);
await wranglerSecret(res.workDir, cfToken, "CONTROL_SECRET", controlSecret);
sp.stop("Worker redeployed with CD enabled");
} catch (e) {
sp.stop("Redeploy failed");
Expand Down Expand Up @@ -157,3 +159,146 @@ export async function runDeployDisable(repoArg: string | undefined): Promise<voi
);
p.outro(kleur.bold(orange("CD disabled.")));
}

// --- control-plane commands (talk to the Worker's /control/* endpoints) ---

function cdSecret(entry: RepoEntry): string | undefined {
if (!entry.deploy) {
p.log.warn(`CD isn't enabled for ${kleur.cyan(entry.githubFullName)}. Run \`gitflare deploy enable\`.`);
return undefined;
}
return entry.deploy.controlSecret;
}

async function controlFetch(
entry: RepoEntry,
secret: string,
path: string,
init?: { method?: string; body?: unknown },
): Promise<Response> {
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<void> {
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<void> {
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<void> {
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`)}`);
}
2 changes: 2 additions & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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 <id>", "deploy id to roll back to")
.action(runDeployRollback);

program.parseAsync(process.argv);
126 changes: 126 additions & 0 deletions packages/worker/src/artifacts/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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<FileAtPath[] | null> {
const rootTreeOid = await treeOidAtPath(shallow, commitOid, dirPath);
if (rootTreeOid === null) return null;
const out: FileAtPath[] = [];
const walk = async (treeOid: string, prefix: string): Promise<void> => {
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<BlobAtPath | null> {
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<ShallowRepo> {
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.
*/
Expand Down
Loading
Loading