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
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
pull_request:
push:
branches: [main]

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm -r typecheck

- name: Test
run: pnpm -r test

- name: Build CLI (bundles the Worker)
run: pnpm --filter gitflare build
52 changes: 52 additions & 0 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Release

on:
push:
branches: [main]

permissions:
contents: write
pull-requests: write

jobs:
# On every push to main, Release Please opens/updates a release PR that bumps
# the version + changelog from Conventional Commits. When that release PR is
# merged, this same run reports release_created=true and the publish job runs.
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}

publish:
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
registry-url: https://registry.npmjs.org

- run: pnpm install --frozen-lockfile

# Build the published artifact (tsc + bundles the Worker into dist/).
- run: pnpm --filter gitflare build

- name: Publish to npm
working-directory: packages/cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.1.3"
}
4 changes: 3 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -550,7 +550,9 @@ The roadmap in §4 is what we're shipping. This section is *where we are right n
| M4 | v0.1 cut: end-to-end working read replica | ✅ done | All three components compose cleanly: CLI provisions → Worker deploys → webhook fires → sync runs → UI shows synced state. [QUICKSTART.md](./QUICKSTART.md) walks the full flow. Live-validated against `sinameraji/kimiflare`. |
| M4.5 | Browseable dashboard | ✅ done | Top-level entries on the home page link to per-path tree/blob routes. Inside a directory: breadcrumb + parent link + clickable entries (links recurse). Inside a file: plain `<pre>` rendering with binary detection. README images rewritten to GitHub raw URLs for public repos. |
| 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 | ⏳ next | The dashboard URL is currently public. Put Access in front of the Worker (free up to 50 seats on Cloudflare One), gate `/` + `/r/*` + `/api/*` with SSO, leave `/webhooks/github` unauth (HMAC already gates it). Then private GitHub repos actually stay private. |
| 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. |

### What's in the repo right now (as of M0)

Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/cloudflare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,64 @@ export class CloudflareClient {
);
return r.subdomain;
}

// --- Cloudflare Access (Zero Trust) ---
// NOTE: exact request/response shapes vary by API version — verify live
// before relying on these. `aud` is the tag the Worker validates.

/**
* Returns the account's Zero Trust org auth domain (e.g.
* "myteam.cloudflareaccess.com"). Throws if the account has no org yet —
* the user must enable Zero Trust once in the dashboard.
*/
async getZeroTrustOrg(
accountId: string,
): Promise<{ authDomain: string; name: string }> {
const r = await this.req<{ auth_domain: string; name: string }>(
"GET",
`/accounts/${accountId}/access/organizations`,
);
if (!r?.auth_domain) {
throw new Error("no Zero Trust organization on this account");
}
return { authDomain: r.auth_domain, name: r.name };
}

async listAccessApps(
accountId: string,
): Promise<Array<{ id: string; aud: string; name: string; domain: string }>> {
return this.req("GET", `/accounts/${accountId}/access/apps`);
}

async createAccessApp(
accountId: string,
params: { name: string; domain: string },
): Promise<{ id: string; aud: string }> {
return this.req("POST", `/accounts/${accountId}/access/apps`, {
type: "self_hosted",
name: params.name,
domain: params.domain,
session_duration: "24h",
});
}

async createAccessPolicy(
accountId: string,
appId: string,
params: { name: string; emails: string[] },
): Promise<{ id: string }> {
return this.req(
"POST",
`/accounts/${accountId}/access/apps/${appId}/policies`,
{
name: params.name,
decision: "allow",
include: params.emails.map((email) => ({ email: { email } })),
},
);
}

async deleteAccessApp(accountId: string, appId: string): Promise<void> {
await this.req("DELETE", `/accounts/${accountId}/access/apps/${appId}`);
}
}
207 changes: 207 additions & 0 deletions packages/cli/src/commands/access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
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 { redeployWorker } from "../redeploy.js";
import { pickRepo, getCfToken } from "../repo-select.js";

// "Cloudflare Access: Apps and Policies: Edit" is needed in addition to the
// 3 scopes `init` requests. The token-creation page:
const ACCESS_TOKEN_URL = "https://dash.cloudflare.com/profile/api-tokens";
const ZERO_TRUST_URL = "https://one.dash.cloudflare.com/";

function hostOf(workerUrl: string): string {
return workerUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
}

function scopeHint(): void {
p.log.warn(
[
"This looks like a missing scope or no Zero Trust org.",
` • Enable Zero Trust once (free up to 50 seats): ${kleur.gray(ZERO_TRUST_URL)}`,
` • Re-issue your Cloudflare token at ${kleur.gray(ACCESS_TOKEN_URL)} adding:`,
` ${kleur.cyan("Cloudflare Access: Apps and Policies")} — Edit`,
" Then re-run `gitflare access enable` and choose a fresh token.",
].join("\n"),
);
}

