.offrails/generators/new.md
Generator: new (rails new)
TL;DR
- Run only after the interview and a confirmed plan (which includes the parts: the template plus the samples that fit, proposed by you, confirmed by the human).
- Generate from the template, never retype it:
cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir <dir>/target/offrails-gen -- new <dir> <crate_name> "<Display Name>" --pitch "<one line>" --parts <a,b>. It copiestemplate/app, renames it, vendors.offrails/, adds the parts andgit inits, in seconds, on Linux, macOS and Windows.- Then fill the
AGENTS.mdheader andproduct/from the interview, prove it green on SQLite and Postgres, run it, and make the one baseline commit. Write only the app's own code after that.Read it all, in order, whenever you create an app.
The framework ships the app skeleton as real files: template/app/ is a complete, compiling app (htmx) whose kernel, layout, error pages, assets, scripts, CI, Docker setup and tests were built from these guides and proven on SQLite and Postgres. Retyping it from Markdown cost an agent 50+ minutes and still drifted, so you copy it. From the moment it's copied, the code is the app's: you read it, adapt it and own it like code you wrote; humans still write none of it (doctrine §7).
Lean base, parts on demand. The base has no job queue, mailer, accounts or realtime. Those are parts, the tested samples, and the app gets the ones its product needs: at generation with --parts, or any time later with cargo offrails add-part <name>, by the same code path. A blog without background jobs doesn't carry a job queue. dev-dashboard fits every app.
0. Preconditions
-
interview.mdRounds 1–2 are done (or the human said "you decide"), and the plan is confirmed, including the parts (interview). - You know: the crate name (snake_case; make it distinctive, since other apps on the machine may be stopped by binary name), the product name (
config::APP_NAME, human-readable), a one-line pitch,FRONTEND_MODE,UI_LIBRARY,ISLANDS,API,PROD_DATABASE,DEPLOY_TARGET,ACCOUNTS,EMAIL, and the parts. - Tools:
rustc/cargostable,cargo-nextestandcargo-watch(cargo install --locked <name>for any that are missing),sea-orm-cli(its major must matchsea-orm's) for later migrations.sqlite3is used byapp db console. - TS modes (
inertia/spa, orISLANDS: yes): Node 24 LTS (22.22 is the hard floor) and pnpm viacorepack enable pnpm(stack). Any mode, for the optional browser smokes: Node ≥ 22.12. - Docker is optional. It's only needed to test Postgres locally and to check the image builds.
1. Generate
$FW is the framework you're reading: the plugin root when run through /offrails:new, ~/.offrails/current after the one-line install, or the path the human gave you. Vendor a release, never a moving working tree. A release install (the plugin, ~/.offrails/current, a tag export) is copied as it is. When $FW is a git checkout, new (and vendor) copy HEAD's committed files (git archive HEAD, the way the release tarball is built), never the working tree, say so, and record the commit as .offrails/VERSION's third line: from git v0.7.3 (<sha>) at the release tag, or from git <sha> (unreleased: HEAD is not the v0.7.3 tag; VERSION names the last release) on a branch before tagging, where VERSION still names the previous release (the first line, and so the FRAMEWORK header, say that version; the third line is the truth: say it in your final message). To vendor a release from a checkout, check out its tag first. --worktree copies the working tree as it is, uncommitted changes included (the framework's own CI; never for an app you hand over).
cargo run -q --manifest-path "$FW/tools/offrails-gen/Cargo.toml" --target-dir <dir>/target/offrails-gen -- \
new <dir> <crate_name> "<Display Name>" --pitch "<one-line pitch>" --parts <comma-separated parts, or none>
# options: --prod-db postgres|sqlite (default postgres), --mode htmx|inertia|spa|api (§4), --no-git, --offline,
# --invite (ACCOUNTS: … invite: DEFAULT_SIGNUP = Signup::Invite; needs auth-password or auth-magic-link)--target-dir keeps the generator's build out of $FW (the plugin's files are compared by content, and a build there reads as drift). On Linux, macOS or Git Bash, sh "$FW/tools/new-app.sh" <dir> <crate_name> "<Display Name>" … is the same command.
What it does, so you can check it: copies template/app/ into <dir> (refusing a non-empty directory), replaces the template's tokens (offrails_app, offrails-app, Offrails App, the pitch), vendors $FW into <dir>/.offrails/ without /.git, /.claude-plugin, /skills, /hooks, /evals, /brand, /.in_use and /media, writes .offrails/VERSION (version, then vendored <UTC date>), writes AGENTS.md and product/README.md from the blueprints, logs app generated from the … template in docs/decisions.md, adds each part with its dependencies (cargo add per part's crate; that needs the crates.io index), runs cargo fmt --all, and git inits without committing. It prints the next commands.
The plugin's skills/ and hooks/ stay out of the app: they're loaded from the installed plugin, which works for every app at once. hooks/fingerprint.sh compares .offrails/ with the plugin by content using the same exclusions.
2. Agent & product files
The template already has CLAUDE.md (@AGENTS.md), .cursor/rules/offrails.mdc, .github/copilot-instructions.md and docs/decisions.md (the template's own decisions, which the app inherits). Fill in:
| File | From |
|---|---|
AGENTS.md | Written from blueprints/AGENTS.md with the template's defaults (htmx, API: none, ACCOUNTS: none, TENANCY: none, and EMAIL/ACCOUNTS set by the parts). Set every header line from the interview; never one the code doesn't match yet (TENANCY changes when the tenancy suites exist: the first owner-scoped cargo offrails g resource writes them, authentication) |
product/README.md | Written from blueprints/product-README.md with the name filled; write the rest from the interview |
product/features/ | one spec per feature from the confirmed plan (blueprints/feature-spec.md). They're built after step 5 |
static/ brand assets | new renders them from brand.toml (cargo offrails brand: headless Chrome, else rsvg-convert; with neither the PNGs keep the template's look and it says so: log that in docs/decisions.md). Set the colors, fonts and logo in brand.toml and re-run |
Design: follow any references from the interview. Otherwise keep the template's clean, neutral Tailwind design.
3. Parts
The parts were chosen in the plan (template). If you generated with --parts, they're in. Otherwise, or for one the plan added later:
cargo offrails parts # installed vs available, what each adds
cargo offrails add <name> [<name>…] # dependencies first; prints what it changed and what to run nextThen cargo run -q -- db migrate (parts with tables; docs/routes.md is already refreshed), and read each part's README.md ("After installing", "Adapting it") and CHECKS.md in .offrails/template/parts/<name>/. Adapt the // APP: lines (names, copy, limits) to this app; keep every // SECURITY: and // INVARIANT: line and the tests they name (samples).
ACCOUNTS isn't none: accounts are the auth parts (auth-core with auth-password and/or auth-magic-link, auth-totp for two-factor; invite-only is new --invite or DEFAULT_SIGNUP, not a part), added here like any other. authentication.md is what they leave to you: APP_SECRET_KEY, paths::after_sign_in(), your scoped handlers, the browser smoke's scenario, roles and teams.
4. Frontend mode
htmx(default): nothing to do; the template is htmx.api,inertia,spa:new --mode <mode>generated the htmx template (the kernel,/dev, tests and CI every mode shares) and logged the requested mode. Convert it per template → other modes and the reference below (other modes), and set the header once the code matches.
5. Verify & report
-
bin/setup(creates.env, builds the CSS, migrates and seeds the dev DB). Windows: template → Windows. -
cargo offrails verify --fullis green: the Definition of Done, withoffrails-check, on SQLite and Postgres at once. Without a local Postgres, rundocker run -d --name pg -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:<major>first (verify finds it on 5432), or setTEST_DATABASE_URL. The first build compiles the whole stack (2–4 min on an M2; a second app on the same machine is no faster unless you opted into sccache, testing); after that,cargo offrails verifytakes seconds. -
Prove the migrations are reversible on both databases:
cargo run -- db rollback --steps <n>thencargo run -- db migrate. For Postgres, setDATABASE_URLto a scratch database. -
Run the app:
cargo run -- serve, then:curl -i localhost:3000/upreturns 200;/shows digested/static/...?v=URLs, and fetching one returnscache-control: …immutable;/devreturns 200; with thedev-mailboxpart,curl -X POST -H 'Sec-Fetch-Site: same-origin' localhost:3000/dev/mailbox/testthencurl -s localhost:3000/dev/mailbox/latest.jsonshows the test email;curl -X POST -H 'Origin: https://evil.example' localhost:3000/returns 403;curl -s -o /dev/null -w '%{http_code}' -H 'Host: evil.test' localhost:3000/devprints 403, andlsof -nP -iTCP:3000 -sTCP:LISTENshows127.0.0.1:3000(development is this machine only);- the fresh-database smoke: an empty database shows the pending-migrations page (503), never a boot crash.
In
inertia/spa,bin/devruns Vite next to the server. Openhttps://localhost:3000(not :5173) and check that the browser console is clean. Thenpnpm --dir web buildandAPP_ENV=production … serve: the shell links/assets/…from the manifest, and/assetsresponses areimmutable. -
Optional, when Node ≥ 22.12 and Chrome are installed:
BASE=http://localhost:3000 bash .offrails/tools/browser-smoke/run.sh(dev-tools). -
If Docker is available:
docker compose up --buildfrom a clean checkout bringsappanddbtohealthy,curl localhost:3000/upanswersok, thendocker compose down -v. -
git add -A && git commit -m "new: <app_name> skeleton". This is the one commit you make unprompted, because it's the baseline. -
Then build the features, one vertical slice at a time, after the spec. Start each piece from a snippet or a generator when one fits (both tested, both yours once copied):
cargo offrails snippets/snippet show <name>/snippet add <name>(snippets),cargo offrails g resource …(ormodel,controller,page,api,job,mailer,channel;cargo offrails g --help; generators); commit after each run; hand-write the rest from the guides, in the same shape (small files per feature, one marked line per shared spot). Then the// APP:lines and the spec's tests. -
Tell the human, in product terms: what exists, which parts are in (and that more can be added any time), how to run it (
bin/setup && bin/dev), that http://localhost:3000/dev has the dev tools (and the mailbox, jobs dashboard and dev dashboard when those parts are in), and what you'll build next from the plan.
What the template contains
Read this when you change a kernel file, port a fix from a newer template, or convert to another mode. Each item points at the guide that is its spec. The files themselves are in .offrails/template/app/ (and in your app).
Workspace & dependencies
-
Cargo.toml: the app plus themigrationcrate as a workspace,publish = false,[profile.dev.package."*"] opt-level = 1,[profile.dev] debug = "line-tables-only"(faster rebuilds;CARGO_PROFILE_DEV_DEBUG=truefor a debugging session; Linux'ssplit-debuginfois in.cargo/config.toml).Cargo.lockis the tested resolution;cargo updateis an ordinary change. -
migration/:sea-orm-migrationwithdefault-features = falseand["runtime-tokio-rustls", "sqlx-sqlite", "sqlx-postgres"](no CLI runner: the app binary hasdb migrate|rollback|status), norust-versionline (the MSRV-aware resolver would downgrade dependencies), and the migrations list as a multi-linevec![…], the only shapesea-orm-cli migrate generateregisters into. One migration:create_cache_entries(caching). Parts bring their own tables. -
Crates: the htmx set from stack, added with
cargo add. More arrive with the feature or part that needs them. For other modes and APIs:# TS modes (inertia / spa / ISLANDS: yes): cargo add ts-rs --features chrono-impl,serde-json-impl # API: rest | both (guides/api.md), and CORS for any API: cargo add utoipa --features axum_extras,chrono cargo add tower-http --features cors # API: graphql | both (guides/graphql.md): cargo add async-graphql --features chrono,dataloader cargo add async-graphql-axumcargo addaccepts several crates in one call only when no--featuresflag is given. In zsh, don't loop over a quoted list (it doesn't word-split); onecargo addper line.
Rust skeleton
Module order (each compiles against the ones before it): config → kernel → error/models → state → views → controllers → routes → tasks → main.
src/config.rs:Config,AppEnv,LogFormat,bind_address(APP_BIND: 127.0.0.1 in development),parse_allowed_hosts,boot_warnings,Config::test(),resolve_app_url, and the lean runtime settingsAPP_WORKER_THREADS(2) andAPP_BLOCKING_THREADS(16) (config).src/kernel/db.rs:connect(SQLite: WAL, foreign keys, busy timeout, a 1 MiB page cache, a pool of 4 by default; Postgres: a pool of 10),begin,ping, and the Postgres test harness's DDL (create_database_from, advisory locks,install_test_reset/reset_test_database,drop_idle_test_databases; testing) (database).src/error.rs:AppError(withUnavailable→ 503 +Retry-After),ResponseFlavor,shape_errors,retry_after(errors-and-logging).src/models/mod.rs:Errors,Page(models).- The kernel:
security.rs(the header matrix and CSP, security),hosts.rs(development-only Host authorization, security),flash.rs,cookie.rs,rate_limit.rs,assets.rs+css.rs(views),tls.rs(HttpToHttps, deployment),introspect.rs(dev-tools),cache.rs,redact.rs(errors-and-logging),theme.rs(the System/Light/Dark switch),ui.rs(kernel chrome:Chrome,Chrome::stylesheet/script,KernelFlash,no_store()). CSRF is tower-http'sCsrfLayer, configured inroutes.rs(security). In TS modes addinertia.rsand its pieces (frontend-inertia).- Parts, not base: the mailer and dev mailbox (
mailer,dev-mailbox), the job queue and/admin/jobs(jobs,jobs-dashboard), sessions and accounts (auth-*), channels and pubsub (realtime),/dev/dashboard(dev-dashboard). Their guides (mailers, jobs, jobs-dashboard, auth, channels, dev-tools) remain their spec.storagearrives with the first attachment field (storage).
- Parts, not base: the mailer and dev mailbox (
src/state.rs:AppState { config, db, cache, assets, rate_limit }plus what parts add, built byAppState::new(config, db); tests build it the same way.src/views/mod.rs:render,Ctxwith the page meta (title,description,path,app_url; views),views/helpers.rs,views/home.rs,views/errors.rs.src/controllers/:application.rs(theCtxextractor,HxRequest,AdminAccess),home.rs,health.rs(/up, 503 while migrations are pending),meta.rs(/robots.txt,/sitemap.xml),theme.rs(POST /theme),errors.rs(the fallback),dev.rs(/dev, routes, system, introspection, reload) andmigrations.rs(the pending-migrations guard, dev-tools). Actions carry#[tracing::instrument(skip_all)](errors-and-logging).src/routes.rs(routing): the routes,/staticwith cache headers, the fallback, the middleware stack (kernel::hosts::middlewareright inside the security middleware;TimeoutLayer::with_status_code(StatusCode::SERVICE_UNAVAILABLE, 30s)),paths, andROUTESwith anAccesslevel for every route;dev::router()merged only in development, then themigrations::guardlayer. Each kernel router ends with.layer(kernel::ui::no_store()).src/tasks/:seed.rs(idempotent per record, in one transaction: cli) andtest_db_cleanup.rs.src/main.rs: the CLI fromcli.md; a runtime built fromConfig(worker and blocking thread caps);serveonconfig.bindwithinto_make_service_with_connect_info::<SocketAddr>(), graceful shutdown on Ctrl-C or SIGTERM (Ctrl-C only on Windows), plain HTTP or direct TLS (h2 + http/1.1 via ALPN, deployment);app healthfor the container healthcheck.
Anchors
Shared files carry one-line comments where a part inserts its wiring: // samples: <anchor> in Rust, {# samples: <anchor> #} in templates, # samples: <anchor> in .env.example and compose.yaml. cargo offrails add-part (and the samples repo's tools/install.sh) inserts above them. Each is on a line of its own; keep them, they cost nothing (why).
| File | Anchors (where) |
|---|---|
src/lib.rs | lib mods (after the pub mod lines) |
src/kernel/mod.rs | kernel mods |
src/kernel/ui.rs | kernel tabs (in the kernel header's tab list), hidden tabs (a list of tab keys a part replaces) |
src/kernel/introspect.rs | introspect sections (in snapshot, before it returns: out["x"] = …;) |
src/kernel/redact.rs | token routes (a route pattern whose {token} segment is a secret) |
src/kernel/security.rs | dev csp (extra CSP directives in development only) |
src/config.rs | config fields (end of Config), config debug (end of the Debug chain), config parse (in from_env, before the missing/invalid check), config init (end of the Config { … } literal in from_env), config test (end of Config::test()'s literal), config sections (end of the file, outside any impl) |
src/state.rs | state fields, state build (in AppState::new, before the struct literal), state init (end of that literal), state fns (end of impl AppState) |
src/routes.rs | route mods (after the use lines: pub mod <feature>;, one per src/routes/<feature>.rs), routes (after the last app .route), dev routers (inside the development-only merge), outer layers (just inside SetRequestIdLayer), paths (end of mod paths), route tables (end of TABLES: <feature>::ROUTES,), routes table (end of APP_ROUTES, the kernel's and the parts' rows) |
src/controllers/mod.rs, controllers/application.rs | controller mods; ctx init (in the Ctx extractor's literal), extractors (end of file) |
src/controllers/dev.rs | dev routes (in router()), dev tiles (end of the /dev tile list), dev try-it (end of the Try-it buttons), system facts (end of /dev/system's facts) |
src/controllers/meta.rs | sitemap paths (end of the public paths /sitemap.xml lists) |
src/views/mod.rs, src/models/mod.rs, src/tasks/mod.rs | view mods, ctx fields (end of Ctx); model mods, model aliases; task mods, tasks (in the task match) |
src/main.rs | cli commands (end of the Command enum), cli dispatch (the first line in the match: rustfmt pulls a comment after a last arm ending in } onto that brace), serve start (in serve, after state is built), serve stop (in the shutdown future), tracing layers (the layer list in init_tracing) |
migration/src/lib.rs | migration mods, migrations (end of the vec![…], after the last Box::new) |
static/app.js | app js (end of file: one delegated listener per behavior) |
templates/layouts/application.html, templates/shared/_nav.html | app head (in <head>, after app.js: a part's scripts and stylesheets, loaded on every page because boosted links run nothing in a page's head block), dev footer links; nav links |
templates/kernel/layout.html | kernel head (last line inside <head>), kernel main (first line inside <main>, before the content block) |
tests/app/main.rs, support.rs, factories.rs | test mods; test support (end of impl TestApp); factories |
tests/app/security_test.rs, kernel_web_test.rs, theme_switch_test.rs | router sources; no-store paths; kernel pages |
.env.example, compose.yaml | env (end of file); compose env (end of the app's environment) |
Generators add their own anchors to the files they write (samples: columns, relations, related in a generated model's mod.rs; model test mods in tests/app/models/mod.rs; tenant fields, tenant records, tenant init, tenant fill, tenant unchanged, tenant expected, not tenant ids in a generated tests/app/tenancy_test.rs; page mods, page view mods, page test mods in the shared pages modules; api paths, api schemas in src/controllers/api.rs), and use the template's for the rest (generators). Every line a generator or a snippet inserts carries its owner's marker (// posts, {# posts #}), so it's found, and removed, by name.
After cargo fmt --all, none may trail code: grep -rnE '[^[:space:]].*(//|\{#|#) samples: ' src templates migration tests | grep -vE ':[[:space:]]*(//|\{#|#) samples: ' prints nothing.
Frontend
- Templates: every class in both color schemes (views;
tests/app/theme_test.rsenforces it);templates/layouts/application.html(thehtmx-configmeta from frontend-htmx,data-themefrom the theme cookie,dev-reload.jsin development),templates/shared/{_flash,_errors,_nav}.html,templates/home/index.html, the standalone error pagestemplates/errors/{404,403,429,500,503}.htmland the_invalid.htmlfragment, andtemplates/kernel/(layout,/devpages). - Assets:
assets/app.css(Tailwind 4, thedarkvariant that follows the theme switch, the component layer),static/app.js,static/dev-reload.js(dev-tools),static/kernel/admin.{css,js}, htmx and its sse extension instatic/vendor/(frontend-htmx), the brand (brand.toml→assets/brand.css,src/brand.rs,favicon.svg,favicon.ico,apple-touch-icon.png,icon-512.png,og.pngfromassets/brand/og.svg), the five layouts (templates/layouts/), the component kit (templates/components/ui.html,static/components.js) and its gallery at/dev/components. - Links into kernel pages carry
hx-boost="false"(dev-tools).
Scripts & ops files
| File | Content |
|---|---|
.gitignore, .dockerignore, storage/.keep | Build output, .env, storage/*, static/app.css, bin/tailwindcss, web/node_modules, web/dist |
.gitattributes | * text=auto eol=lf: LF everywhere, so a Windows checkout keeps rustfmt, anchors and tests green |
.cargo/config.toml | The cargo offrails alias (the vendored generators) |
.env.example | Every Config var with dev defaults (config) |
bin/setup-tailwind, bin/setup | Pinned Tailwind, verified download; first run: tools, .env, CSS, db setup |
bin/dev | HTTPS + HTTP/2 only with a locally trusted certificate (bin/dev-cert), else plain HTTP; cargo watch server + Tailwind watcher; one PID file per port; stops only its own tree (dev-tools) |
bin/dev-cert, bin/docker-entrypoint | mkcert or nothing (why); migrate unless SKIP_MIGRATIONS=true, then exec "$@" |
compose.yaml, Dockerfile | The whole app, production-shaped, on Postgres; profiles worker and mail (deployment, Dockerfile) |
.github/workflows/ci.yml | SQLite (with offrails-check first), Postgres, security (cargo-deny + gitleaks) and Docker (boots the production image, ZAP baseline) jobs (testing) |
deny.toml, .gitleaks.toml, .zap/rules.tsv, .github/dependabot.yml | security |
Deploy config only for the chosen DEPLOY_TARGET (deployment); none for "decide later".
Tests
One binary, tests/app/ (testing): main.rs (#[allow(dead_code)] mod support;), support.rs (TestApp, TestResponse, the query counter, the Postgres template and reset slots), factories.rs (empty; each model adds one), and the kernel suites:
smoke_test.rs: home page and layout,/up, the 404 page and/apiJSON 404, security headers,foreign_keys = 1, a flash rendered once, a stable query count, meta and brand assets,/robots.txtand/sitemap.xml;headers_test.rs: the header matrix per response class, development and production (security);security_test.rs: everyROUTESentry is routable, non-public routes are never 2xx anonymously,Devroutes 404 in production, cross-site and foreign-OriginPOSTs get 403, development refuses a reboundHost(test-standards §3);errors_test.rs,logs_test.rs(tokens never reach logs),kernel_test.rs(cache, rate limiter, flash cookie,DbErrmapping,Retry-After),kernel_web_test.rs(/dev, introspection,no-store, the pending-migrations page and/up503),theme_test.rsandtheme_switch_test.rs(test-standards §11); unit tests inkernel/tls.rs,kernel/hosts.rs,config.rs.
Each part brings its own suites (tests/app/<part>_test.rs). tests/app/guard_test.rs and tenancy_test.rs arrive with the first owner-scoped cargo offrails g resource (or by hand, test-standards §4) when TENANCY isn't none.
Other modes
api: no app templates, noassets/, no Tailwind (bin/setup-tailwindand the Dockerfile's Tailwind line go), noweb/. Keep the kernel'stemplates/kernel/, the standalonetemplates/errors/pages,static/kernel/and the vendored htmx they use.views/holds JSON bodies (#[derive(Serialize, ToSchema)], aPageInfoenvelope);controllers/root.rsanswersGET /with a JSON index and/robots.txtwithDisallow: /; errors are JSON everywhere except/devand/admin;controllers/api/mod.rs(ApiJson,ApiDoc,ErrorBody,/dev/api-docs) forAPI: rest | both(api).smoke_test.rschecks the JSON index, robots and JSON 404s instead of pages; notheme_test.rs.inertia:pnpm create vite web --template react-ts, then the "Vite wiring" section of frontend-inertia exactly (including.cargo/config.toml'sTS_RS_EXPORT_DIRandTS_RS_LARGE_INT,test-setup.ts, andapp.csswith the progress-bar CSS);SharedProps+type Inertia,home::indexrendering"home/Index"; the HTML shell layout;cargo test --lib export_bindingsbeforepnpm --dir web typecheck. Svelte: frontend-svelte#inertia-mode.spa: the same Vite scaffold per frontend-react; Svelte: SvelteKit SPA per frontend-svelte#spa-mode-sveltekit.ISLANDS: yes:web/in islands configuration (frontend-htmx#islands).