You are here:Setup Ruby, cc, SQLite
Requirements
WhyCyberTrain compiles through Spinel to C, so it needs a C toolchain and the SQLite headers; the cybertrain command is a gem, so it needs Ruby.
- Ruby 3.2+ and RubyGems, for the
cybertraincommand only; applications never run on CRuby. - Spinel
2026.09.12, untested against other versions. You do not install it by hand: the first command that needs it builds that release into~/.cybertrain(a few minutes, once), unless aspinelof that release is already onPATH. - A C toolchain (
cc, or the compilerCCnames) andmake, plusgitandcurl, to build Spinel, which compiles every program to C. - SQLite 3 headers and library:
libsqlite3-devon Debian/Ubuntu; present with Xcode's command line tools on macOS. - OpenSSL headers (
libssl-dev) are optional: without them Spinel builds without itsopensslpackage, which CyberTrain does not use.
cybertrain doctor checks these tools and headers and the Spinel install; when a required one is missing it also prints the install command for your platform.
How CyberTrain installs SpinelWhat happens the first time a command needs spin
cybertrain new, db, server, build and spin first look for Spinel 2026.09.12: a spinel and spin of that release already on PATH, else the copy under ~/.cybertrain/spinel/2026.09.12/, else they build that copy from the Spinel release tag (git clone, make deps, make -j, make install), once, in a few minutes. The log is ~/.cybertrain/log/spinel-2026.09.12-build.log; if a tool the build needs is missing, cybertrain says which and prints the install command for your platform before it starts.
cybertrain setup does the same on request (--force rebuilds the copy under ~/.cybertrain) and, when its own copy is the one in use, prints the line that puts it on your PATH, for running spin yourself:
export PATH="$HOME/.cybertrain/bin:$PATH"
~/.cybertrain/bin holds spinel and spin links to the release in use. Without it on PATH, cybertrain spin ARGS... runs spin with that release from any directory. CYBERTRAIN_HOME replaces ~/.cybertrain, and CYBERTRAIN_SPINEL_HOME=PREFIX uses the Spinel installed in PREFIX instead (it must be that release). A spinel of another release on PATH is never used or touched.
No machine to install on? The playground opens this blog in a GitHub Codespace with steps 02, 03, 04 and 07 already done.
You are here:Setup the cybertrain CLI
Install the CLI
Whycybertrain is the command that creates apps, scaffolds resources, migrates, serves and builds.
The first line installs it from RubyGems. The rest is the whole tutorial at a glance; the steps below take it one command at a time.
gem install cybertrain
cybertrain new blog # installs Spinel 2026.09.12 the first time (a few minutes)
cd blog
cybertrain g scaffold article title:string body:text
cybertrain db migrate
cybertrain server # http://127.0.0.1:3000
cybertrain build # production: dist/blog, dist/public/, dist/storage/, dist/tmp/
The cybertrain command is plain Ruby and the gem runs it under CRuby (3.2 or newer); neither the framework nor Spinel is in the gem. Its commands (cybertrain help prints them):
cybertrain new NAME [--path DIR | --version V | --git URL [--ref R]] [--skip-spin]creates an application in NAME, then runsspin lockandspin run genin it, installing Spinel first when it is needed.cybertrain generate scaffold NAME field:type ...(aliasg scaffold) adds a resource to the application in the current directory.cybertrain db migrategenerates, applies pending migrations, and generates again (spin run gen; spin run db -- migrate; spin run gen);cybertrain migrationis kept as an alias.cybertrain db COMMAND...runs any other database command afterspin run gen:status,rollback [N](which generates again afterwards),schema:dump,create.cybertrain server [PORT]generates, then starts the development server (spin run gen; spin run NAME, orspin run NAME -- PORT; default port 3000).cybertrain buildbuilds NAME withapp/views/embedded and assemblesdist/(the binary,public/,storage/,tmp/).cybertrain spin ARGS...runsspinwith the pinned Spinel, from any directory:cybertrain spin test,cybertrain spin run gen -- --check.cybertrain setup [--force]installs Spinel2026.09.12into~/.cybertrainunless aspinelof that release is already onPATH;--forcerebuilds the copy under~/.cybertrain.cybertrain doctorchecks the C toolchain, the SQLite headers and the Spinel install.cybertrain versionprints the version (cybertrain 0.2.1);cybertrain helpprints the usage.
db, server and build run inside an application; NAME is the [package] name in its spin.toml.
You are here:You write blog/
Create the application
WhyYou get the Rails directory conventions plus three bin/ programs: bin/blog.rb (the app), bin/gen.rb (the generator) and bin/db.rb (development migrations).
cybertrain new blog
cd blog
cybertrain new writes the application with its spin.toml pointing at the release matching the CLI:
[package]
name = "blog"
version = "0.1.0"
[dependencies]
cybertrain = { git = "https://github.com/saeki-mototsune/cybertrain", ref = "v0.2.1" }
Then it makes sure Spinel is installed (the first time, that means building Spinel 2026.09.12 into ~/.cybertrain: a few minutes, once; see step 00) and runs spin lock (spin fetches the framework into its cache, ~/.cache/spin/packages/, and pins the commit in spin.lock) and spin run gen, much as rails new runs bundle install, so cybertrain server works right away. It ends by printing the next steps. Nothing of the framework is copied into the app; commit spin.toml and spin.lock.
If Spinel or one of the two spin steps fails, the files stay, new exits with 1 and prints what to run once the cause is fixed (for Spinel: cybertrain setup, then cybertrain spin lock && cybertrain spin run gen in the new directory). --skip-spin only writes the files; --path DIR depends on a local checkout instead, --git URL [--ref R] on the framework at URL (at branch or tag R), and --version V on an index version once cybertrain is on a spin-index.
App names use lowercase letters, digits and _, starting with a letter. The generated tree:
blog/
spin.toml config/app.rb config/routes.rb
db/schema.rb db/migrate/
app/controllers/application_controller.rb app/models/ app/views/ app/helpers/
public/ bin/blog.rb bin/gen.rb bin/db.rb
gen/ storage/ tmp/ test/
bin/blog.rb is the application's one entry point, the program spin build blog compiles: ./blog serves, ./blog migrate and ./blog db status manage the database. bin/gen.rb runs the generator (spin run gen), and bin/db.rb runs the migrator during development (cybertrain db migrate uses it, since bin/blog.rb cannot compile until the first migration has generated gen/models/). gen/ already holds views.rb, the committed, empty table of embedded views.
ApplicationController starts like this, so a missing record maps to a 404 through rescue_from:
class ApplicationController < Cybertrain::Controller
rescue_from Cybertrain::RecordNotFound, with: :record_not_found
private
def record_not_found
render plain: "Not Found", status: :not_found
end
end
You are here:You write app/, db/migrate/
Scaffold an article
WhyOne command writes the whole CRUD slice, Rails-style.
cybertrain generate scaffold article title:string body:text
It writes a migration, a model, a controller and five views (index, show, new, edit, _form), and inserts resources :articles into config/routes.rb. Field types are string, text, integer, float, boolean, date, datetime and references; the default is string. The quick start's cybertrain g scaffold is the same command.
Existing files are never overwritten: each file is reported as create, identical or exist.
You are here:You write config/routes.rb
Point the root route
Why/ should show the article list.
# config/routes.rb
Cybertrain::Routes.draw do
root "articles#index"
resources :articles
end
Routes are read at build time: spin run gen turns them into gen/routes.rb. cybertrain server, cybertrain db migrate and cybertrain build run it for you.
You are here:You write app/models/
Add model validations
WhyAs in Rails' guide, add a length validation by hand.
# app/models/article.rb -- only the first string field gets a default
# presence validation; add anything past that yourself.
class Article
validates :title, presence: true
validates :body, presence: true, length: { minimum: 10 }
end
class Article has no superclass written: the Cybertrain::Model superclass, columns and finders come from gen/models/article.rb. A failed save reports messages such as "Title can't be blank" and "Body is too short (minimum is 10 characters)".
You are here:You write app/controllers/
The ArticlesController
WhyFull CRUD, Rails-shaped down to strong params and flash-then-redirect, except for two things Spinel forces everywhere.
# app/controllers/articles_controller.rb (index/show/edit are as plain as
# Rails' own scaffold; update mirrors create)
class ArticlesController < ApplicationController
before_action :set_article, only: [:show, :edit, :update, :destroy]
def new_action # a `new` method would shadow ArticlesController.new
@article = Article.new
end
def create
@article = Article.new(article_params)
if @article.save
flash[:notice] = "Article was successfully created."
redirect_to article_path(@article), status: :see_other
else
render :new, status: :unprocessable_entity
end
end
private
def set_article
@article = Article.find(params[:id])
end
def article_params
params.require(:article).permit(:title, :body)
end
end
The scaffold writes the full controller with index, show, edit, update and destroy too; the excerpt above omits them. In index, @articles = Article.all.to_a: the .to_a is required.
Instance variables are found by a lexical scan of @ivar =, so re-run cybertrain spin run gen after adding one; the development server does this for you.
You are here:Build time, then run time gen/ → build/bin/blog
Generate, migrate, run
WhyTurn schema and routes into gen/, create the SQLite database, start the server.
cybertrain db migrate # spin run gen; spin run db -- migrate; spin run gen
cybertrain server # http://127.0.0.1:3000; rebuilds on Ruby edits, views reload without a rebuild
spin run gen reads db/schema.rb and config/routes.rb, scans app/ and writes gen/; spin run db -- migrate applies db/migrate/*.rb and rewrites db/schema.rb. The order matters: the first spin run gen puts the new migration into gen/migrations.rb, which bin/db.rb requires, and the second builds the models from the rewritten db/schema.rb.
cybertrain server prints each spin command it runs, then the app's boot banner. The first start compiles the app, so it takes a while:
=> Booting cybertrain 0.2.1
=> development environment (1 worker)
=> Watching app/, config/ and db/schema.rb for changes
* Listening on http://127.0.0.1:3000
Use Ctrl-C to stop
- Re-run
cybertrain spin run genand commitgen/after touching the schema, routes, or a controller's or model's callbacks and ivars. The development server does this for you. - Change the port with
cybertrain server 4000(orPORT=4000). cybertrain dbadds the--thatspin run dbneeds before its arguments;cybertrain db statusand the other database commands work the same way.
You are here:You write db/migrate/, app/
Scaffold comments
WhyThe references type gives you the foreign key that the association is derived from.
cybertrain generate scaffold comment commenter:string body:text article:references
cybertrain db migrate # the scaffold wrote a migration for the comments table
article:references adds an article_id column, index and foreign key — enough for Article/Comment to get their association; there's no has_many/belongs_to to write, spin run gen reads it off the foreign key. The resulting schema:
s.create_table "comments" do |t|
t.string "commenter"
t.text "body"
t.integer "article_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
s.add_index "comments", ["article_id"], name: "index_comments_on_article_id"
s.add_foreign_key "comments", "articles", column: "article_id"
The scaffold inserts an independent resources :comments line, which the next step replaces.
You are here:You write config/routes.rb
Nest the routes
WhyRails' guide nests comments under articles.
Cybertrain::Routes.draw do
root "articles#index"
# A nested block takes the mapper explicitly (Spinel's instance_eval
# trampoline only rewires `self` for the outermost block): `articles.resources`.
resources :articles do |articles|
articles.resources :comments, only: [:create, :destroy]
end
end
You are here:You write app/controllers/
The CommentsController
WhyOnly two actions are reachable, and the parent article comes from the URL (params[:article_id]), not :id.
class CommentsController < ApplicationController
before_action :set_article
def create
@comment = Comment.new(comment_params)
@comment.article_id = @article.id
if @comment.save
flash[:notice] = "Comment was successfully created."
else
flash[:alert] = "Comment could not be saved: #{@comment.errors.full_messages.join(", ")}"
end
redirect_to article_path(@article), status: :see_other
end
def destroy
@comment = Comment.where(article_id: @article.id).find(params[:id])
@comment.destroy
flash[:notice] = "Comment was successfully destroyed."
redirect_to article_path(@article), status: :see_other
end
private
def set_article
@article = Article.find(params[:article_id])
end
def comment_params
params.require(:comment).permit(:commenter, :body)
end
end
Then add validations to the comment model:
class Comment
validates :commenter, presence: true
validates :body, presence: true
end
Article#comments is an Array here, so Rails' @article.comments.find(params[:id]) becomes Comment.where(article_id: @article.id).find(params[:id]). A comment under the wrong article returns 404.
You are here:Run time app/views/, read from disk in development, embedded by cybertrain build
Views and partials
WhyThe article page lists its comments and ends with a form bound to [@article, @comment].
First, delete the now-unreachable index, show, new and edit comment views, and give ArticlesController#show a blank @comment:
def show
@comment = Comment.new
end
Then render the comments and the form on the article page:
<%# app/views/articles/show.html.erb %>
<% @article.comments.each do |comment| %>
<%= render "comments/comment", comment: comment %>
<% end %>
<%= render "comments/form" %>
<%# app/views/comments/_form.html.erb %>
<%= form_with(model: [@article, @comment]) do |f| %>
<%= f.label :commenter %> <%= f.text_field :commenter %>
<%= f.label :body %> <%= f.text_area :body %>
<%= f.submit %>
<% end %>
The comment partial, as it ships in the example:
<%# locals: (comment:) %>
<div class="comment">
<p>
<strong>Commenter:</strong>
<%= comment.commenter %>
</p>
<p>
<strong>Comment:</strong>
<%= comment.body %>
</p>
<%= button_to "Destroy comment", [@article, comment], method: :delete %>
</div>
Both button_to ... [@article, comment] and form_with(model: [@article, @comment]) resolve through the same [parent, child] rule: a new child routes to the nested collection (article_comments_path), a persisted one to the nested member (article_comment_path) — generated into gen/routes.rb from the nested resources block above.
You are here:Build time cybertrain spin test
Test it
Whyminitest and RSpec find tests by reflection, which an ahead-of-time compiler cannot do, so CyberTrain ships Cybertrain::Test.
A model test
require_relative "support/blog_test" # the app, BLOG and Cybertrain::Test
test "title must be present" do
article = Article.new(body: "a body long enough to pass length")
refute article.save
assert_includes article.errors.full_messages, "Title can't be blank"
end
Assertions: assert, refute, assert_equal, assert_nil, assert_includes, assert_raises("Class") { }, flunk. The example also uses assert_response and assert_redirected_to.
A full HTTP flow
require_relative "support/blog_test" # the app, BLOG and Cybertrain::Test
test "POST /articles creates an article and redirects to it" do
client = Cybertrain::Test::Client.new(BLOG)
client.get("/articles/new")
res = client.post("/articles", { "authenticity_token" => BlogTest.form_token(client),
"article[title]" => "Hello Rails",
"article[body]" => "I am on Rails! This is my first article." })
assert_redirected_to res, BlogTest.article_path(Article.last)
assert_response res, :see_other
end
Cybertrain::Test::Client drives an app in-process (no socket) with a cookie jar, so sessions and CSRF behave as behind a browser. Requests without the token get a 403.
Run the tests
cybertrain spin test
cybertrain spin test runs spin test with the pinned Spinel; with the toolchain on PATH (step 00), spin test does the same. It compiles each test/*.rb into its own program and diffs its output (stdout and stderr together) against a committed test/<name>.rb.expected snapshot. Files in test/support/ are required, not run. To write a snapshot:
cybertrain spin test --regen test/<name>.rb
rm -f build/test/<name>; cybertrain spin test test/<name>.rb; [ -x build/test/<name> ] && ./build/test/<name> > test/<name>.rb.expected 2>&1
--regen writes the snapshot from CRuby's output. Tests that touch SQLite through FFI can't run under CRuby, so their snapshot comes from the compiled binary (the second line above). cybertrain spin test exits non-zero while a snapshot is stale, so it is followed by ;, not &&; the [ -x ] check skips the capture when the compile failed, so the old snapshot is kept. Tests that cannot run under CRuby need this snapshot before cybertrain spin test can pass; other tests without a snapshot are compared with CRuby's output.
You are here:Run time dist/blog
Build for deployment
WhyOne command builds the app into a deployable dist/ folder, with every view embedded.
cybertrain build
This writes dist/: the app binary with every view embedded, plus the files it uses beside it. It runs spin run gen -- --embed-views, spin build blog and spin run gen (which puts the committed, empty gen/views.rb back, even after a failure or Ctrl-C), then assembles:
dist/blogthe compiled framework, controllers, models and template interpreter, with every view embeddeddist/public/static assets and error pages, or let a reverse proxy serve itdist/storage/created empty; the SQLite file by default (a rebuild never empties it)dist/tmp/created empty (a rebuild never empties it)
Copy dist/ to a machine with the same OS and CPU (the binary links the system SQLite) and run it there. A built binary is production by default (CYBERTRAIN_ENV overrides). Set CYBERTRAIN_SECRET_KEY_BASE; for illustration:
cd dist
export CYBERTRAIN_SECRET_KEY_BASE="$(openssl rand -hex 32)"
./blog migrate && ./blog
./blog migrate applies the migrations compiled into the binary, and ./blog db status shows their status; PORT sets the port. Keep the secret the same across restarts: changing it ends every session.
Cybertrain::Config reads the environment first; config/app.rb overrides:
| Variable | Attribute | Default |
|---|---|---|
CYBERTRAIN_ENV | env | "development"; "production" in a binary from cybertrain build (also "test") |
PORT | port | 3000 |
CYBERTRAIN_DATABASE | database_path | storage/#{env}.sqlite3 |
CYBERTRAIN_SECRET_KEY_BASE | secret_key_base | required in production; dev/test auto-generate one into tmp/secret_key |
SPINEL_WORKERS | workers | 1 |
CYBERTRAIN_HOST | host | "127.0.0.1"; 0.0.0.0 listens on every interface (containers) |
CYBERTRAIN_SESSION_SAME_SITE | session_same_site | "Lax"; also Strict or None (None always adds Secure); anything else stops the server at boot |
CYBERTRAIN_SESSION_PARTITIONED | session_partitioned | false; 1 or true adds Partitioned (and Secure) |
# config/app.rb
Cybertrain.configure do |c|
c.port = 3000
c.workers = 1
end
- The server speaks plain HTTP/1.1: put nginx or Caddy in front for TLS and HTTP/2. The session cookie is
Securein production, so serve it over HTTPS (setc.session_secure = falseinconfig/app.rbonly if production really is plain http). - Production refuses to start without
CYBERTRAIN_SECRET_KEY_BASE. - In production an exception answers 500, and a bare error response (the router's plain-text 404,
head :not_found,render plain:) is replaced bypublic/<status>.htmlwhen that file exists;cybertrain newwrites404.htmland500.html. SIGTERMstops accepting, lets requests in flight finish and exits when they are done, or after the server'sdrain_timeout(10 s).- The watcher and rebuild loop never run in production. A running
cybertrain serverskips its rebuilds whilecybertrain buildholdstmp/cybertrain-build.lock.
Things to know
WhyLimits and quirks worth knowing before they surprise you.
- Sessions hold Strings only, in an HMAC-signed cookie.
- SQLite only, through Spinel’s FFI.
- No backtraces: errors show class, message, request line and template name/line.
- A misspelled attribute in a view renders blank instead of raising; this is a known gap.
- Associations come from schema foreign keys only; no
has_many :through,includes,pluck,dependent:, enums, STI or polymorphic associations. - No
namespace,respond_to/formats,constraintsormount;render json:is minimal. - Only the first string field gets a default presence validation in a scaffolded model.
- The generator uses a lexical scan (regexes), not a parser, over
app/controllersandapp/models. - Views are embedded at build time: a view-only change needs
cybertrain buildand a restart before it reaches production. - Build
dist/on the same OS and CPU as the machine that runs it: the binary links the system SQLite. SPINEL_WORKERSdefaults to1.- Only Spinel
2026.09.12is tested.
Differences from Rails
WhyThe full table from the README, in one place.
| Rails | CyberTrain |
|---|---|
rails console | No console — Spinel has no eval |
| Edit code, the running app picks it up | Ruby needs a rebuild; cybertrain server in development does this for you (rebuild, stop, execv) |
| Edit a view, no reload needed | Same — views are parsed from disk per request in development |
def new | def new_action (new would shadow Klass.new(ctx)) |
before_action { do_thing } (implicit self) | before_action { |c| c.do_thing } — no instance_exec on a stored block, so callbacks take the controller/record explicitly |
resources :posts do resources :comments end | resources :posts do |posts| posts.resources :comments end — nested blocks take the mapper explicitly |
| ERB compiles to a method; any object, any method | Interpreted against a fixed grammar/dispatch table |
| Session holds any marshalled object | Session values are Strings only, HMAC-signed cookie |
| Many database adapters | SQLite only, via FFI |
has_many :through, includes, pluck, dependent:, enums, STI, polymorphic associations | Not implemented; associations come from schema foreign keys only |
namespace, format/respond_to, constraints, mount | Not implemented — flat names, render json: only |
| Full backtrace on an exception | Class, message, request line, template name/line — Spinel exposes no backtraces |
Rack, its middleware, and any gem in a Gemfile | No Rack compatibility; a small fixed middleware set; Spinel's own spin-index, limited to what compiles under its Ruby subset |
| minitest / RSpec | Cybertrain::Test — reflection-based runners can't work ahead-of-time |
| Auth, mailers, jobs, ActionCable, asset pipeline, i18n | Not built (outside the MVP) |
Not exhaustive.