export async function runAccessEnable(
repoArg: string | undefined,
): Promise<void> {
p.intro(kleur.bold(orange("GitFlare access enable")));
const cfg = await loadConfig();
const entry = await pickRepo(cfg, repoArg);
if (!entry) return;

const cfToken = await getCfToken(cfg);
if (!cfToken) return p.cancel("Cancelled."), undefined;
const cf = new CloudflareClient(cfToken);

// 1. Resolve the Zero Trust team auth domain.
const sp = p.spinner();
sp.start("Resolving Zero Trust organization");
let teamDomain: string;
try {
const org = await cf.getZeroTrustOrg(entry.cloudflareAccountId);
teamDomain = org.authDomain;
sp.stop(`Zero Trust org: ${kleur.cyan(teamDomain)}`);
} catch (e) {
sp.stop("Could not resolve Zero Trust org");
p.log.error((e as Error).message);
scopeHint();
return;
}

// 2. Who's allowed in.
const emailsRaw = await p.text({
message: "Allowed email(s), comma-separated",
placeholder: "you@example.com",
validate: (s) => (!s ? "at least one email required" : undefined),
});
if (p.isCancel(emailsRaw)) return p.cancel("Cancelled."), undefined;
const emails = (emailsRaw as string)
.split(",")
.map((s) => s.trim())
.filter(Boolean);

// 3. Reconstruct the Artifacts remote (needed to redeploy).
sp.start("Fetching Artifacts remote");
let remote: string;
try {
const r = await cf.getRepo(
entry.cloudflareAccountId,
entry.artifactsNamespace,
entry.artifactsRepoName,
);
remote = r.remote;
sp.stop("Got Artifacts remote");
} catch (e) {
sp.stop("Artifacts remote lookup failed");
p.log.error((e as Error).message);
return;
}

// 4. Create (or reuse) the Access app + policy.
const host = hostOf(entry.workerUrl);
sp.start("Creating Cloudflare Access application");
let appId: string;
let aud: string;
try {
const existing = (await cf.listAccessApps(entry.cloudflareAccountId)).find(
(a) => a.domain === host || a.domain === host + "/",
);
if (existing) {
appId = existing.id;
aud = existing.aud;
sp.message("Reusing existing Access app");
} else {
const app = await cf.createAccessApp(entry.cloudflareAccountId, {
name: `GitFlare — ${entry.githubFullName}`,
domain: host,
});
appId = app.id;
aud = app.aud;
}
await cf.createAccessPolicy(entry.cloudflareAccountId, appId, {
name: "GitFlare allow-list",
emails,
});
sp.stop(`Access app ready (aud ${kleur.gray(aud.slice(0, 8) + "…")})`);
} catch (e) {
sp.stop("Access app setup failed");
p.log.error((e as Error).message);
scopeHint();
return;
}

// 5. Record the Access config, then redeploy so the vars take effect.
entry.access = { appId, aud, teamDomain, allowedEmails: emails };
sp.start("Redeploying Worker with Access enabled");
try {
await redeployWorker(entry, cfToken, remote);
sp.stop("Worker redeployed");
} catch (e) {
sp.stop("Redeploy failed");
p.log.error((e as Error).message);
return;
}

// 6. Persist.
cfg.cloudflare = { token: cfToken };
await saveConfig(cfg);

p.outro(
[
kleur.bold(orange("Access enabled.")),
"",
` ${entry.workerUrl} now requires SSO login.`,
` Allowed: ${kleur.cyan(emails.join(", "))}`,
"",
kleur.yellow(" Note: this gates the web dashboard + API only."),
kleur.gray(" `git clone` of the Artifacts remote is NOT gated by Access —"),
kleur.gray(" it uses Artifacts' own repo tokens. Private-clone lands in a later version."),
].join("\n"),
);
}

export async function runAccessDisable(
repoArg: string | undefined,
): Promise<void> {
p.intro(kleur.bold(orange("GitFlare access disable")));
const cfg = await loadConfig();
const entry = await pickRepo(cfg, repoArg);
if (!entry) return;

if (!entry.access) {
p.log.warn(`Access is not enabled for ${kleur.cyan(entry.githubFullName)}.`);
p.outro("");
return;
}

const cfToken = await getCfToken(cfg);
if (!cfToken) return p.cancel("Cancelled."), undefined;
const cf = new CloudflareClient(cfToken);

const sp = p.spinner();
sp.start("Fetching Artifacts remote");
let remote: string;
try {
const r = await cf.getRepo(
entry.cloudflareAccountId,
entry.artifactsNamespace,
entry.artifactsRepoName,
);
remote = r.remote;
sp.stop("Got Artifacts remote");
} catch (e) {
sp.stop("Artifacts remote lookup failed");
p.log.error((e as Error).message);
return;
}

sp.start("Deleting Access application");
try {
await cf.deleteAccessApp(entry.cloudflareAccountId, entry.access.appId);
sp.stop("Access app deleted");
} catch (e) {
// Non-fatal: the app may already be gone. Continue to redeploy open.
sp.stop("Access app delete failed (continuing)");
p.log.warn((e as Error).message);
}

delete entry.access;
sp.start("Redeploying Worker as public");
try {
await redeployWorker(entry, cfToken, remote);
sp.stop("Worker redeployed");
} catch (e) {
sp.stop("Redeploy failed");
p.log.error((e as Error).message);
return;
}

await saveConfig(cfg);
p.outro(kleur.bold(orange("Access disabled — repo is public again.")));
}
Loading
Loading