tekki-forge
v0.3.0
Published
The Forge command line: create apps, generate code, manage the database
Maintainers
Readme
Forge
Describe the application once → compile the description into a production-ready full-stack application.
Forge is a schema-driven TypeScript framework. You write forge.config.ts; Forge's deterministic compiler turns it into a PostgreSQL schema and migrations, a NestJS REST API with authentication, RBAC, multi-tenancy and audit logging, a Next.js admin/CRUD interface, an OpenAPI document, a typed SDK, and Docker configuration.
node packages/cli/dist/bin.js new my-app # interactive
cd my-app
forge up # Docker: build + start; asks for other ports if one is taken
# → http://localhost:3000 ([email protected] / admin12345)What a generated app contains
- Sign in, sign up, password reset, two-factor (TOTP + backup codes), passkeys, Google/Microsoft/GitHub sign-in (Better Auth)
- Organizations: onboarding, org switcher, invitations, member roles; every tenant-scoped query is filtered by the active organization
- One CRUD screen set per entity (
/projects,/projects/new,/projects/:id,/projects/:id/edit) with search, filters, sorting, pagination, bulk delete, CSV export and CSV import, related lists, custom actions and record history - Inline editing in tables, saved views (personal or shared with the organization), Kanban board, calendar and map views, a ⌘K command palette that searches every entity you can read
- A dashboard of metrics, charts and lists declared in the config, computed within each viewer's permissions
- In-app notifications (bell, live updates) and optional emails from rules in the config; React Email templates you can override
- An administration area at
/adminwith its own navigation (the Admin link in the app): system dashboard, analytics, users (roles, ban, impersonate, sessions), organizations, support inbox, transactions, audit logs, system settings (maintenance mode, sign-up, feature flags…), members, teams, scoped API keys, webhooks, jobs - An application area per user: Dashboard, your entities, Notifications, Team, Integrations, Usage, Billing, Settings and Help & support (tickets answered from the admin area)
- REST API:
GET/POST /api/<resource>,GET/PATCH/DELETE /api/<resource>/:id, pluslookup,export,import,bulk-delete,:id/restore,:id/actions/<name>,:id/transitions/<name>,:id/comments,:id/timeline;/api/search,/api/dashboard,/api/saved-views,/api/notifications; Swagger UI at/api/docs packages/sdk: a dependency-free, typed client:client.projects.list({ status: "active" }); optional Python and Dart SDKs (see SDKs)- The admin in several languages (English, French, Spanish, German, Portuguese built in) with your labels translated in
locales/*.json - Plugins: file storage (any S3, SeaweedFS in Docker, or disk), background jobs (Redis + BullMQ, cron), webhooks (signed, retried, delivery log), Stripe payments
- Email over SMTP (
SMTP_URL) or Resend (RESEND_API_KEY), console output otherwise - An idempotent seed (admin user, first organization, sample records) and Dockerfiles that migrate on start
The schema
import { action, defineApp, entity, enumeration, money, relation, string, text } from "@tekki-forge/core";
export default defineApp({
name: "Acme",
auth: { emailPassword: true, social: ["google", "microsoft"], twoFactor: true, passkeys: true },
multiTenant: { enabled: true },
roles: ["admin", "manager", "member"],
entities: {
Project: entity({
fields: {
name: string({ required: true, searchable: true }),
description: text(),
status: enumeration({ values: ["draft", "active", "completed"], default: "draft" }),
budget: money({ currency: "XOF" }),
owner: relation({ entity: "User", required: true }), // "owner" → record owner
},
permissions: {
create: ["admin", "member"],
read: ["admin", "owner", "manager"],
update: ["admin", "owner"],
delete: ["admin"],
},
features: { audit: true, softDelete: true },
actions: { archive: action({ label: "Archive", confirm: "Archive this project?" }) },
}),
},
});Field types: string text richtext integer decimal money percentage boolean date datetime enumeration email url phone color json location file image computed relation.
Relations, files, computed fields and field permissions
Invoice: entity({
fields: {
customer: relation({ entity: "Customer", required: true, inverse: "invoices" }), // many-to-one (+ inverse list)
tags: relation({ entity: "Tag", type: "many-to-many" }), // API: tagIds: string[]
site: location(), // { lat, lng }; map view, `site.within=` filter
scan: file({ accept: ["application/pdf"], maxSize: "10MB" }), // needs storage()
logo: image({ maxSize: "2MB" }),
discount: percentage({ default: 0, permissions: { write: ["admin", "manager"] } }),
internalNote: text({ permissions: { read: ["admin"], write: ["admin"] } }), // hidden from everyone else, in lists, filters, search, exports
lineCount: computed({ type: "integer", expression: "count(lines)" }),
subtotal: computed({ type: "money", currency: "EUR", expression: "sum(lines.amount)" }),
total: computed({ type: "money", currency: "EUR", expression: "round(subtotal * (100 - discount) / 100, 2)" }),
},
}),- Computed fields are stored columns kept current inside the same transaction as the write (including parents'
sum/count/min/max/avgover children and chains of computed fields), so they can be sorted and filtered.forge db recomputerebuilds them after changing an expression. Expressions are type-checked at compile time: decimal arithmetic, comparisons,and/or/not,if,coalesce,round/floor/ceil/abs/min/max,concat/upper/lower/trim/length,today()/now(). - Field permissions use the same tokens as entity permissions (
ownerincluded). Unreadable fields are removed from responses and can't be used to filter, sort or search (no query oracle). - Uploads are sniffed (the declared type must match the content), size-limited, tenant-scoped, and served with safe headers; unreferenced files are cleaned up.
Status workflows
workflow: {
field: "status", // an enumeration; it can then only change through transitions
transitions: {
send: { from: "draft", to: "sent", permission: ["admin", "owner"] },
pay: { from: "sent", to: "paid", permission: ["admin", "manager"], confirm: "Mark as paid?" },
void: { from: ["draft", "sent"], to: "void", variant: "danger" },
},
},Transitions appear as buttons on the record page and drive the board view; POST /api/invoices/:id/transitions/send runs them. Guards and after-effects live in the extension file (transitions: { send: { guard, after } }).
Views
views: {
board: { groupBy: "status", fields: ["dueDate", "total"] }, // drag cards between columns (uses transitions when the field has a workflow)
calendar: { date: "startsAt", end: "endsAt" }, // month view, drag to reschedule
map: { location: "site" }, // OpenStreetMap tiles, clustering, loads the visible area
},Map tiles default to OpenStreetMap (light use only, attribution shown); set NEXT_PUBLIC_FORGE_MAP_TILES (e.g. https://tiles.example.com/{z}/{x}/{y}.png) for production traffic.
Permission tokens: a role, owner, team (members of the record's team, see below), authenticated, public (read only), or policy:<name> implemented in the extension file. All checks run on the server; the UI only mirrors them.
Defaults are secure: an entity without permissions is readable and creatable by signed-in users, updatable by admin + owner (or any signed-in user when there is no owner relation), and deletable by admin (+ owner).
Dashboard, notifications, languages
dashboard: {
widgets: [
metric({ title: "Open deals", entity: "Deal", where: { stage: ["lead", "proposal"] } }),
metric({ title: "Won this month", entity: "Deal", aggregate: "sum", field: "amount", where: { stage: "won", "closedAt.gte": "$startOfMonth" } }),
metric({ title: "Revenue", entity: "Deal", aggregate: "sum", field: "amount", where: { stage: "won" }, dateField: "closedAt", compare: "quarter", visibleTo: ["admin"] }),
metric({ title: "Churned", entity: "Customer", where: { status: "churned" }, compare: "week", trend: "down-is-good" }),
chart({ title: "Pipeline", entity: "Deal", chart: "bar", groupBy: "stage" }),
chart({ title: "Revenue", entity: "Deal", chart: "line", groupBy: { field: "closedAt", by: "month" }, aggregate: "sum", field: "amount", span: 3 }),
list({ title: "My next deals", entity: "Deal", where: { owner: "$me" }, sort: "closeDate", limit: 5 }),
// Goals, ratios and the current calendar period
metric({ title: "Won this month", entity: "Deal", aggregate: "sum", field: "amount", where: { stage: "won" }, dateField: "closedAt", period: "month", goal: 50000 }),
metric({ title: "Conversion", ratio: { numerator: { entity: "Deal", where: { stage: "won" }, dateField: "closedAt" }, denominator: { entity: "Lead" } } }),
// Several entities on one date axis (money on a second axis)
chart({ title: "Leads and revenue", chart: "line", groupBy: { by: "month" }, series: [
{ entity: "Lead", label: "Leads" },
{ entity: "Deal", label: "Deals won", where: { stage: "won" }, dateField: "closedAt", aggregate: "sum", field: "amount" },
] }),
chart({ title: "Pipeline", entity: "Deal", chart: "funnel", groupBy: "stage", stages: ["lead", "proposal", "won"], cumulative: true }),
chart({ title: "Stages by owner", entity: "Deal", chart: "stackedBar", groupBy: "stage", stackBy: "owner", height: "lg" }),
chart({ title: "When leads arrive", entity: "Lead", chart: "heatmap", groupBy: { field: "createdAt", by: "hour" } }),
],
},
notifications: [
{ on: "Deal.updated", when: { changed: "owner" }, to: "owner", title: "{actor} gave you {name}" },
{ on: "Deal.transitioned:win", to: ["owner", "role:manager"], title: "{name} was won 🎉", email: true },
],
i18n: { defaultLocale: "en", locales: ["en", "fr"] },- Metrics show the change versus the previous period ("▲ +12% vs previous month") and a sparkline of the last 12 periods.
compare: "week" | "month" | "quarter" | "year" | false— default"month", or off when the filter already uses relative dates or the entity has no timestamps;dateField(defaultcreatedAt) places records in periods;trend: "down-is-good"colours a fall as good news. Periods are rolling windows ending today, computed with the same tenant, owner and row-level rules as the value; the change is told by an icon and text (plus a full sentence for screen readers), not by colour alone. visibleTo: ["admin"]shows a widget only to those roles; tabs take it too, for a dashboard per role:dashboard: { tabs: [{ title: "Sales", visibleTo: ["sales"], widgets: [...] }] }.- Charts:
bar,line,area,donut,pie,stackedBar(groupBy+stackBy),funnel(enum stages in order;cumulativecounts records past a stage),heatmap(by: "hour": day of week × hour;by: "day": calendar) andtable.seriesputs several aggregates — from any entities — on a shared date axis (each series'dateField) or category axis (fields with the same values); a series in another unit goes on a right axis, and a viewer who can't read an entity loses that series, not the chart. Options:height(sm/md/lg),colors(theme tokens or hex),legend,stacked,format(compact,currency,percent). - Metrics:
period: "month"counts the current calendar month (the whole month, so upcoming dates count; withoutperiodthe value is all-time),goalshows a progress ring,ratiodivides two aggregates (hidden from viewers who can't read both sides).dashboard.timezone(IANA, default UTC) sets date buckets, heatmap hours and periods. - Accessible and theme-aware: chart colours come from
--forge-chart-1…8(derived from the theme, lightened in dark mode), series also differ by line pattern and point shape, every chart has a data table (for screen readers, and a "show as table" toggle for everyone), tabs work with the arrow keys, and points link to the filtered list (date buckets included).forge dashboard add|edit|move|remove|listedits widgets from the shell. - Widget filters use the list query syntax plus
$me,$today,$now,$startOfMonth,$startOfYear, each with an optional offset ($today+7d,$today-30d,-2w,+1m,-1y). Every number respects the viewer's row-level rules, tenant and field permissions (widgets on fields they can't read are hidden). Chart segments link to the filtered list. forge generatewriteslocales/<default>.jsonfrom your labels and keepslocales/<other>.jsonin sync (new keys added in the default language, your translations kept, removed keys dropped).forge doctorlists what's still untranslated. Users switch language in the sidebar; the choice is remembered, and the browser language is used otherwise. Dates and numbers follow the language.
Teams
Ticket: entity({
fields: { title: string({ required: true }) },
features: { teams: true }, // adds a `team` relation (built-in Team model, API: teamId)
permissions: {
create: ["admin", "team"], // on create: the record must go to one of your teams
read: ["admin", "team"], // members of the record's team
update: ["admin", "team"],
delete: ["admin"],
},
}),- Admins manage teams of the active organization under Administration → Teams (
/admin/teams): create, rename, delete, add and remove members (any member of the organization; a user can be in several teams). NeedsmultiTenant. "team"works like"owner"everywhere: lists (WHERE teamId IN <your teams>), record pages, updates, deletes, bulk delete, exports, ⌘K search, lookups, dashboard numbers and charts, field permissions (permissions: { read: ["admin", "team"] }), notification recipients, and record comments, the timeline and @mentions (only people who can read the record can be mentioned). Records without a team are only reachable through the other tokens.- Lists get a Team filter; forms a team picker. Non-admins can only assign records to teams they belong to. Deleting a team keeps its records (their team is cleared).
- API:
GET/POST /api/teams,PATCH/DELETE /api/teams/:id,GET/POST /api/teams/:id/members,DELETE /api/teams/:id/members/:userId(admins),GET /api/teams/lookup?search=&mine=1(members).
Administration area
Every app has two areas: the application (/, the entities and the person's pages) and the administration at /admin, with its own layout and navigation, reached from the Admin entry shown in the sidebar, the top bar, the compact rail and the mobile header to people allowed in. Everyone else gets a 403 page.
| Section | URL | Who sees it |
| --- | --- | --- |
| Dashboard | /admin | all admins — users and new sign-ups (trend), organizations, active sessions, records created per entity (30 days), recent sign-ups and audit events, failed jobs (jobs), webhook failures (webhooks), MRR and failed payments (payments), open support requests |
| Analytics | /admin/analytics | all admins — sign-ups, active users and records created over 7/30/90 days or 12 months |
| Users | /admin/users | global admins — search, roles (any app role without organizations), create, ban, impersonate, sessions, delete |
| Organizations | /admin/organizations | global admins, multiTenant — member counts, plan, members and roles of each, impersonation |
| Members, Teams | /admin/members, /admin/teams | admins of the active organization (teams: features.teams) |
| Content | /admin/content | global admins, apps with a website, when a page is registered as admin/content |
| Transactions | /admin/transactions | global admins, payments plugin — subscriptions, invoices, payments and refunds with filters and CSV export |
| Support | /admin/support | global admins, admin.support (default on) |
| API keys, Webhooks | /admin/api-keys, /admin/webhooks | admins of the active organization (webhooks plugin) |
| Jobs | /admin/jobs | global admins, jobs plugin |
| Schema | /admin/schema | admins, while running forge dev |
| Audit logs | /admin/audit-logs | all admins — global admins see every organization (?organizationId= for one) |
| System settings | /admin/settings | global admins |
Global admins have the admin role on their account (forge user create --role admin, or the Users page). With organizations, organization admins (member role admin or owner) see the admin area limited to their organization: its members, data, audit log and activity. Without organizations, admin is the global admin. Every endpoint checks this on the server; API keys are refused.
- System settings live in the database (
system_settings) and apply right away, each change written to the audit log: app name override, support email, maintenance mode (a banner for everyone; writes answer503except for global admins — sign-in, notifications and support stay open), sign-up open/closed at runtime (it can't be opened whenauth.registration: false; invited people still get in), default role for new sign-ups (apps without organizations), email sender (elseEMAIL_FROM), feature flags, and a read-only environment status (email delivery, storage, Redis, Stripe). - Feature flags are declared with their default and switched at runtime:
featureFlags: { newEditor: false }. In the UI:useFeatureFlag("newEditor")from@tekki-forge/runtime-admin; in extensions and jobs:await ctx.use<FeatureFlags>("featureFlags")?.enabled("newEditor"). They are public (GET /api/config). - Support:
/supportin the app opens tickets (subject, priority, message) and shows the thread; global admins are notified, answer, assign (to a global admin), change status (open→ waiting for support,pending→ waiting for the person,closed) and, from a ticket, impersonate the person or open their organization. Replies notify the other side (staff replies also by email).admin: { support: false }removes it. - Application pages: Notifications; Team (with organizations: members, invitations and teams for organization admins, a read-only list for members); Integrations (organization admins: API keys and webhook endpoints); Usage (payments plugin with plan limits: limits against current counts); Billing; Settings (account, security, sessions and, for organization admins, the organization's name and slug).
/accountredirects to/settings. - API:
GET /api/admin/overview,GET /api/admin/analytics?days=7|30|90|365,GET /api/admin/organizations[/:id],GET|PATCH /api/admin/settings,GET /api/admin/support/tickets,PATCH /api/admin/support/tickets/:id,GET|POST /api/support/tickets,GET /api/support/tickets/:id,POST /api/support/tickets/:id/messages,GET /api/audit-logs?scope=all; payments:GET /api/billing/admin/summary|subscriptions|invoices|export?kind=,GET /api/billing/invoices. - The admin area keeps the theme (style, preset, colours, light/dark) with a neutral tint and an "Administration" badge; it is a sidebar on large screens and a drawer on phones, whatever
theme.layoutthe app uses. Plugin admin pages (nav: [{ admin: true }]) appear under Developers; register your own page in the pages registry (pages={{ "admin/reports": ReportsPage }}) and it is served at/admin/reports.
API keys
Admins create keys under Administration → API keys (/admin/api-keys) or with the CLI, for scripts and integrations:
forge apikey create --name "Accounting sync" --scopes invoices:read,customers:read --role member --expires 90d
forge apikey list
forge apikey revoke fk_3f9a1c2b7d4e # id, prefix or the key itself
curl -H "Authorization: Bearer fk_3f9a1c2b7d4e_…" https://app.example.com/api/invoices- Scopes per resource and operation:
invoices:read,invoices:write(create, update, delete, actions, transitions, import),invoices:*,*:read,*:write,*. A key is a service principal of its organization: it acts with the role chosen at creation, limited to its scopes, under the same row-level, tenant and field rules as people (it owns no records and belongs to no team). - A key never exceeds its creator: the role must be one the creator has (admins may pick any), and it is re-checked on every request — if the creator is demoted, leaves the organization or is banned, the key loses those rights or stops working.
- The key (
fk_<prefix>_<secret>) is shown once. Only a SHA-256 hash of the secret is stored; the prefix is the lookup id. Revoked, expired or unknown keys get401; last use is recorded (at most once a minute). Admin API:GET/POST /api/api-keys,DELETE /api/api-keys/:id(revoke). - Each key is limited to
FORGE_API_KEY_RATE_LIMITrequests per minute (default 600,0disables) — an in-memory counter per API process, so put a limit in your proxy too when you run several instances. Over the limit:429. - The audit log records the key, not a user, as the actor (
apiKeyId; filter with/api/audit-logs?apiKeyId=). Records with an owner relation created by a key are owned by the key's creator. - Keys work on resource endpoints and
/api/search,/api/dashboard,/api/stats,/api/me; comments and the timeline of a record can be read with<resource>:read, but keys cannot post, edit or delete comments (comments and mentions need a person); person-centric endpoints (saved views, notifications) and plugin routes (uploads, jobs, webhooks, billing) refuse them. The OpenAPI document declares theapiKeybearer scheme, and the SDK takes it:createClient({ baseUrl, apiKey: process.env.APP_API_KEY }).
Backups
forge backup # pg_dump of DATABASE_URL → backups/<app>-<UTC time>/{database.sql.gz,manifest.json}
forge backup --keep 14 # …then delete older backups so 14 remain
forge backup list
forge backup restore acme-20261001T030000Z # shows the target and the tables it would replace, changes nothing
forge backup restore acme-20261001T030000Z --yes # replaces them (one transaction); add --force for a production databasebackups: { schedule: "0 3 * * *", keep: 14 }, // forge.config.ts — cron (UTC); keep defaults to 14- A backup is a compressed plain-SQL
pg_dump(no owners or grants, objects dropped and recreated on restore) and a manifest: time, source database (never credentials), size and SHA-256 of the dump (checked before restoring), and every uploaded file the database references (key, name, size, type, record). Uploaded objects themselves stay in their storage — back up the bucket orSTORAGE_DIRtoo. - Where:
./backups(or--dir,FORGE_BACKUP_DIR); when the storage plugin uses S3 (S3_BUCKETset), the same bucket underbackups/(FORGE_BACKUP_S3_BUCKET,FORGE_BACKUP_S3_PREFIXoverride;--localforces the folder). Retention only deletes backups Forge named. pg_dumpmust match the server (PostgreSQL 17): Forge uses the local client when it is recent enough, otherwise runs it inside thepostgrescontainer ofdocker-compose.yml(docker compose exec), or in any container named byFORGE_PG_CONTAINER. The generated API image installspostgresql17-client.- Scheduled: with the jobs plugin,
backups.scheduleregisters aforge.backupcron job that the worker runs (visible under Administration → Jobs); in Docker the worker writes to abackupsvolume unless S3 is configured. Without the jobs plugin, runforge backup --keep 14from cron (0 3 * * * cd /srv/app && forge backup --keep 14) or a scheduled CI job withDATABASE_URLset (andFORGE_ENV=productionso restores need--force). - Restores refuse to run without
--yes, and into a production database (NODE_ENV/FORGE_ENV=productionor a non-local host) without--force. Tables that are not in the backup are left untouched; restart the API afterwards.
Comments, mentions and activity
Ticket: entity({
fields: { /* … */ },
features: { audit: true, comments: true },
}),- The record page gets an Activity panel: created / changed (field by field, "Priority: low → high") / deleted / restored, workflow transitions and actions from the audit log, and comments, in time order with "show earlier activity". It appears when the entity has
auditorcomments. - Anyone who can read a record can comment on it; authors edit and delete their own comments, admins can delete any. Type
@to mention someone (arrow keys, Enter, Escape): suggestions are people of the organization who can read the record. Mentioned people get an in-app notification and thementionemail (override it like any template); mentions of anyone else are dropped. - Changes to fields the viewer can't read never appear (an update that only touched them is left out). Comments live in the built-in
RecordCommentmodel (tablerecord_comments, soft-deleted,editedAtkept; your ownCommententity is unaffected) rather than in the audit log. - API:
GET/POST /api/<resource>/:id/comments,PATCH/DELETE /api/<resource>/:id/comments/:commentId,GET /api/<resource>/:id/comments/mentionable?search=,GET /api/<resource>/:id/timeline?before=<nextCursor>&limit=. A mention is written@[Name](userId)in the comment body (max 5000 characters). SDK:client.tickets.comments.create(id, "Hi @[Ada](…)"),client.tickets.timeline(id).
Plugins
forge add storage # file/image fields — SeaweedFS (S3) in Docker, disk in development, any S3 in production
forge add jobs # Redis + BullMQ queue, cron schedules, worker process, admin page at /admin/jobs
forge add webhooks # endpoints + delivery log; HMAC-signed (Forge-Signature: t=…,v1=…), retried with backoff, SSRF-guarded
forge add payments # Stripe Checkout + customer portal, plans with limits, idempotent webhookforge add edits forge.config.ts, installs the package, adds Docker services and .env entries, and regenerates. A plugin can contribute entities, API routes, worker handlers, admin pages and navigation (definePlugin in @tekki-forge/core, runtimes via definePluginRuntime).
- Jobs: a file
apps/api/src/jobs/<name>.job.tsexportingdefineJob("name", { schedule?, attempts?, handler })is registered byforge generate; enqueue withctx.use<JobQueue>("jobs")?.enqueue("name", data). Emails are queued (and retried) when jobs are installed. - Emails:
apps/api/src/emails/<name>.email.tsx(React Email components from@tekki-forge/emails); a file named like a built-in template (resetPassword,verifyEmail,invitation,notification) replaces it. - Payments:
payments({ plans: [{ id: "free", name: "Free", limits: { Project: 3 } }, { id: "pro", name: "Pro", priceEnv: "STRIPE_PRICE_PRO", limits: { Project: 100 } }], trialDays: 14 })— creating beyond a plan's limit answers402with an upgrade message;/billingshows the plan and opens Checkout / the portal,/usagethe limits against current counts. Invoices, payments and refunds are stored from the webhook (PaymentInvoice; sendinvoice.*andcharge.refundedevents) and listed in Administration → Transactions, with MRR from the subscriptions' prices.
Themes
Start from a preset, then override any key — chosen during forge new, changed any time with forge theme:
theme: {
preset: "midnight", // midnight | sandstone | ocean | forest | sunset | graphite | candy | paper | phosphor
// | boardroom | sakura | noir | arcade | ember | atelier | matcha | circuit | lagoon
// | orchid | harbor | blueprint
style: "aurora", // simple | shadow | neumorphism | glassmorphism | skeuomorphism | material
// | brutalist | editorial | aurora | terminal | clay | corporate | soft | luxury
// | retro | neon | swiss | nature | cyber | minimal | playful
colors: { primary: "#7c3aed", accent: "#0891b2" }, // optional: danger
mode: "system", // light | dark | system
radius: "large", // none | small | medium | large | full
font: "serif", // inter | geist | plex | grotesk | serif | rounded | mono | system
// | elegant | pixel | tech | playful
layout: "topbar", // sidebar (default) | topbar | compact (icon rail with tooltips, expand toggle)
density: "compact", // comfortable (default) | compact
logo: "/logo.svg", // file in apps/web/public or an https:// URL; also the favicon
tagline: "Invoices without the paperwork", // shown on the sign-in brand panel
// Look and feel (all optional; default: the style's own)
background: "mesh", // plain (default) | gradient | mesh | dots | grid | noise
cards: "glass", // flat | bordered | elevated | glass
buttons: "pill", // solid | soft | outline | pill
motion: "lively", // none | subtle (default) | lively — always none under prefers-reduced-motion
sidebar: "brand", // light | dark | brand (the primary colour)
iconStyle: "duotone", // outline (default) | duotone
chartPalette: "vivid", // theme (default) | vivid | pastel | mono — exposed as --forge-chart-1 … --forge-chart-8
},| Preset | Look |
| --- | --- |
| midnight | Aurora style, deep indigo + cyan, dark, Geist |
| sandstone | Neumorphism in terracotta and teal, IBM Plex |
| ocean | Glass over sea blues, follows the OS mode |
| forest | Shadow cards, evergreen + gold, serif headings, top bar |
| sunset | Clay in rose and orange, Nunito |
| graphite | Neutral, dense, icon rail, Space Grotesk |
| candy | Neo-brutalist pink and yellow, top bar |
| paper | Editorial ink on paper, Fraunces headings |
| phosphor | Terminal green on black, JetBrains Mono, icon rail |
| boardroom | Corporate blue-grey, navy sidebar, dense tables, IBM Plex |
| sakura | Soft cherry-blossom pastels, gradient page, Nunito |
| noir | Luxury black and gold, Cormorant headings, top bar, dark |
| arcade | Retro 90s windows with bevels, pixel headings, teal |
| ember | Neon orange and magenta glow on charcoal, mesh background |
| atelier | Swiss grid, big type, signal red, top bar |
| matcha | Nature: tea green and clay on textured paper, Fraunces |
| circuit | Cyber HUD yellow and cyan, angled corners, icon rail, dark |
| lagoon | Minimal calm teal, hairlines, Geist, follows the OS mode |
| orchid | Playful violet and sunshine, hand-drawn edges, lively motion |
| harbor | Shadow cards, navy brand sidebar, coral, pill buttons, vivid charts |
| blueprint | Dark drafting grid, bordered cards, duotone icons, mono charts |
| Style | Look |
| --- | --- |
| simple | Flat and crisp, hairline borders (Linear-like) — the default |
| shadow | Borderless cards floating on soft layered shadows |
| neumorphism | Soft extruded surfaces, the same colour as the page |
| glassmorphism | Frosted translucent panels over a backdrop made from your colours |
| skeuomorphism | Tactile gradients, bevels, paper cards and a leather sidebar |
| material | Material Design 3: tonal surfaces, elevation, pill buttons, filled fields |
| brutalist | Thick borders, hard offset shadows, bold type |
| editorial | Paper tones, serif headings, hairlines, generous whitespace |
| aurora | A slowly drifting gradient mesh from your colours behind glassy cards |
| terminal | Dark-first, monospace, phosphor accents (no scanlines or flicker) |
| clay | Claymorphism: puffy rounded surfaces with inner highlights |
| corporate | Sober and dense, enterprise blue-grey, crisp zebra tables |
| soft | Pastel, rounded and airy, gentle tinted shadows |
| luxury | Deep tones, gold (your primary) accents, serif display, thin rules |
| retro | 80s/90s desktop: bevelled windows with title bars, pixel headings, readable body |
| neon | Dark, glowing accents, crisp borders (glow off for reduced motion and forced colours) |
| swiss | Strict grid, big type, red accent, no shadows |
| nature | Earthy tones, organic shapes, paper texture (CSS only) |
| cyber | Angled corners (clip-path), HUD brackets and accents, dark-first |
| minimal | Almost no chrome: whitespace and hairlines |
| playful | Bold colours, hand-drawn edges, bouncy hover (still under reduced motion) |
- Some styles bring a companion when you don't set one:
editorial→ serif,terminal→ mono + dark,brutalist→ grotesk,clay→ rounded,corporate→ plex + small corners,soft→ rounded + large corners,luxury→ elegant + dark,retro→ pixel + square,neon→ grotesk + dark,swiss→ inter + square,nature→ serif + noise background,cyber→ tech + dark + grid background,minimal→ geist,playful→ playful font + dots background + lively motion. - Look and feel works with any style:
backgroundpaints the page from your colours (CSS only, static),cards/buttons/sidebarreplace the style's own treatment,iconStyle: "duotone"tints the navigation icons,motiontunes page and card transitions, andchartPalettesets--forge-chart-1…--forge-chart-8for charts (custom charts should read these variables). - Fonts load from Google Fonts with
display=swapthrough<ThemeHead app={app} />in the root layout (nonext/font, nothing fetched at build time);systemloads nothing. Pairings set--forge-font-display(headings, metrics) and--forge-font-body. - Sign-in pages are split-screen on desktop: a brand panel generated from your primary/accent (CSS gradient mesh + SVG pattern) with the app name, description and
tagline; a single column on mobile. - Built in: skeleton loaders, toasts after saves, deletes, transitions and imports (
useToast()for custom pages), small illustrated empty states, subtle page transitions and hover elevation. All motion stops underprefers-reduced-motion. - Landing page:
landing: true(top level of the config) gives signed-out visitors of/a public page in your theme — hero on the same brand art as the sign-in pages, feature cards (default: one per entity), call to action and footer, with title/description/Open Graph metadata; signed-in users still get the dashboard. Override any part:landing: { headline, subheadline, features: [{ title, description, icon: "zap" }], cta: { label: "Start free", href: "/sign-up" } }(texts are translated throughlocales/*.json). Server-rendered; no client JavaScript for its content. For more than one page, usesite(landingbecomes its home page). - Emails use the theme too: brand colour on buttons (text colour checked for contrast), an accent rule, the logo (local paths made absolute with
PUBLIC_URL, elseBETTER_AUTH_URL), radius and the tagline in the footer, with dark-mode styles where clients support them. Email clients don't load web fonts, so the theme font is named first, then a web-safe stack (Georgia forserif, Menlo/Consolas formono, the system sans otherwise). Custom templates built onLayout/Title/Actionget all of it automatically. forge theme— interactive picker (presets first, with a terminal preview), or flags:forge theme --preset paper,forge theme --style material --primary "#059669" --mode dark --font plex --layout topbar --density compact --logo /logo.svg --tagline "…",forge theme --background mesh --cards glass --buttons pill --sidebar brand --icon-style duotone --chart-palette vivid --motion lively(--cards autoetc. hands a treatment back to the style).- Preview in the browser: "Preview in browser…" in the preset picker of
forge new/forge theme(orforge theme --preview) opens a local page showing every preset as a mini admin rendered with the real admin CSS, in light or dark, with selects to try any background, cards, buttons, sidebar, icons, motion and chart palette on all of them. Click a preset's "Use …" button there or keep choosing in the terminal — whichever answers first wins. The server listens on 127.0.0.1 only (random port, per-session token, same-origin and Host checks) and stops once a preset is chosen;FORGE_NO_OPEN=1/BROWSER=nonekeeps the browser closed (the URL is always printed). - In
forge deva Theme button (development only) previews presets, styles, fonts, layouts, density, modes, corners, colours and the look and feel live on your real screens and prints the matchingforge theme …command. - Text colour on top of your primary/accent is chosen automatically for WCAG contrast, and links clamp the primary's lightness so they stay readable. A test evaluates the real stylesheet for every style × mode × preset colours (text, links, buttons, dark and brand sidebars) against WCAG AA.
- How it works:
@tekki-forge/runtime-admin/styles.cssdefines each style on stableforge-*hooks, colours and fonts come from CSS variables in the generatedpackages/generated/theme.css, and<html {...themeAttributes(app)}>(data-forge-style,-mode,-radius,-layout,-density,-font,-background,-cards,-buttons,-motion,-sidebar,-icons,-chart) selects the look. Styles are token-first: anything built on the--forge-*tokens inherits every style. Custom pages can use the same tokens as Tailwind classes:bg-surface,text-ink,text-ink-muted,border-line,bg-primary,text-primary-foreground,text-link, or the hooks (forge-card,forge-btn forge-btn-primary,forge-input,forge-heading).
Website
A public website — pages made of sections, a header menu with dropdowns, a footer and a blog — declared in the config and edited afterwards by admins under Content (/admin/content):
import { defineApp, page, section } from "@tekki-forge/core";
site: {
pages: {
home: page({ template: "home" }), // hero, logos, features, steps, stats, testimonials, FAQ, CTA
pricing: page({ template: "pricing" }),
catalog: page({
title: "Catalog",
path: "/catalog",
seo: { description: "Browse our products." },
sections: [
section("hero", { variant: "minimal", title: "Catalog" }),
section("entityGrid", { entity: "Product", fields: ["name", "price"], sort: "name" }), // Product's read rule includes "public"
],
}),
blog: page({ template: "blog" }), // /blog, /blog/<slug>, /blog/rss.xml
login: page({ template: "login" }), // header buttons → /sign-in, /sign-up
signup: page({ template: "signup" }),
},
// nav: [{ page: "pricing" }, { label: "Resources", items: [{ page: "blog" }, { label: "Docs", href: "https://docs.acme.com" }] }],
footer: { note: "Made in Dakar", social: [{ network: "github", href: "https://github.com/acme" }] },
},- Sections (each with 2–4
variants, all built on the theme tokens):hero(centered, split, brand, minimal),features(grid, alternating, bento),logos,testimonials,stats(static numbers, or livecount/sum/avg/min/maxof an entity like a dashboard metric),steps,pricing(static plans or the payments plugin's),faq,cta,comparison,integrations,team,gallery,contact(messages stored inContactMessage, admins notified),newsletter(NewsletterSubscriber),richText(sanitized markdown),imageText,video(YouTube, Vimeo, file),embed(allow-listed hosts +site.embedHosts),blogList,spacer,divider,entityList/entityGrid(public records with chosen fields, filter, sort, limit). - Templates fill a page with sections and placeholder copy from your app's name, description and entities: home, features, how-it-works, integrations, product-tour, use-cases, industries, pricing, customers, blog, guides, webinars, docs, about, team, careers, contact, security, privacy, compliance, login, signup, blank. Anything you set (
title,path,seo,sections) overrides the template. - Menu and footer are built from the pages when
nav/footerare omitted: Product ▸ Features…, Company ▸ About/Contact (nav: { group }, or the first path segment), legal pages in the footer only (nav: { footerOnly: true }), keyboard-accessible dropdowns and a mobile menu. - Blog: one built-in
SitePostmodel (title, slug, excerpt, cover, markdown body, author, tags, draft/published) with akind—post,guide,webinar,case-study— listed byblogListsections; each kind gets<listing path>/<slug>pages and an RSS feed at<listing path>/rss.xml. - Validation refuses unknown section types, variants and props (with suggestions), duplicate paths and paths of the app (
/sign-in,/admin,/api, entity pages, plugin pages), live data on entities that aren't publicly readable or are organization-scoped, payments plans without the payments plugin and uploaded images without the storage plugin. - Config + admin edits: pages and sections are seeded into the database (
SitePage,SiteSection) when the API starts and onforge db seed. A section the admin never touched follows later config changes; one the admin edited keeps the admin's version and is flagged in Content ("changed in forge.config.ts too") with a one-click "use the config version". Admins edit props through generated forms with a live preview of the real components, add sections from a visual picker, reorder them (drag or keyboard), hide, publish/unpublish pages, edit SEO, upload images (storage plugin), and manage posts, contact messages and subscribers. - Rendering: server-rendered pages for every site path (signed-out visitors see the home page at
/, signed-in users the dashboard), title/description/canonical/Open Graph metadata,sitemap.xmlandrobots.txt; client JavaScript only for the menu and the forms. - CLI:
forge newasks whether you build an internal tool or a product with a website (--websitenon-interactively);forge add page <template|name> [--path /x] [--title …](--listshows the templates),forge add section <page> <type> [--variant v] [--after n],forge remove page|section. The first site needsforge db migrate(new tables); later content changes don't. landing: true | {...}still works: it becomes the home page.
SDKs
forge generate writes a typed client for the REST API. TypeScript (packages/sdk) is the default; add Python and Dart in the config:
export default defineApp({
name: "Acme CRM",
sdk: { languages: ["typescript", "python", "dart"] },
// …
});| Language | Folder | Client |
| --- | --- | --- |
| TypeScript | packages/sdk | dependency-free fetch client: createClient({ baseUrl }) |
| Python ≥ 3.10 | packages/sdk-python | httpx, sync ForgeClient + async AsyncForgeClient, dataclass records, passes mypy --strict |
| Dart ≥ 3.4 / Flutter | packages/sdk-dart | package:http, null-safe records with fromJson/toJson, enums, sealed exceptions, passes dart analyze |
from decimal import Decimal
from acme_crm_sdk import ForgeClient, OpportunityCreateInput, ValidationError
with ForgeClient("https://api.example.com", token="…") as client: # or session_token=…, sign_in(), headers=…
page = client.opportunities.list(stage=["qualified", "won"], amount_gte=Decimal("1000"), sort="-amount")
deal = client.opportunities.create(OpportunityCreateInput(title="Big deal", amount=Decimal("1234.50"), customer_id=page.data[0].customer_id))
client.opportunities.mark_won(deal.id)
try:
client.opportunities.create({})
except ValidationError as err:
print(err.field_errors) # {"title": ["Required"], …}final client = ForgeClient(baseUrl: 'https://api.example.com', token: '…');
final page = await client.opportunities.list(stage: OpportunityStage.won, sort: '-amount');
await client.invoices.transition(id, InvoiceTransition.send);Every SDK covers the same surface: typed records (enums, money as exact decimals — Decimal in Python, String in Dart — dates, relation ids plus expanded { id, label }, files), create/update inputs that mirror the API's validation (required vs optional, None/clear to null a field), list with pagination, sort, search and typed filters (.gte/.lte ranges, trashed), page iteration, get/create/update/delete, restore, workflow transitions, actions, CSV export, lookup and import, me/search/dashboard; auth by bearer token (Authorization: Bearer, static or per-request), the session cookie (sign_in/signIn keeps it) or custom headers; the active organization via set_active_organization; timeouts; and errors typed by status with the API's field issues. Code under src//lib/ is regenerated; pyproject.toml, pubspec.yaml and the READMEs are created once and yours to edit.
Architecture
forge.config.ts ──► @tekki-forge/compiler ──► AppIR (normalized, validated, JSON)
│
┌──────────────┬──────────────┬───┴──────────┬──────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼ ▼
generator-prisma generator-api generator-ui generator-sdk generator-docker extensions
schema.prisma IR, types, resource typed client compose + stubs (once) +
OpenAPI metadata Dockerfiles registry
│ │ │
▼ ▼ ▼
PostgreSQL runtime-api runtime-admin ◄── generic runtimes read the metadata;
(NestJS) (React/Next.js) no per-entity CRUD code is generated
│
runtime-auth (Better Auth) · runtime-permissions (RBAC/owner/policies, shared)
runtime-schema (Zod validators, shared by API and forms)Generators never inspect raw config — only the IR — so other inputs (Prisma import, OpenAPI import, an AI schema architect) can target the same IR later.
Packages
| Package | Role |
| --- | --- |
| @tekki-forge/core | DSL: defineApp, entity, field builders, action |
| @tekki-forge/compiler | Loads forge.config.ts, validates (all issues at once, with paths), normalizes to AppIR; UI metadata derivation; file ownership model |
| @tekki-forge/runtime-permissions | authorize, allowsRecord, can, effectiveRoles — shared by API and UI |
| @tekki-forge/runtime-schema | Zod input schemas and value formatting derived from the IR |
| @tekki-forge/runtime-auth | Better Auth configured from the IR (admin, organization, two-factor, passkey plugins; mailer) and the browser client |
| @tekki-forge/runtime-api | NestJS runtime: one generic controller, query parser, tenancy, ownership, audit, soft delete, hooks, actions, policies, error mapping, Swagger |
| @tekki-forge/runtime-admin | React components: AppShell, ResourceList (table/board/calendar/map, inline edit, saved views, import), ResourceForm, ResourceDetail, dashboard, command palette, notifications, auth pages, account, admin pages, i18n catalogs |
| @tekki-forge/generator-* | Prisma, API (IR/types/OpenAPI), UI metadata, SDK, Docker |
| tekki-forge / create-tekki-forge | The forge command and npx create-tekki-forge |
| starters/default | Template for generated apps |
| @tekki-forge/emails | React Email layout, built-in templates, createMail (SMTP/Resend, queued with jobs) |
| @tekki-forge/plugin-storage · plugin-jobs · plugin-webhooks · plugin-payments | First-party plugins |
| @tekki-forge/expression | Computed-field expression language (parser, type checker, decimal evaluator) |
| examples/ | Field service (every feature), CRM, blog, e-commerce configs (+ extension files, jobs, translations) |
Generated app layout and file ownership
my-app/
├── forge.config.ts ← you
├── locales/<locale>.json ← Forge creates, you translate (merged on generate)
├── apps/api/src/
│ ├── main.ts, auth.ts, seed.ts ← you (thin, created once)
│ ├── modules/<entity>/<entity>.extension.ts ← you: hooks, actions, policies, transitions
│ ├── jobs/*.job.ts, emails/*.email.tsx ← you
│ └── .generated/extensions.ts ← Forge
├── apps/web/app/** ← you: thin pages rendering Forge components
├── packages/generated/ ← Forge: IR, types, resources, OpenAPI
├── packages/sdk/ ← Forge: typed client
├── packages/sdk-python/, sdk-dart/ ← Forge: Python/Dart clients when `sdk.languages` lists them (metadata files are yours)
├── packages/database/prisma/schema.prisma ← Forge
├── packages/database/prisma/migrations/ ← Forge creates, you commit
└── docker-compose.yml, Dockerfile.* ← you (created once; `forge docker --force` recreates)forge generate rewrites only framework-owned files; developer-owned files are created once and never touched.
Extending behaviour
// apps/api/src/modules/opportunity/opportunity.extension.ts
export default defineExtension("Opportunity", {
hooks: {
async beforeUpdate({ data }) {
if (data.stage === "won") return { ...data, probability: 100 };
},
},
actions: {
async markWon({ record, prisma }) {
return prisma.opportunity.update({ where: { id: record.id }, data: { stage: "won" } });
},
},
policies: {
// referenced in forge.config.ts as "policy:assignee"
assignee: { check: ({ principal, record }) => record.assigneeId === principal?.id, where: ({ principal }) => ({ assigneeId: principal?.id }) },
},
});CLI
forge new [name] [--yes] [--config file] [--website] [--no-install] [--local]
forge dev generate, run API + web, regenerate when forge.config.ts changes
forge generate [db|api|ui|sdk|sdk-python|sdk-dart|docker]
forge schema validate | inspect [--json]
forge add entity [Name] interactive entity builder (edits any forge.config.ts via the TypeScript AST)
forge add field <Entity> [name]
forge add relation <From> <To> [--name field] [--required]
forge remove field <Entity> <field>
forge add page <template|name> [--path /x] [--title "…"] [--key k] · forge add page --list website page from a template, or a blank one
forge add section <page> <type> [--variant v] [--after <index>] [--id k] · forge add section --list
forge remove page <page> · forge remove section <page> <index|id> website edits: no migration (the first site needs forge db migrate)
forge db generate [-n name] SQL migration from schema changes — no database needed
forge db migrate [-n name] generate + apply
forge db rollback [--yes] [--no-database] [--keep-config] undo the latest migration (database, files, config)
forge db deploy | seed | reset | studio
forge import prisma <schema.prisma> | openapi <file|url> | db <postgresql://…> [--into] [--name]
forge test [Entity…] [--ci] [--watch] API tests per entity (CRUD × roles) on a throwaway database
forge doctor --perf missing indexes, full scans, slow list queries, unused indexes, slowest queries
forge theme [--preset --style --primary --accent --mode --radius --font --layout --density --logo --tagline]
forge add storage|jobs|webhooks|payments
forge dashboard list | add [metric|chart|list] [--title --entity --type --group-by --by --series …] | edit <n> | move <n> <to> | remove <n>
forge db recompute recalculate computed fields (after changing an expression)
forge auth add google|microsoft|github|passkey|2fa
forge user create --email --password [--role r] [--org slug] · forge user promote <email> [--role r]
forge apikey create --name --scopes [--role] [--expires 90d|2027-01-01] [--org] [--user] · forge apikey list · forge apikey revoke <key>
forge backup [--keep n] [--dir] [--s3|--local] · forge backup list · forge backup restore <name> [--yes] [--force]
forge up [services…] [--yes] [--ports] [--no-build] [--attach] Docker Compose, with port-conflict checks (saved in .env)
forge down [--volumes] · forge logs [service]
forge docker [--force] · forge doctor · forge build · forge startforge db generate diffs the current Prisma schema against a snapshot of the last migrated schema, so migrations are created deterministically without a shadow database.
Tests, rollback, import, performance and the schema editor
forge testruns a suite generated per entity (apps/api/test/generated/): the real API starts in-process against<database>_test(recreated each run), with one user per role plus an admin of another organization. For every role it checks that create, list, read, update and delete succeed exactly whenforge.config.tsallows them, that owner rules and other organizations see nothing they shouldn't, that hidden fields stay hidden and that invalid input is refused field by field. Add your own*.test.tsnext to them (getKit()gives youkit.request(kit.actors.member, "POST", "/invoices", {...})). CI runs it withforge test --ci.forge db rollbackundoes the latest migration: Forge keeps the schema andforge.config.tsnext to every migration, so it computes the reverse SQL, shows it (and warns about data loss), runs it with the migration bookkeeping in one transaction, moves the migration toprisma/rolled-back/and restores the config (the current one is kept asforge.config.ts.before-rollback). Migrations made before this version can't be undone automatically.forge importstarts from what you have: a Prisma schema, an OpenAPI/Swagger document, or a live PostgreSQL database (prisma db pull). Models become entities with mapped types (enums, money, dates, e-mails, URLs), relations (many-to-one and many-to-many), uniques and defaults; auth tables are replaced by Forge's own; everything adjusted is listed. You review the result like inforge new, or add it to the current app with--into. Forge creates fresh tables — bring rows over with CSV import.- Performance: indexes follow how the app queries (tenant + default sort for lists, relations, filters, calendar, map, dashboard filters, owner-scoped lists). The API logs queries slower than
FORGE_SLOW_QUERY_MS(500 ms).forge doctor --perfchecks the database: missing planned indexes, big tables read by full scans, list pages that scan and sort, unused indexes, and the slowest statements whenpg_stat_statementsis on. - Schema editor (
/admin/schema, admins, only underforge dev): edit entities and fields visually (types, relations, enums, validation, order, labels, icons), see a diagram, review the exact diff and data-loss warnings, apply (the app regenerates) and create the migration from the page. Renaming an entity rewrites every reference and pinsstorageName,tableandpathto the old values, so the database and stored rows (files, audit log, webhook patterns) stay as they are. It writesforge.config.tsthrough the same AST editing as the CLI; setFORGE_SCHEMA_EDITOR=offto disable it.
Deploy
forge ci [--force] [--push | --no-push] GitHub Actions CI workflow (+ push to your GitHub)
forge deploy [fly|railway|render] [--dry-run] [--yes] [--force] [--push | --no-push]Everything runs with your own accounts. forge deploy without a target asks where to deploy; when the provider's CLI is missing it offers to install it (Homebrew, or the provider's installer), and when you're not signed in it runs the provider's own login (fly auth login, railway login) in your browser. forge ci (and forge deploy, at the end) offers to put the app on your GitHub with the GitHub CLI: git init/commit if needed, gh repo create (private by default) or a push to your existing remote, and the secrets the deploy workflow needs via gh secret set (values through stdin): a Fly org deploy token is created for you; Railway's project token and Render's deploy hooks are pasted from their dashboards. Nothing is pushed or created without asking.
forge ci writes .github/workflows/ci.yml: install with the pnpm cache, fail if forge generate or forge db generate would change committed files, typecheck, unit tests (if any), prisma migrate deploy on a Postgres 17 service (plus Redis with the jobs plugin), forge test --ci, and Docker builds of both images (no push).
forge deploy <target> writes the platform config, .github/workflows/deploy.yml (deploys main after CI passes) and a DEPLOY.md with the exact steps, the variables the app needs and the OAuth callback URLs to register. All files are yours: they are created once and only recreated with --force.
| Target | Files | What the command does |
| --- | --- | --- |
| fly | fly.api.toml, fly.web.toml (+ fly.worker.toml) | creates the apps, a private Flycast address for the API, Managed Postgres (fly mpg create/attach) or your URL, stages secrets through stdin, deploys API (migrations as release_command) → web → worker |
| railway | railway.api.json, railway.web.json (+ worker) | adds Postgres/Redis and the services, sets variables with references (${{Postgres.DATABASE_URL}}) — secret values one by one through --stdin, never on the command line — creates the web domain, railway up per service |
| render | render.yaml Blueprint | pushes to your GitHub (Render deploys from the repository), then prints the Blueprint link (managed Postgres, Key Value for jobs, generated BETTER_AUTH_SECRET) |
Health checks: /api/health (API) and /sign-in (web). Only the web app is public; it proxies /api/* to the API over the platform's private network. Secrets never land in files: BETTER_AUTH_SECRET is generated, ADMIN_EMAIL/ADMIN_PASSWORD and plugin secrets (STRIPE_*, S3_*) are asked for (or read from the environment with --yes), and email (SMTP_URL or RESEND_API_KEY) and social OAuth credentials are optional. Every external command is printed before it runs and anything billable asks first unless --yes; --dry-run only writes files and shows the plan. CI deploys need one repository secret: FLY_API_TOKEN, RAILWAY_TOKEN, or the Render deploy hook URLs.
AI agents
Tekki Forge ships a skill (the open Agent Skills format) that teaches coding agents to build apps with it: designing the config from a description, creating and running the app, permissions patterns, extensions, tests and deploys. It lives in skills/tekki-forge and inside the CLI:
forge skill install Claude Code (~/.claude/skills); --codex for OpenAI Codex, --agents for ~/.agents/skills
forge skill install --project into the current project instead (commit it so your team's agents get it)
forge skill zip tekki-forge-skill.zip to upload in claude.ai or ChatGPT (Skills settings)Every config in the skill is compiled by the test suite, and the skill was checked by giving it, alone, to fresh agents that built complete apps.
Developing the framework
pnpm install
pnpm build # builds every package (the CLI also bundles starters/default)
pnpm test # compiler, generators (incl. `prisma validate`), permissions, query parsing, validation, CLI config editingWhen the CLI runs from this checkout, forge new packs the local packages into the new app's .forge/vendor/ and pins them with pnpm.overrides, so generated apps (and their Docker builds) work before anything is published to npm.
See tests/e2e/ for browser walkthroughs against a running example.
Stack (pinned)
TypeScript 6 · pnpm + Turborepo · Next.js 16 · React 19 · Tailwind CSS 4 · NestJS 12 · Prisma 7.10 (pinned; Prisma 8 is still a release candidate) · PostgreSQL 17 · Better Auth 1.7 · Zod 4 · TanStack Query · Vitest.
Updating apps created with an earlier version
forge generate refreshes framework-owned files. Starter files you own were created once, so apps made before these features need two small edits to get the new screens:
apps/web/app/providers.tsx— passlocale={locale}andmessages={messages}(from../forge.locales) to<ForgeProvider>apps/web/app/layout.tsx— resolve the language withresolveLocale(app, cookie, acceptLanguage)and pass it to<Providers>/<html lang>; add<ThemeHead app={app} />for fonts and the logoschema editor: copy
apps/web/app/forge-dev/and addtekki-forgetoapps/webdevDependenciesforge test: addvitesttoapps/apidevDependencies and"test": "vitest run"(the test files are generated)slow-query log:
packages/database/src/index.tsforge apikey: the first run addsapps/api/src/scripts/apikey.tsand itsapikeyscript toapps/api/package.jsonlanding page: copy
apps/web/proxy.tsandapps/web/app/(public)/welcome/page.tsx, then setlanding: trueemails with an absolute logo URL:
apps/api/src/auth.tspassesbaseUrl: process.env.PUBLIC_URL ?? process.env.BETTER_AUTH_URLwebsite (
site): copyapps/web/proxy.ts,apps/web/app/(public)/site/,apps/web/app/site-feed/,apps/web/app/sitemap.tsandapps/web/app/robots.tsadministration area: move
apps/web/app/(app)/admin/toapps/web/app/(admin)/admin/and add(admin)/admin/layout.tsx(<AdminShell>) and(admin)/admin/page.tsx(<AdminDashboardPage />);(admin)/admin/[page]/page.tsxrenders<AdminPage path={admin/${page}} />. Without the move,/admin/*pages still work inside the app shell, and the new app pages (/settings,/team,/integrations,/usage,/support) need no file. Optionally makeapps/web/app/(app)/account/page.tsxredirect("/settings").
Compare with starters/default/. Then forge generate and forge db migrate (new saved_views, system_settings, support_tickets, support_messages tables, payment_invoices and subscription price columns with the payments plugin, and query indexes).
Roadmap
Not yet implemented:
- The natural-language schema architect (LLM → Forge schema → deterministic compiler)
