mcp server
hedge-cms
HEDGE (Headless + Edge) CMS for Cloudflare Workers — D1, R2, and Email Sending. Bun, Hono, React + shadcn/ui.
Set up
Runs on your computer. Pick your agent and operating system.
This project does not publish an install method we can turn into a config. Follow the README instead.
README
synced Oct 26, 2026Hedge
HEDGE (Headless + Edge) CMS. Content management that runs entirely on Cloudflare's edge — no origin server, no container, no cold starts. One Worker serves the admin UI, the management API, and a cached read-only delivery API.
Status: early scaffold. The foundation below works end to end; features are being deepened.
Stack
| Concern | Choice |
|---|---|
| Runtime | Cloudflare Workers |
| Toolchain | Bun (package manager, test runner, scripts) |
| API | Hono + Zod |
| Database | Cloudflare D1 (SQLite) via Drizzle ORM |
| File storage | Cloudflare R2 |
Cloudflare Email Sending (send_email binding) | |
| Admin UI | React 19 + Vite + Tailwind v4 + shadcn/ui |
| Lint/format | Biome |
What's here
- Sites — one deployment holds many websites: a blog, a docs site, a landing page. Each is its own namespace for collections, entries, media, API keys and members.
- Users and members — two separate account types. A User operates the CMS; a Member is a visitor of one site who signs in on that site to unlock gated content, and can never reach the admin.
- Per-site roles — owners and admins run the instance and reach every site; editors and viewers only reach the sites they are granted, at the role they are granted.
- Auth — Better Auth on both sides: signed session cookies for operators, bearer tokens for website members, and OAuth 2.1 for MCP clients. A first-run onboarding wizard, emailed invites, password reset, email verification, revocable sessions, and rate limiting backed by D1. Nobody ever sets somebody else's password: users and members alike are added by email and choose their own.
- Collections — user-defined content types. Twelve field kinds (text, richtext, number, boolean, date, select, media, reference, url, email, color, json), edited in the admin and enforced on write.
- Entries — draft/published/archived, public or members-only, per-locale, keyset-paginated, with an automatic revision snapshot on every update.
- Media — uploads to R2 with type and size limits, served from
/media/*with immutable cache headers so repeat hits never touch the bucket. - Delivery API —
/api/v1/content/*, published content only,s-maxagecached at the edge, authenticated with scoped per-site API keys. - Analytics — a dashboard on
/and a detail page on/analytics: which articles were read, where readers came from, share clicks, and newsletter delivery. Fed by a first-party snippet your website embeds, because the Worker sits behind the edge cache and never sees your readers. No cookies, no visitor identity, Do Not Track honoured — see docs/website-analytics.md. Entirely optional. - MCP — an AI assistant can run the whole CMS over OAuth 2.1: collections, entries, media, newsletters, sites, users and keys, bounded by scopes you approve and your own role.
Repository layout
apps/
api/ Cloudflare Worker — API, media serving, admin asset hosting
src/routes/ auth · users · sites · members · api-keys · collections · entries · media · content · mcp
src/lib/ The services behind the routes, shared with the MCP tools
src/mcp/ MCP tool definitions, one module per area of the CMS
src/auth/ The two Better Auth instances — operators (with OAuth for MCP) and members
src/db/ Drizzle schema and client
migrations/ D1 migrations (generated by drizzle-kit)
admin/ React admin UI, built to dist/ and served by the Worker
installer/ Wizard that provisions and deploys Hedge without a Git repository
packages/
core/ Zod schemas and types shared by both sides
deploy/ Cloudflare deploy client — used by the in-Worker updater and the installer
wrangler.jsonc The Worker's only config — bindings, assets, vars, migrations
apps/installer is never a dependency of the Worker: the shared half lives in packages/deploy,
which both import, so nothing about the installer is in the bundle that serves every request.
Deploy your own
Three ways in. They differ in what they need from you and in how the deployment updates afterwards — that last column is the one that catches people out, so it is here rather than three sections down.
| For someone who | Needs | Gives up | Updates by | |
|---|---|---|---|---|
| Deploy button | wants CI, preview URLs and a repository to work in | a GitHub or GitLab account | nothing | the admin, or a Git merge that Workers Builds redeploys |
| Installer | wants a CMS, not a repository | a Cloudflare account and a downloaded runner | CI, preview URLs, redeploy-on-push | the admin |
| CLI | already has a terminal and opinions | Bun and a checkout | one-click anything | bun run deploy, or the admin |
All three run the same Hedge on the same Cloudflare products, and all three can update from Settings → About & updates by pasting a Cloudflare API token that is used once and never stored. The deployment records which one made it, so the admin only ever shows you instructions that exist for your deployment.
R2 must be enabled on the account whichever you pick, since media lives there.
The deploy button
The button clones this repository into your GitHub or GitLab account — a clone, not a fork, so it
has no upstream relationship to this one — then creates the D1 database and R2 bucket it needs,
builds the Worker and deploys it. The only thing you must type is AUTH_SECRET, a long random
string that openssl rand -base64 32 will produce. Every later push to your copy redeploys.
The setup page also lists a few variables. Every one of them is fine as it stands — prefilled
(INSTALLED_BY arrives as button; leave it) or empty:
| Field | What to enter |
|---|---|
PUBLIC_URL | Leave empty until you add a custom domain. Then it must be the full origin, scheme included, no trailing slash — https://cms.example.com — as covered under next steps. |
EMAIL_FROM | Leave empty. Set it later under Settings → Email, once a domain of yours is onboarded for sending. |
INSTALLED_BY | Leave as button — so Settings → About & updates shows the update instructions that exist for this deployment. |
WORKER_NAME | Leave empty. Only the installer sets it; a button deployment is always hedge-cms. |
There is nothing to tell it about your repository: every Workers Builds deploy records the
repository it deployed from as REPO_URL on its own, which is what lets the admin's update notice
deep-link your copy.
You land on https://hedge-cms.<your-subdomain>.workers.dev, in the onboarding wizard, where you
create the owner account and the first site. Nothing else is required to start writing content.
The installer
For someone who wants a CMS and has no Git account. It creates the resources and deploys Hedge straight onto your Cloudflare account, with no repository anywhere.
bun run installer # from a checkout — see apps/installer/README.md
It opens a wizard, asks for a Cloudflare API token, and does the rest: creates the database and
bucket, applies the migrations, uploads the admin and the Worker, and hands back the URL. It
generates AUTH_SECRET itself, so there is no openssl to run.
Your Cloudflare token never leaves your machine. That is not a courtesy — Cloudflare's API refuses cross-origin browser requests, so an installer that ran as a hosted web page would have to route every user's token through someone else's server, and this one does not. The reasoning is written down in docs/spikes/37-browser-cloudflare-api.
What you give up: no repository means no Workers Builds, so no CI, no preview URLs and no redeploy on push. Updates come from the admin. Email also needs a domain onboarded from a terminal before anything sends, exactly as it does for the other two paths.
Next steps, whichever path you took
Two things are worth doing when you need them:
-
Email. Invites, password resets and newsletters send through Cloudflare Email Sending, which needs a domain of yours onboarded first:
bunx wrangler email sending enable yourdomain.com. Until then those sends fail — the address they send from is set under Settings → Email, and a site can send its own newsletters and member email as itself by setting a sender under Site settings. Operator invites and password resets always use the deployment's. -
A custom domain. Add the route in the Cloudflare dashboard, then set the
PUBLIC_URLvariable to that URL. Left empty, the deployment answers with whatever origin the request arrived on, which is right for the generated workers.dev URL and for local development but not for a Worker reached by two hostnames.PUBLIC_URLmust be a full origin, scheme included —https://cms.example.com, notcms.example.com, and no trailing slash. Authentication is built from this value, so a bare hostname is not a near miss: it is rejected outright and every sign-in and admin request answers 500, while/api/healthkeeps returningokbecause it never touches auth.
Keeping a deployment up to date
Settings → About & updates shows the version the deployment runs and, when this project has cut
a newer release, how to take it. The page offers the path that exists for your deployment — it
knows which one made it, from the INSTALLED_BY variable — so an installer deployment is never told
to sync a repository it does not have.
-
From the dashboard. Available to every deployment, however it was installed. An owner clicks Update now, pastes a Cloudflare API token that carries
Workers Scripts:EditandD1:Edit, and Hedge uploads the new release, applies pending migrations, and deploys — in that order, so new code never serves against an un-migrated schema. The token is used once and discarded: it is never written to the database, never logged, and never returned. The Worker holds no standing deploy credential, so reaching CMS admin never becomes reaching the Cloudflare account. -
From Git, for a button or CLI deployment. The button creates a clone, not a fork, so there is no "Sync fork" button — add this repository as an upstream and merge it:
git remote add upstream https://github.com/bihaviour/hedge-cms git fetch upstream && git merge upstream/main && git pushWorkers Builds redeploys on the push and applies any new database migrations on the way, and records your repository's URL as
REPO_URLso the About page links straight to it.
Both paths use the same migration bookkeeping, so switching between them never re-runs or skips one.
Every update is something a person starts by presenting a credential. Nothing self-updates on a schedule, and nothing runs without someone deciding it should.
Getting started locally
Requires Bun 1.3+. No Cloudflare account: wrangler dev emulates D1, R2 and the
assets binding on your machine.
bun install
# Local secrets
echo 'AUTH_SECRET="'$(openssl rand -base64 32)'"' > .dev.vars
bun run db:migrate # apply migrations to the local D1
bun run db:seed # optional sample collection and entries
Run the two dev servers in separate terminals:
bun run dev:api # Worker on http://localhost:8787
bun run dev:admin # Admin UI on http://localhost:5173 (proxies /api to the Worker)
Open http://localhost:5173. A deployment with no users starts in the onboarding wizard: it creates the owner account, then the first site. Nothing is created behind your back — a fresh database has no site until you name one, and an interrupted setup picks up where it left off when you sign in.
In development, emails are not sent — invite and reset links are printed to the dev:api
console so you can follow them locally.
Deploying from your machine
The button above is the short path; this is the same deployment done by hand.
bunx wrangler secret put AUTH_SECRET
bunx wrangler email sending enable yourdomain.com # required before any email is sent
bun run deploy # build, migrate the remote D1, deploy
The D1 database and R2 bucket are created on the first deploy — wrangler.jsonc names them but
carries no ids, which is what lets anyone deploy this repository into their own account unedited.
PUBLIC_URL and EMAIL_FROM are the two variables worth setting there once the deployment has a
domain; PUBLIC_URL is what invite, reset and media links point at.
Using the delivery API
Create a key in Settings → API keys with the content:read scope, then:
curl https://your-worker.workers.dev/api/v1/content/posts \
-H "Authorization: Bearer hdg_..."
| Endpoint | Returns |
|---|---|
GET /api/v1/content/:collection | Published entries, paginated via nextCursor |
GET /api/v1/content/:collection/:slug | A single published entry |
GET /api/v1/content/:collection/_schema | The collection's field definitions |
Query parameters: locale, limit, cursor, sort, order.
A key belongs to the site it was created on and can only read that site's content, so nothing extra is needed to pick a tenant.
Previewing unpublished content
The delivery API serves published entries only — which leaves no way to see a draft the way a reader
would, short of publishing it and looking. Preview in the entry editor closes that: the CMS mints
a short-lived token scoped to that one entry, opens your website's own preview route with it, and
your website's server forwards it to the delivery API in X-Hedge-Preview to render the draft in
your real layout.
Point a site at its preview route under Settings → Configuration → Overview; the website side is a page you write. docs/preview.md walks through it, with worked snippets, the header the embedded pane needs, and the one mistake worth naming — handing the delivery key to the browser.
Images
A media field stores an R2 object key, not a URL — a stored URL bakes this deployment's origin
into your content and is wrong the day the CMS moves domain. So data.cover is
blog/2026/07/k1a2-photo.jpg, which a browser would resolve against your website's origin.
Entries in a collection that declares a media field therefore carry a resolved media sibling
alongside data, built per request from the CMS's own origin:
{
"slug": "hello-world",
"data": { "title": "Hello", "cover": "blog/2026/07/k1a2-photo.jpg" },
"media": {
"cover": {
"key": "blog/2026/07/k1a2-photo.jpg",
"url": "https://your-worker.workers.dev/media/blog/2026/07/k1a2-photo.jpg",
"alt": "A cat asleep on a keyboard",
"width": 1600,
"height": 900
}
}
}
<img src={entry.media.cover.url} alt={entry.media.cover.alt ?? ''} />
data is unchanged, so nothing that reads the key today breaks. A field declared multiple
resolves to an array in stored order, a single one to an object, and a field left empty is absent
from media entirely. A value that was already an absolute URL is passed through with a null
key. Members-only entries withhold media exactly as they withhold data.
metadata.ogImage is different: it is always an absolute URL, whether you stored a key or a
URL. Open Graph rejects a relative one, and the tag has a single slot, so there is no additive
version of that fix. Metadata is served for locked entries too, so a paywalled page still
previews correctly when it is shared.
Seeing what readers did
The admin opens on a dashboard, and /analytics breaks it down: articles ranked by views with their
trend, where readers arrived from, share clicks, and how each newsletter was delivered.
Hedge cannot measure any of it on its own. Your website is a separate frontend on its own origin, and its delivery API calls are absorbed by Cloudflare's cache — so delivery request counts are not pageviews, and nothing here pretends otherwise. Your pages report a view directly:
<script src="https://your-worker.workers.dev/api/v1/collect/script.js" data-site="blog" defer></script>
That is the whole integration. The script sets no cookie, stores no identifier, counts no unique
visitors, and honours Do Not Track and Global Privacy Control by not sending. Share clicks need one
more line, because no platform publishes share counts any more and only your own click handler can
see one: hedge('share', 'x').
Newsletter delivery and subscriber growth need nothing embedded — they come from rows the CMS already writes.
Full integration notes, including what the numbers deliberately do not claim and a privacy-policy paragraph you can adapt: docs/website-analytics.md.
Writing content from a script
A key with a write scope also reaches the authoring routes, so an import or another service can create content without a person's password:
curl -X POST https://your-worker.workers.dev/api/v1/collections/posts/entries \
-H "Authorization: Bearer hdg_..." -H 'content-type: application/json' \
-d '{"slug":"hello","status":"published","data":{"title":"Hello"}}'
| Scope | Reaches |
|---|---|
content:read | The delivery API — published entries only |
content:write | Creating, editing and deleting entries |
media:read / media:write | Listing media, and uploading or deleting it |
collections:write | Creating, changing and deleting collections |
members:session | Signing a reader in — see Members and gated content |
An authoring key wants content:read and content:write: the first is what lets it read back
what it wrote, including drafts.
A key with no write scope is confined to the delivery API and never sees a draft — that is the credential a public website holds, and the separation is enforced by which middleware runs on which path prefix, not by each route remembering to check. No key of any kind reaches users, sites, email, or the API key routes themselves; those need a signed-in person.
members:session is the one exception worth reading twice, and it is why it is a scope of its own
rather than something the admin role carries. A key that holds it can sign in as any member of
its site, and therefore read anything that site gates behind membership. It grants nothing else —
not the member list, not an address, not a password — but grant it only to a server you would
trust with the content it unlocks, and to nothing that runs in a browser.
Managing the CMS over MCP
Hedge exposes a Model Context Protocol server so an AI assistant can run the CMS directly — not just draft a post, but define the content model it lives in, caption the media it references, write the email template that announces it, and manage who has access. It is a single Streamable-HTTP endpoint on the Worker — no separate process — speaking JSON-RPC 2.0, with 48 tools grouped into read/write scope pairs:
| Area | Tools | Scopes |
|---|---|---|
| Collections | List, read, create, update, delete the content model | collections:read / collections:write |
| Entries | List, read, draft, publish, delete, plus revision history and restore | entries:read / entries:write |
| Media | List and read uploads; edit alt text and filenames; delete | media:read / media:write |
| Newsletters | Email templates, campaign drafts, subscribers, test sends | newsletters:read / newsletters:write |
| Sites | List and read sites; create, configure and delete them | sites:read / sites:write |
| Users | List, invite, change roles, remove; grant and revoke per-site access | users:read / users:write |
| API keys | List, issue and revoke a site's keys | keys:read / keys:write |
Two things the REST API can do are deliberately left out:
- Sending a newsletter to its audience. It reaches real inboxes and cannot be recalled, so it
stays a decision a person makes in the admin.
send_test_newslettermails one address you name, which is what an assistant actually needs to check its work. - Uploading media. It needs a multipart body streamed into R2, and base64 through a model's
context window is not a substitute. Everything about an upload is exposed; the bytes arrive
through the admin or
POST /api/v1/media.
One thing worth knowing before you grant it: create_api_key returns a raw secret, which lands in
the assistant's context and whatever transcript its client keeps. That is a weaker place than the
admin's show-once dialog. Withhold keys:write unless you want that.
Connecting a client
Authentication is OAuth 2.1 — no key to paste. Point the client at the URL and it discovers the rest: it reads the protected-resource metadata, registers itself, and opens a browser for you to sign in and approve it.
claude mcp add hedge --transport http https://your-worker.workers.dev/api/v1/mcp
{
"mcpServers": {
"hedge": {
"type": "http",
"url": "https://your-worker.workers.dev/api/v1/mcp"
}
}
}
What happens behind that, and where each piece lives:
| Step | Endpoint |
|---|---|
| Discover the resource | GET /.well-known/oauth-protected-resource |
| Discover the authorization server | GET /.well-known/oauth-authorization-server |
| Register the client (RFC 7591) | POST /api/v1/auth/mcp/register |
| Sign in and approve | GET /api/v1/auth/mcp/authorize → /oauth/consent |
| Exchange the code (PKCE, S256) | POST /api/v1/auth/mcp/token |
An unauthenticated request is answered with 401 and a WWW-Authenticate: Bearer resource_metadata="…" challenge, which is what lets a client bootstrap from the URL alone.
Every authorization request goes through the consent screen even when the client does not ask for
one — a token that can rewrite your content model should never arrive from a redirect nobody read.
Access tokens last an hour; ask for the offline_access scope to get a refresh token. You can see
what you have approved, and revoke it, under Settings → Account.
What a client is allowed to do
A token acts as the user who approved it, so it is limited twice over: by the scopes granted at the consent screen, and by that user's own role. Neither implies the other, and the narrower one always wins.
That means the same MCP client gives different people different powers, with nothing to configure:
| The user approving it | What their client can do |
|---|---|
| Viewer on the site | Read entries, collections and media |
| Editor on the site | Also draft, publish and delete entries; write newsletters and templates |
| Admin on the site | Also reshape collections, and issue that site's API keys |
| Instance admin | Also invite users, change their roles, and create sites |
| Owner | Everything, including deleting a site |
So granting collections:write to an editor's client does not let it rewrite schemas — the editor
could not either. And granting users:write to a site admin's client does not let it manage users,
because who may sign in to the deployment is not one site's business.
Roles are checked at whichever level the tool belongs to: content, media, newsletters and keys
against your role on the active site; users and creating or deleting sites against your role on
the deployment. Send the site with the X-Hedge-Site header when there is more than one.
A client is only shown the tools its scopes cover, so an assistant approved for reading never sees a
delete tool at all. Ask for a narrow set — a documentation assistant wants the :read halves and
nothing else, and then no prompt it is fed can talk it into a write.
Delivery API keys are deliberately not accepted here. The key that serves a public website sits in that website's environment variables, which is the least protected place any Hedge credential lives; it has no business rewriting content models.
Roles
Two levels, deliberately separate — a site admin runs their site, not your instance.
| Level | Where it lives | Grants |
|---|---|---|
| Instance | users.role | owner and admin manage users and sites, and reach every site. editor / viewer here is only the role a user is granted with by default. |
| Site | site_users | admin — the site's content, keys and members. editor — write content. viewer — read. |
For anyone below instance admin the grant is their access: no row, no site — it will not even appear in their site switcher. Inviting an editor or viewer grants them the site you invited them from; anything after that is handed out under Settings → Users → Manage.
Users are invited by email and set their own password, so an invited account shows as pending until they do. The invite can be sent again from that row — and doing so spends the previous link, so an invite forwarded to the wrong inbox stops working the moment a new one goes out.
Members and gated content
Set an entry's visibility to Members only and the delivery API stops handing out its data.
It still appears in list responses, marked "locked": true and stripped of content, so a site can
render a teaser; the single-entry endpoint answers 403 instead.
Visitors sign in against their site and get a bearer token to replay:
# The site is picked with the X-Hedge-Site header (a site slug), or by the request's Host
# when the site has a domain set.
curl -X POST https://your-worker.workers.dev/api/v1/member/login \
-H 'content-type: application/json' -H 'X-Hedge-Site: blog' \
-d '{"email":"reader@example.com","password":"…"}'
# → { "data": { "token": "…", "expiresAt": "…", "member": { … } } }
curl https://your-worker.workers.dev/api/v1/content/posts/members-only-deep-dive \
-H "Authorization: Bearer hdg_..." -H "X-Member-Token: …"
| Endpoint | Does |
|---|---|
POST /api/v1/member/register | Sign up, when the site allows it |
POST /api/v1/member/login | Exchange credentials for a token |
POST /api/v1/member/logout | Revoke the presented token |
GET /api/v1/member/me | The signed-in member |
POST /api/v1/member/magic-link | Email a sign-in link instead of a password |
GET /api/v1/member/magic-link/verify | Redeem that link |
POST /api/v1/member/forgot-password | Email a reset link |
POST /api/v1/member/reset-password | Set a new password from that link's token |
POST /api/v1/member/send-verification-email | Re-send the confirmation email |
The token travels in X-Member-Token, not Authorization — a website sends both at once, its
delivery key identifying the site and the member token identifying the reader. It is rotated as the
member keeps using it, and register, login and reset are rate limited.
Registering sends a confirmation email. It is not required to sign in, but emailVerified is there
for a site that wants to gate on it. Reset links point at https://<your site domain>/reset-password
when the site has a domain set; pass redirectTo to choose another page on that same domain.
Adding a member from the admin emails them a link. There is no field for a password, because an account a stranger knows the password to is not the reader's account. Until they follow that link they show as invited in Members, and the invite can be sent again from the same row. Someone who already reads another site in this deployment is simply granted this one — they keep the password they have.
Responses that carry a member token are returned private, no-store, so gated content never lands
in a shared cache. Members have no access to the admin API at all — they authenticate against a
separate Better Auth instance over separate tables, so a member token is not merely refused there,
it cannot be resolved.
Signing readers in without a second password
A site whose readers are already signed in somewhere else — the application it documents, a customer portal — should not make them hold a Hedge password as well. Two routes cover the two ways they arrive, and neither one lets anybody set or learn somebody else's password.
From the application. Its backend mints a session for a member it has already authenticated:
curl -X POST https://your-worker.workers.dev/api/v1/member-sessions \
-H "Authorization: Bearer hdg_..." -H 'content-type: application/json' -H 'X-Hedge-Site: blog' \
-d '{"memberId":"mem_..."}'
# → { "data": { "token": "…", "expiresAt": "…", "member": { … } } }
The answer is the one POST /member/login gives, and the session it names is the same kind of
session — /member/me, logout and the daily rotation cannot tell them apart. It needs a key with
members:session, or a signed-in site admin; it refuses a blocked member and a site that is
invite-only exactly as a sign-in does; and it works for a member who has never set a password,
because a password is the thing this flow exists to avoid. Every mint is logged with the credential
that asked for it — this is the one route in Hedge that issues a credential to someone other than
its owner.
From a search result. A reader who lands on a gated page cold has no application to be handed over from, so they ask for a link instead:
curl -X POST https://your-worker.workers.dev/api/v1/member/magic-link \
-H 'content-type: application/json' -H 'X-Hedge-Site: blog' \
-d '{"email":"reader@example.com","callbackURL":"https://blog.example.com/welcome"}'
# → { "data": { "ok": true } } ← the same answer for an address that is nobody
The link works once, expires in fifteen minutes, and lands the reader back on your site with the
token in the URL fragment — #hedge_member_token=…&hedge_member_expires=… — which no browser
sends to a server, so it stays out of your access logs and out of Referer. Read it from
location.hash, store it, and clear the hash. callbackURL must be on the site's own domain or it
falls back to the site root, the same check a reset link goes through; with no domain configured
there is nowhere to land, so the session comes back as JSON instead. Both routes are rate limited,
the link by recipient as well as by caller.
Redeeming a link marks the address verified — clicking something in an inbox proves it more directly than the confirmation mail does. On an account that was not yet verified it also drops the password, which is Better Auth's rule and a sound one: a password set before anyone proved they own the mailbox is not evidence of anything. That member shows as invited again in the admin, signed in, and free to choose a new password.
One account, many sites. A member identity belongs to the deployment, and member_sites records
which sites they may read. A reader of your blog who also reads your docs site keeps one password,
and a site can block them without touching their access to the other. Signing in to a second site
joins it when that site takes signups, and is refused when it is invite-only.
Scripts
| Command | Does |
|---|---|
bun run build | Build core, admin, and the Worker bundle |
bun run deploy | Build, migrate the remote D1, then deploy |
bun run test | Run the test suite |
bun run typecheck | Typecheck every workspace |
bun run lint / lint:fix | Biome check, optionally with fixes |
bun run db:generate | Generate a migration from schema changes |
bun run cf-typegen | Regenerate Worker binding types after config edits |
Contributing
See CONTRIBUTING.md. Issues and pull requests are welcome.
License
MIT © Bihaviour