cybertrain 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/README.md +461 -0
- data/cybertrain/cli/new_app.rb +75 -0
- data/cybertrain/cli/scaffold.rb +227 -0
- data/cybertrain/cli/templates.rb +421 -0
- data/cybertrain/cli.rb +100 -0
- data/cybertrain/generator/inflector.rb +126 -0
- data/cybertrain/version.rb +8 -0
- data/exe/cybertrain +7 -0
- metadata +53 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 213798cb904eb6eb4ba8f45d0ef3c4d89db13533689a40ca0faf8419514bdc25
|
|
4
|
+
data.tar.gz: 4a975e911f2e3bbd3de9c3e5d126a5095a52a36bbe21365e97620c0b13b921d1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 44538d5ef1697095ca313333baaf0c7a47ece4ae98ed480b39da2553cb99f3da265862d24e921a7e60f8226bbb02a52b804cbc7f95b553152730b19f626772e9
|
|
7
|
+
data.tar.gz: 7edfda7ced1ce88bea85fca1a45ced852ebdee842861fb3ebd6ee0cfe9bfdb317bdecce8e1f330a1095ba1712f5e6f122eb2e48e6b0f447849ea1a29db7b95fb
|
data/README.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# cybertrain
|
|
2
|
+
|
|
3
|
+
cybertrain is a Rails-shaped web application framework written natively for
|
|
4
|
+
[Spinel](https://github.com/matz/spinel), matz's ahead-of-time Ruby compiler.
|
|
5
|
+
There is no Rack, no gems at runtime, and no Ruby interpreter in the deployed
|
|
6
|
+
artifact: `spin build` compiles an application — controllers, models, views
|
|
7
|
+
and the framework itself — into a single native binary. Where Rails leans on
|
|
8
|
+
runtime metaprogramming (`method_missing`, `define_method`, `eval`) for
|
|
9
|
+
`before_action :set_post` or `Post.where(title: "x")`, cybertrain gets the
|
|
10
|
+
same vocabulary by generating plain, committed Ruby from `db/schema.rb` and
|
|
11
|
+
`config/routes.rb` at build time (`spin run gen`), plus a little runtime data
|
|
12
|
+
for validations/callbacks and a runtime-interpreted template language.
|
|
13
|
+
|
|
14
|
+
**Status:** pre-alpha. Every milestone in [docs/design.md](docs/design.md) is
|
|
15
|
+
implemented end to end: an HTTP/1.1 server, routing and controllers with
|
|
16
|
+
callbacks and `rescue_from`, cookie sessions/flash/CSRF, an ERB-flavored
|
|
17
|
+
template engine, SQLite-backed models with validations, callbacks and
|
|
18
|
+
schema-derived associations, migrations, the `cybertrain` CLI, and a
|
|
19
|
+
development loop that rebuilds and re-execs the server on code changes.
|
|
20
|
+
[examples/blog](examples/blog) is the Rails "Getting Started" blog (articles
|
|
21
|
+
with comments) built on it, exercised by CI on Linux and macOS. Not built:
|
|
22
|
+
authentication, mailers, jobs, WebSockets/ActionCable, an asset pipeline,
|
|
23
|
+
i18n, and anything beyond a minimal `render json:` — see "Differences from
|
|
24
|
+
Rails" below.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
- Spinel `2026.09.12`, with `spinel` and `spin` on `PATH` (see `SPINEL_TAG`
|
|
29
|
+
in [.github/workflows/ci.yml](.github/workflows/ci.yml)); untested against
|
|
30
|
+
other versions.
|
|
31
|
+
- A C toolchain (`cc`) — Spinel compiles every program to C.
|
|
32
|
+
- Ruby 3.2+ and RubyGems, for the `cybertrain` command only (applications
|
|
33
|
+
never run on CRuby).
|
|
34
|
+
- SQLite 3 headers/library (`libsqlite3-dev` on Debian/Ubuntu; present with
|
|
35
|
+
Xcode's command line tools on macOS) — models use Spinel's FFI directly,
|
|
36
|
+
not a gem. Building Spinel itself also needs OpenSSL's headers
|
|
37
|
+
(`libssl-dev`); cybertrain itself does not link OpenSSL.
|
|
38
|
+
|
|
39
|
+
The framework has no separate test runner; it tests itself:
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
spin test
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
compiles and runs every program under `test/` and diffs its output against a
|
|
46
|
+
committed `test/<name>.rb.expected` snapshot — minitest/RSpec can't run under
|
|
47
|
+
Spinel, since they find test methods by reflection and an ahead-of-time
|
|
48
|
+
compiler has nothing to reflect on at run time. `spin test --regen
|
|
49
|
+
test/<name>.rb` rewrites a snapshot from CRuby's output; a few tests that
|
|
50
|
+
touch SQLite through FFI take theirs from the compiled binary instead
|
|
51
|
+
([spikes/NOTES.md](spikes/NOTES.md), rule 23).
|
|
52
|
+
|
|
53
|
+
## Installing the CLI
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
gem install cybertrain
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The `cybertrain` command is plain Ruby and the gem runs it under CRuby
|
|
60
|
+
(3.2 or newer); the framework itself is not in the gem. Two commands:
|
|
61
|
+
`cybertrain new NAME` scaffolds an application, `cybertrain generate
|
|
62
|
+
scaffold NAME field:type ...` (alias `g scaffold`) adds a resource to one.
|
|
63
|
+
|
|
64
|
+
`cybertrain new` points the app's `spin.toml` at the release matching the
|
|
65
|
+
CLI — `cybertrain = { git = "https://github.com/saeki-mototsune/cybertrain",
|
|
66
|
+
ref = "v0.1.0" }` — then runs `spin lock` (spin fetches the framework into
|
|
67
|
+
its cache, `~/.cache/spin/packages/`, and pins the commit in `spin.lock`)
|
|
68
|
+
and `spin run gen`, much as `rails new` runs `bundle install`. Nothing of
|
|
69
|
+
the framework is copied into the app; commit `spin.toml` and `spin.lock`.
|
|
70
|
+
`--skip-spin` leaves both steps for later, `--path DIR` depends on a local
|
|
71
|
+
checkout instead, and `--version V` on an index version once cybertrain is
|
|
72
|
+
on a `spin-index`.
|
|
73
|
+
|
|
74
|
+
Working on cybertrain itself, run the CLI from the checkout (`spin install`
|
|
75
|
+
builds `bin/cybertrain.rb` into `~/.local/bin/cybertrain`; `ruby -I.
|
|
76
|
+
bin/cybertrain.rb` also works) and create apps against it with `cybertrain
|
|
77
|
+
new NAME --path ~/src/cybertrain` (wherever the checkout is).
|
|
78
|
+
|
|
79
|
+
## Walkthrough: building a blog
|
|
80
|
+
|
|
81
|
+
Mirrors Rails' own [Getting
|
|
82
|
+
Started](https://guides.rubyonrails.org/getting_started.html): a blog with
|
|
83
|
+
articles and comments.
|
|
84
|
+
|
|
85
|
+
### Create the application
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
cybertrain new blog
|
|
89
|
+
cd blog
|
|
90
|
+
spin build # works right away: `new` already locked the framework and ran `spin run gen`
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
blog/
|
|
95
|
+
spin.toml config/app.rb config/routes.rb
|
|
96
|
+
db/schema.rb db/migrate/
|
|
97
|
+
app/controllers/application_controller.rb app/models/ app/views/ app/helpers/
|
|
98
|
+
public/ bin/server.rb bin/gen.rb bin/db.rb
|
|
99
|
+
gen/ storage/ tmp/ test/
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`ApplicationController` starts with the one thing every controller inherits:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
class ApplicationController < Cybertrain::Controller
|
|
106
|
+
rescue_from Cybertrain::RecordNotFound, with: :record_not_found
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def record_not_found
|
|
111
|
+
render plain: "Not Found", status: :not_found
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Scaffold an article
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
cybertrain generate scaffold article title:string body:text
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
writes a migration, a model, a controller and five views, and inserts
|
|
123
|
+
`resources :articles` into `config/routes.rb`. Point root at it and, as
|
|
124
|
+
Rails' guide does, add a length validation by hand:
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# config/routes.rb
|
|
128
|
+
Cybertrain::Routes.draw do
|
|
129
|
+
root "articles#index"
|
|
130
|
+
resources :articles
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# app/models/article.rb -- only the first string field gets a default
|
|
136
|
+
# presence validation; add anything past that yourself.
|
|
137
|
+
class Article
|
|
138
|
+
validates :title, presence: true
|
|
139
|
+
validates :body, presence: true, length: { minimum: 10 }
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The controller is full CRUD, Rails-shaped down to strong params and
|
|
144
|
+
flash-then-redirect, except for two things Spinel forces everywhere: `new` is
|
|
145
|
+
written `new_action` (a method literally named `new` would shadow
|
|
146
|
+
`ArticlesController.new(ctx)`), and every action/callback is called by its
|
|
147
|
+
literal name, never `send`:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
# app/controllers/articles_controller.rb (index/show/edit are as plain as
|
|
151
|
+
# Rails' own scaffold; update mirrors create)
|
|
152
|
+
class ArticlesController < ApplicationController
|
|
153
|
+
before_action :set_article, only: [:show, :edit, :update, :destroy]
|
|
154
|
+
|
|
155
|
+
def new_action # a `new` method would shadow ArticlesController.new
|
|
156
|
+
@article = Article.new
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def create
|
|
160
|
+
@article = Article.new(article_params)
|
|
161
|
+
if @article.save
|
|
162
|
+
flash[:notice] = "Article was successfully created."
|
|
163
|
+
redirect_to article_path(@article), status: :see_other
|
|
164
|
+
else
|
|
165
|
+
render :new, status: :unprocessable_entity
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def set_article
|
|
172
|
+
@article = Article.find(params[:id])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def article_params
|
|
176
|
+
params.require(:article).permit(:title, :body)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Generate, migrate, run
|
|
182
|
+
|
|
183
|
+
```sh
|
|
184
|
+
spin run gen # reads db/schema.rb + config/routes.rb, scans app/, writes gen/
|
|
185
|
+
spin run db -- migrate # applies db/migrate/*.rb, rewrites db/schema.rb (arguments go after --)
|
|
186
|
+
spin run server # http://127.0.0.1:3000
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Re-run `spin run gen` (and commit `gen/`) after touching the schema, routes,
|
|
190
|
+
or a controller/model's callbacks and ivars — the dev server does this for
|
|
191
|
+
you automatically (see "How it works").
|
|
192
|
+
|
|
193
|
+
### Add comments, nested under articles
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
cybertrain generate scaffold comment commenter:string body:text article:references
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`article:references` adds an `article_id` column, index and foreign key —
|
|
200
|
+
enough for `Article`/`Comment` to get their association; there's no
|
|
201
|
+
`has_many`/`belongs_to` to write, `spin run gen` reads it off the foreign
|
|
202
|
+
key. This also inserts an independent `resources :comments` line; Rails'
|
|
203
|
+
guide nests comments under articles instead, so edit `config/routes.rb`:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
Cybertrain::Routes.draw do
|
|
207
|
+
root "articles#index"
|
|
208
|
+
|
|
209
|
+
# A nested block takes the mapper explicitly (Spinel's instance_eval
|
|
210
|
+
# trampoline only rewires `self` for the outermost block): `articles.resources`.
|
|
211
|
+
resources :articles do |articles|
|
|
212
|
+
articles.resources :comments, only: [:create, :destroy]
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Trim `CommentsController` to the two actions the nested routes call,
|
|
218
|
+
resolving the parent from the URL instead of `:id`:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class CommentsController < ApplicationController
|
|
222
|
+
before_action :set_article
|
|
223
|
+
|
|
224
|
+
def create
|
|
225
|
+
@comment = Comment.new(comment_params)
|
|
226
|
+
@comment.article_id = @article.id
|
|
227
|
+
if @comment.save
|
|
228
|
+
flash[:notice] = "Comment was successfully created."
|
|
229
|
+
else
|
|
230
|
+
flash[:alert] = "Comment could not be saved: #{@comment.errors.full_messages.join(", ")}"
|
|
231
|
+
end
|
|
232
|
+
redirect_to article_path(@article), status: :see_other
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def destroy
|
|
236
|
+
@comment = Comment.where(article_id: @article.id).find(params[:id])
|
|
237
|
+
@comment.destroy
|
|
238
|
+
flash[:notice] = "Comment was successfully destroyed."
|
|
239
|
+
redirect_to article_path(@article), status: :see_other
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
def set_article
|
|
245
|
+
@article = Article.find(params[:article_id])
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def comment_params
|
|
249
|
+
params.require(:comment).permit(:commenter, :body)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Delete the now-unreachable `index`/`show`/`new`/`edit` comment views, give
|
|
255
|
+
`ArticlesController#show` a blank `@comment` to bind against, and add a list
|
|
256
|
+
and a form to the article page:
|
|
257
|
+
|
|
258
|
+
```erb
|
|
259
|
+
<%# app/views/articles/show.html.erb %>
|
|
260
|
+
<% @article.comments.each do |comment| %>
|
|
261
|
+
<%= render "comments/comment", comment: comment %>
|
|
262
|
+
<% end %>
|
|
263
|
+
<%= render "comments/form" %>
|
|
264
|
+
|
|
265
|
+
<%# app/views/comments/_form.html.erb %>
|
|
266
|
+
<%= form_with(model: [@article, @comment]) do |f| %>
|
|
267
|
+
<%= f.label :commenter %> <%= f.text_field :commenter %>
|
|
268
|
+
<%= f.label :body %> <%= f.text_area :body %>
|
|
269
|
+
<%= f.submit %>
|
|
270
|
+
<% end %>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`comments/_comment.html.erb` destroys with `button_to "Destroy comment",
|
|
274
|
+
[@article, comment], method: :delete`. Both that and `form_with(model:
|
|
275
|
+
[@article, @comment])` resolve through the same `[parent, child]` rule: a new
|
|
276
|
+
child routes to the nested collection (`article_comments_path`), a persisted
|
|
277
|
+
one to the nested member (`article_comment_path`) — generated into
|
|
278
|
+
`gen/routes.rb` from the nested `resources` block above.
|
|
279
|
+
|
|
280
|
+
## How it works
|
|
281
|
+
|
|
282
|
+
**Build-time code generation.** `spin run gen` (`bin/gen.rb`) executes
|
|
283
|
+
`db/schema.rb`'s `create_table` DSL and `config/routes.rb`'s `Routes.draw`
|
|
284
|
+
DSL to get table and route *data*, and separately does a **lexical scan**
|
|
285
|
+
(regexes, not a parser — Spinel exposes none at run time) of
|
|
286
|
+
`app/controllers/**/*.rb` and `app/models/**/*.rb` for `@ivar =`
|
|
287
|
+
assignments, `before_action`/`after_action`/`rescue_from ... with:` names and
|
|
288
|
+
zero-argument `def`s. From that it writes plain, readable Ruby under `gen/`:
|
|
289
|
+
`gen/models/<name>.rb` (accessors, casts, finders, FK-derived associations),
|
|
290
|
+
`gen/routes.rb` (route table, dispatcher, `*_path`/`*_url` helpers),
|
|
291
|
+
`gen/controllers.rb` (`view_assigns` and `run_callback` per controller),
|
|
292
|
+
`gen/migrations.rb` and `gen/app.rb` (the `require_relative` manifest).
|
|
293
|
+
`gen/` is committed, not gitignored — `spin build`/`spin test` have no hook
|
|
294
|
+
to generate first, so the checked-in output has to already be what compiles.
|
|
295
|
+
CI re-runs `spin run gen` and fails on a diff, so stale generated code can't
|
|
296
|
+
merge.
|
|
297
|
+
|
|
298
|
+
**Views are interpreted, not compiled.** `app/views/**/*.html.erb` files look
|
|
299
|
+
like Rails ERB but are read from disk, parsed into an AST at request time
|
|
300
|
+
(cached after first parse in production; re-parsed on change in
|
|
301
|
+
development), then walked by a tree-walking interpreter — Spinel has no
|
|
302
|
+
`eval`, so this is a closed grammar (literals, `@ivar`/local lookups, calls
|
|
303
|
+
through fixed per-type dispatch tables, `if`/`unless`,
|
|
304
|
+
`each`/`each_with_index`/one helper block), not real Ruby. Full grammar,
|
|
305
|
+
helpers and gaps: [docs/template-language.md](docs/template-language.md).
|
|
306
|
+
|
|
307
|
+
**The HTTP server.** `Cybertrain::Server` is plain-Ruby HTTP/1.1:
|
|
308
|
+
`TCPServer#accept` plus one Spinel green thread per connection (M:N
|
|
309
|
+
scheduled, no GVL), typed `Request`/`Response`, no Rack layer. TLS/HTTP/2 are
|
|
310
|
+
left to a reverse proxy. `SPINEL_WORKERS` defaults to `1` — spikes found that
|
|
311
|
+
faster and stall-free for this I/O-bound shape at 100 concurrent connections.
|
|
312
|
+
|
|
313
|
+
**SQLite via FFI.** Models talk to SQLite through Spinel's
|
|
314
|
+
`ffi_func`/`ffi_lib` directly — no C extension, no gem. A pool (`SizedQueue`,
|
|
315
|
+
4 connections by default) hands out connections with WAL, a `busy_timeout`,
|
|
316
|
+
and foreign keys on; every query is bound, never interpolated. It's the only
|
|
317
|
+
adapter today (PostgreSQL via `libpq` FFI is noted as possible future work).
|
|
318
|
+
|
|
319
|
+
**The development loop.** `spin run server` in `development` (the default
|
|
320
|
+
`CYBERTRAIN_ENV`) also polls `app/**/*.rb`, `config/**/*.rb`, `db/schema.rb`
|
|
321
|
+
and `gen/**/*.rb` every half second (views are excluded — the engine reloads
|
|
322
|
+
those itself). A change runs `spin run gen && spin build server` in the
|
|
323
|
+
background; success sends the server `SIGHUP`, whose handler stops listening
|
|
324
|
+
and `execv`s the new binary on the same port and PID, invisible to a client
|
|
325
|
+
mid-session. A failed build keeps serving the old binary and banners the
|
|
326
|
+
compiler output on every HTML response. None of this loads in production.
|
|
327
|
+
|
|
328
|
+
## Differences from Rails
|
|
329
|
+
|
|
330
|
+
| Rails | cybertrain |
|
|
331
|
+
| --- | --- |
|
|
332
|
+
| `rails console` | No console — Spinel has no `eval` |
|
|
333
|
+
| Edit code, the running app picks it up | Ruby needs a rebuild; `spin run server` in development does this for you (rebuild, `SIGHUP`, `execv`) |
|
|
334
|
+
| Edit a view, no reload needed | Same — views are parsed from disk per request in development |
|
|
335
|
+
| `def new` | `def new_action` (`new` would shadow `Klass.new(ctx)`) |
|
|
336
|
+
| `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 |
|
|
337
|
+
| `resources :posts do resources :comments end` | `resources :posts do \|posts\| posts.resources :comments end` — nested blocks take the mapper explicitly |
|
|
338
|
+
| ERB compiles to a method; any object, any method | Interpreted against a fixed grammar/dispatch table — [docs/template-language.md](docs/template-language.md) |
|
|
339
|
+
| Session holds any marshalled object | Session values are Strings only, HMAC-signed cookie |
|
|
340
|
+
| Many database adapters | SQLite only, via FFI |
|
|
341
|
+
| `has_many :through`, `includes`, `pluck`, `dependent:`, enums, STI, polymorphic associations | Not implemented; associations come from schema foreign keys only |
|
|
342
|
+
| `namespace`, format/`respond_to`, `constraints`, `mount` | Not implemented — flat names, `render json:` only |
|
|
343
|
+
| Full backtrace on an exception | Class, message, request line, template name/line — Spinel exposes no backtraces |
|
|
344
|
+
| 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 |
|
|
345
|
+
| minitest / RSpec | `Cybertrain::Test` — reflection-based runners can't work ahead-of-time |
|
|
346
|
+
| Auth, mailers, jobs, ActionCable, asset pipeline, i18n | Not built (MVP non-goals, `docs/design.md` §2.3) |
|
|
347
|
+
|
|
348
|
+
Not exhaustive — `docs/design.md` §8 ("捨てたもの") is the fuller record.
|
|
349
|
+
|
|
350
|
+
## Configuration and environment variables
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
# config/app.rb
|
|
354
|
+
Cybertrain.configure do |c|
|
|
355
|
+
c.port = 3000
|
|
356
|
+
c.workers = 1
|
|
357
|
+
end
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`Cybertrain::Config` reads the environment first; `config/app.rb` overrides:
|
|
361
|
+
|
|
362
|
+
| Variable | Attribute | Default |
|
|
363
|
+
| --- | --- | --- |
|
|
364
|
+
| `CYBERTRAIN_ENV` | `env` | `"development"` (also `"test"`, `"production"`) |
|
|
365
|
+
| `PORT` | `port` | `3000` |
|
|
366
|
+
| `CYBERTRAIN_DATABASE` | `database_path` | `storage/#{env}.sqlite3` |
|
|
367
|
+
| `CYBERTRAIN_SECRET_KEY_BASE` | `secret_key_base` | required in production; dev/test auto-generate one into `tmp/secret_key` |
|
|
368
|
+
| `SPINEL_WORKERS` | `workers` | `1` |
|
|
369
|
+
|
|
370
|
+
Other attributes with fixed, overridable defaults: `host` (`"127.0.0.1"`),
|
|
371
|
+
`views_root` (`"app/views"`), `public_root` (`"public"`), `layout`
|
|
372
|
+
(`"layouts/application"`), `log_level` (`:info`), `session_cookie_name`,
|
|
373
|
+
`session_max_age` (2 weeks), `session_secure` (`true` in production, which
|
|
374
|
+
marks the session cookie `Secure`; `false` elsewhere), `pool_size` (4),
|
|
375
|
+
`static_files`/`csrf` (`true`).
|
|
376
|
+
|
|
377
|
+
**Deployment:** `spin build server` produces `build/bin/server`. Ship it with
|
|
378
|
+
`app/views/` (read at request time, never compiled in), `public/` (static
|
|
379
|
+
assets, or let a reverse proxy serve it) and `storage/` (the SQLite file),
|
|
380
|
+
`CYBERTRAIN_ENV=production` and `CYBERTRAIN_SECRET_KEY_BASE` set. The binary
|
|
381
|
+
speaks plain HTTP/1.1 only; put nginx/Caddy in front for TLS and HTTP/2 (the
|
|
382
|
+
session cookie is `Secure` in production, so serve it over HTTPS). In
|
|
383
|
+
production an exception answers 500 and a bare error response (the router's
|
|
384
|
+
plain-text 404, `head :not_found`, `render plain:`) is replaced by
|
|
385
|
+
`public/<status>.html` when that file exists; errors an action renders as HTML
|
|
386
|
+
or JSON pass through. `SIGTERM` stops accepting, lets requests already in
|
|
387
|
+
flight finish (answered with `Connection: close`; idle keep-alive connections
|
|
388
|
+
are closed at once) and exits when they are done, or after the server's
|
|
389
|
+
`drain_timeout` (10 s). The watcher/rebuild loop never runs in production. [docs/deploy.md](docs/deploy.md)
|
|
390
|
+
(Japanese) walks through it end to end: `cybertrain new`, an Ubuntu server,
|
|
391
|
+
systemd, Caddy with HTTPS, redeploys, rollbacks and backups.
|
|
392
|
+
|
|
393
|
+
## Testing an app
|
|
394
|
+
|
|
395
|
+
App tests are plain Spinel programs under `test/`, like the framework's own:
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
require "cybertrain/test"
|
|
399
|
+
|
|
400
|
+
test "title must be present" do
|
|
401
|
+
article = Article.new(body: "a body long enough to pass length")
|
|
402
|
+
refute article.save
|
|
403
|
+
assert_includes article.errors.full_messages, "Title can't be blank"
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Assertions: `assert`, `refute`, `assert_equal`, `assert_nil`,
|
|
408
|
+
`assert_includes`, `assert_raises("Class") { }`, `flunk`. For controllers and
|
|
409
|
+
full HTTP flows, `Cybertrain::Test::Client` drives an app in-process (no
|
|
410
|
+
socket) with a cookie jar, so sessions and CSRF behave as behind a browser:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
require "cybertrain/test/client"
|
|
414
|
+
|
|
415
|
+
client = Cybertrain::Test::Client.new(BLOG)
|
|
416
|
+
client.get("/articles/new")
|
|
417
|
+
res = client.post("/articles", "article[title]" => "Hello",
|
|
418
|
+
"article[body]" => "I am on Rails!",
|
|
419
|
+
"authenticity_token" => token) # from the rendered form
|
|
420
|
+
assert_redirected_to res, "/articles/1"
|
|
421
|
+
assert_response res, :see_other
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
`examples/blog/test/articles.rb`/`comments.rb` (setup in
|
|
425
|
+
`test/support/blog_test.rb`) show the full pattern, including token
|
|
426
|
+
extraction, against a freshly migrated `storage/test.sqlite3`.
|
|
427
|
+
|
|
428
|
+
Run with `spin test`: it compiles each `test/*.rb` into its own program and
|
|
429
|
+
diffs stdout against `test/<name>.rb.expected`. Regenerate with `spin test
|
|
430
|
+
--regen test/<name>.rb`; FFI/database tests can't run under CRuby, so their
|
|
431
|
+
snapshot comes from the compiled binary instead: `spin test test/<name>.rb &&
|
|
432
|
+
./build/test/<name> > test/<name>.rb.expected`.
|
|
433
|
+
|
|
434
|
+
## Learn more
|
|
435
|
+
|
|
436
|
+
- [docs/design.md](docs/design.md) — the design record (Japanese): every
|
|
437
|
+
decision, what was rejected and why, and the Spinel constraints behind it.
|
|
438
|
+
- [docs/template-language.md](docs/template-language.md) — the full template
|
|
439
|
+
grammar and helpers, and where it differs from Rails' ERB.
|
|
440
|
+
- [spikes/NOTES.md](spikes/NOTES.md) — the spikes that answered
|
|
441
|
+
`docs/design.md`'s open questions, and the compiler constraints they found.
|
|
442
|
+
- [Spinel](https://github.com/matz/spinel) — the AOT Ruby compiler cybertrain targets.
|
|
443
|
+
- [Roundhouse](https://github.com/rubys/roundhouse) (Sam Ruby) — transpiles
|
|
444
|
+
*existing* Rails apps to Spinel. cybertrain is not that: it's a native
|
|
445
|
+
framework you write directly against, not a compatibility layer.
|
|
446
|
+
|
|
447
|
+
## Releasing
|
|
448
|
+
|
|
449
|
+
The gem and the framework are released from the same tag, and `cybertrain
|
|
450
|
+
new` depends on the tag `v` + `Cybertrain::VERSION`, so the tag must exist
|
|
451
|
+
before the gem is pushed:
|
|
452
|
+
|
|
453
|
+
1. Bump `Cybertrain::VERSION` (`cybertrain/version.rb`) and `version` in
|
|
454
|
+
`spin.toml` together (CI checks they match); spin caches a git
|
|
455
|
+
dependency by that version.
|
|
456
|
+
2. Merge to `main`, then tag and push: `git tag v0.1.0 && git push origin v0.1.0`.
|
|
457
|
+
3. `gem build cybertrain.gemspec && gem push cybertrain-0.1.0.gem`.
|
|
458
|
+
|
|
459
|
+
## License
|
|
460
|
+
|
|
461
|
+
MIT.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# `cybertrain new blog`: writes the skeleton of an application package.
|
|
2
|
+
require "cybertrain/generator/inflector"
|
|
3
|
+
require "cybertrain/cli/templates"
|
|
4
|
+
|
|
5
|
+
module Cybertrain
|
|
6
|
+
module CLI
|
|
7
|
+
module NewApp
|
|
8
|
+
KEEP_DIRS = ["db/migrate", "app/models", "app/helpers", "gen", "storage", "tmp", "test"]
|
|
9
|
+
|
|
10
|
+
# dir is the directory to create ("blog" or "path/to/blog"; its last
|
|
11
|
+
# component names the package). framework_dep is the TOML value that
|
|
12
|
+
# spin.toml's `cybertrain =` line gets: `{ path = "/abs/cybertrain" }` or
|
|
13
|
+
# a version constraint such as `"~> 0.1"`. Returns the created paths,
|
|
14
|
+
# relative to dir.
|
|
15
|
+
def self.create(dir, framework_dep)
|
|
16
|
+
package = File.basename(dir)
|
|
17
|
+
title = Inflector.camelize(package)
|
|
18
|
+
files = [
|
|
19
|
+
["spin.toml", Templates.spin_toml(package, framework_dep)],
|
|
20
|
+
[".gitignore", Templates.gitignore],
|
|
21
|
+
["README.md", Templates.readme(title)],
|
|
22
|
+
["config/app.rb", Templates.config_app],
|
|
23
|
+
["config/routes.rb", Templates.routes],
|
|
24
|
+
["db/schema.rb", Templates.schema],
|
|
25
|
+
["db/migrate/.keep", ""],
|
|
26
|
+
["app/controllers/application_controller.rb", Templates.application_controller],
|
|
27
|
+
["app/models/.keep", ""],
|
|
28
|
+
["app/helpers/.keep", ""],
|
|
29
|
+
["app/views/layouts/application.html.erb", Templates.layout(title)],
|
|
30
|
+
["public/404.html", Templates.error_page("404", "Not Found")],
|
|
31
|
+
["public/500.html", Templates.error_page("500", "Internal Server Error")],
|
|
32
|
+
["public/style.css", Templates.style_css],
|
|
33
|
+
["bin/server.rb", Templates.bin_server],
|
|
34
|
+
["bin/gen.rb", Templates.bin_gen],
|
|
35
|
+
["bin/db.rb", Templates.bin_db],
|
|
36
|
+
["gen/.keep", ""],
|
|
37
|
+
["storage/.keep", ""],
|
|
38
|
+
["tmp/.keep", ""],
|
|
39
|
+
["test/.keep", ""]
|
|
40
|
+
]
|
|
41
|
+
created = Array.new(0) { "" }
|
|
42
|
+
files.each do |entry|
|
|
43
|
+
created << entry[0] if Templates.write(dir, entry[0], entry[1]) == "create"
|
|
44
|
+
end
|
|
45
|
+
created
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What `rails new` does with `bundle install`: resolve and lock the
|
|
49
|
+
# framework (fetching it into spin's cache, ~/.cache/spin), then write
|
|
50
|
+
# gen/ so `spin build` works straight away. Without spin on PATH it
|
|
51
|
+
# only says what to run later. Returns false when a step failed.
|
|
52
|
+
def self.bootstrap(dir)
|
|
53
|
+
unless system("command -v spin > /dev/null 2>&1")
|
|
54
|
+
puts "skip spin lock / spin run gen: `spin` is not on PATH"
|
|
55
|
+
puts " install Spinel (https://github.com/matz/spinel), then: #{bootstrap_command(dir)}"
|
|
56
|
+
return true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
puts "run spin lock && spin run gen"
|
|
60
|
+
return true if system(bootstrap_command(dir))
|
|
61
|
+
|
|
62
|
+
puts "error: bootstrapping #{dir} failed; fix the cause, then run: #{bootstrap_command(dir)}"
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.bootstrap_command(dir)
|
|
67
|
+
"cd #{shell_quote(dir)} && spin lock && spin run gen"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.shell_quote(text)
|
|
71
|
+
"'#{text.gsub("'", "'\\\\''")}'"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|