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 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