cybertrain 0.1.0 → 0.2.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.
@@ -0,0 +1,192 @@
1
+ # `cybertrain build`: dist/ = the app binary with app/views embedded, plus
2
+ # public/. Also the command lists `cybertrain db` and `server` run. Plain Ruby:
3
+ # runs under CRuby (the gem) and compiles under Spinel (spin install).
4
+ module Cybertrain
5
+ module CLI
6
+ module Build
7
+ # The [package] name in root/spin.toml, or "" when there is none.
8
+ def self.app_name(root)
9
+ path = "#{root}/spin.toml"
10
+ return "" unless File.exist?(path)
11
+
12
+ name = ""
13
+ in_package = false
14
+ File.read(path).each_line do |line|
15
+ text = line.strip
16
+ in_package = text == "[package]" if text.start_with?("[")
17
+ next unless in_package && text.start_with?("name")
18
+
19
+ match = text.match(/\Aname\s*=\s*"([^"]*)"/)
20
+ unless match.nil?
21
+ name = match[1]
22
+ break
23
+ end
24
+ end
25
+ name
26
+ end
27
+
28
+ def self.commands(name)
29
+ ["spin run gen -- --embed-views", "spin build #{name}", "spin run gen"]
30
+ end
31
+
32
+ # `cybertrain db ARGS`, through bin/db.rb (the app binary cannot compile
33
+ # before the first migration has produced gen/models). bin/db.rb only
34
+ # knows the migrations gen/migrations.rb lists, so gen runs first;
35
+ # migrate and rollback rewrite db/schema.rb, which gen/models is
36
+ # derived from, so gen runs again after them.
37
+ def self.db_commands(args)
38
+ words = args.map { |arg| quote_arg(arg) }.join(" ")
39
+ commands = ["spin run gen", "spin run db -- #{words}"]
40
+ commands << "spin run gen" if args[0] == "migrate" || args[0] == "rollback"
41
+ commands
42
+ end
43
+
44
+ # `cybertrain migration`: the same as `cybertrain db migrate`.
45
+ def self.migration_commands
46
+ db_commands(["migrate"])
47
+ end
48
+
49
+ # A word sh reads as itself stays bare; anything else is single-quoted.
50
+ def self.quote_arg(text)
51
+ return text if !text.empty? && text.match(/\A[A-Za-z0-9_:.\/-]+\z/)
52
+
53
+ shell_quote(text)
54
+ end
55
+
56
+ # port is "" (the app's default, 3000) or a port? string.
57
+ def self.server_commands(name, port)
58
+ run = port == "" ? "spin run #{name}" : "spin run #{name} -- #{port}"
59
+ ["spin run gen", run]
60
+ end
61
+
62
+ # All digits, 1 to 65535 (the app reads 0 as "no port").
63
+ def self.port?(text)
64
+ return false if text.empty? || text.size > 5
65
+ return false unless text.bytes.all? { |b| b >= 48 && b <= 57 }
66
+
67
+ text.to_i >= 1 && text.to_i <= 65535
68
+ end
69
+
70
+ # Runs one build step (a spin command) in root; true when it succeeds.
71
+ # Build.run takes a Runner so tests can stand in for spin.
72
+ class Runner
73
+ def run_step(root, command)
74
+ Build.run_in(root, command)
75
+ end
76
+ end
77
+
78
+ # Holds this process's PID while the steps run and dist/ is assembled:
79
+ # the development server (Dev::Rebuilder) skips its rebuilds
80
+ # meanwhile, since both write build/bin/<name>, and treats a lock
81
+ # whose PID is gone (a killed build) as stale.
82
+ def self.lock_path(root)
83
+ "#{root}/tmp/cybertrain-build.lock"
84
+ end
85
+
86
+ # Runs the build in root and assembles dist/. Returns the exit code.
87
+ def self.run(root, name, runner = Runner.new)
88
+ steps = commands(name)
89
+ ok = false
90
+ restored = false
91
+ code = 1
92
+ Templates.mkdir_p("#{root}/tmp")
93
+ File.write(lock_path(root), Process.pid.to_s)
94
+ begin
95
+ begin
96
+ ok = runner.run_step(root, steps[0]) && runner.run_step(root, steps[1])
97
+ ensure
98
+ # Always put gen/views.rb back to the empty table, even after a
99
+ # failure or Ctrl-C.
100
+ restored = runner.run_step(root, steps[2])
101
+ puts "warning: gen/views.rb still holds the embedded views; run spin run gen" unless restored
102
+ end
103
+ if ok && restored
104
+ assemble(root, name)
105
+ code = 0
106
+ end
107
+ ensure
108
+ # Released only once dist/ holds the new binary: assemble reads
109
+ # build/bin/<name>, which a dev rebuild would be writing.
110
+ File.delete(lock_path(root)) if File.exist?(lock_path(root))
111
+ end
112
+ return 1 unless code == 0
113
+
114
+ puts ""
115
+ puts "dist/#{name} (production by default)"
116
+ puts "dist/public/"
117
+ puts "run: cd dist && ./#{name} migrate && ./#{name}"
118
+ 0
119
+ end
120
+
121
+ def self.run_in(root, command)
122
+ puts "run #{command}"
123
+ system("cd #{shell_quote(root)} && #{command}")
124
+ end
125
+
126
+ # Copies build/bin/<name> and public/ into dist/; storage/ and tmp/ are
127
+ # created when missing and never emptied (a database and the secret
128
+ # key can live there). Each copy lands in a dist/.*.tmp entry first and
129
+ # is renamed into place: a running dist/<name> keeps its old file
130
+ # (writing over a running binary fails with ETXTBSY on Linux), and the
131
+ # old dist/public/ steps aside to dist/.public.old between two renames
132
+ # (rename cannot replace a non-empty directory), so dist/public/ is
133
+ # missing only for that instant rather than for the whole copy.
134
+ def self.assemble(root, name)
135
+ binary = "#{root}/build/bin/#{name}"
136
+ raise InvalidArgument, "build/bin/#{name} is missing: `spin build #{name}` did not produce it" unless File.exist?(binary)
137
+
138
+ dist = "#{root}/dist"
139
+ Templates.mkdir_p(dist)
140
+ binary_tmp = "#{dist}/.#{name}.tmp"
141
+ public_tmp = "#{dist}/.public.tmp"
142
+ public_old = "#{dist}/.public.old"
143
+ # Leftovers of an interrupted earlier run.
144
+ rm_tree(binary_tmp)
145
+ rm_tree(public_tmp)
146
+ rm_tree(public_old)
147
+
148
+ copy_command = "cp #{shell_quote(binary)} #{shell_quote(binary_tmp)}"
149
+ raise InvalidArgument, "could not copy #{binary} to dist/" unless system(copy_command)
150
+
151
+ File.rename(binary_tmp, "#{dist}/#{name}")
152
+
153
+ if File.directory?("#{root}/public")
154
+ public_command = "cp -R #{shell_quote("#{root}/public")} #{shell_quote(public_tmp)}"
155
+ raise InvalidArgument, "could not copy public/ to dist/" unless system(public_command)
156
+ else
157
+ Dir.mkdir(public_tmp)
158
+ end
159
+ public_dir = "#{dist}/public"
160
+ File.rename(public_dir, public_old) if File.exist?(public_dir) || File.symlink?(public_dir)
161
+ File.rename(public_tmp, public_dir)
162
+ rm_tree(public_old)
163
+
164
+ Templates.mkdir_p("#{dist}/storage")
165
+ Templates.mkdir_p("#{dist}/tmp")
166
+ ["dist/#{name}", "dist/public/", "dist/storage/", "dist/tmp/"]
167
+ end
168
+
169
+ # A symlink is removed itself, never followed: its target may be
170
+ # dist/storage/ or outside dist/.
171
+ def self.rm_tree(path)
172
+ if File.symlink?(path)
173
+ File.delete(path)
174
+ return nil
175
+ end
176
+ return nil unless File.exist?(path)
177
+
178
+ if File.directory?(path)
179
+ Dir.children(path).each { |child| rm_tree("#{path}/#{child}") }
180
+ Dir.rmdir(path)
181
+ else
182
+ File.delete(path)
183
+ end
184
+ nil
185
+ end
186
+
187
+ def self.shell_quote(text)
188
+ "'#{text.gsub("'", "'\\\\''")}'"
189
+ end
190
+ end
191
+ end
192
+ end
@@ -1,12 +1,12 @@
1
1
  # `cybertrain new blog`: writes the skeleton of an application package.
2
2
  require "cybertrain/generator/inflector"
3
3
  require "cybertrain/cli/templates"
4
+ require "cybertrain/cli/build"
5
+ require "cybertrain/cli/toolchain"
4
6
 
5
7
  module Cybertrain
6
8
  module CLI
7
9
  module NewApp
8
- KEEP_DIRS = ["db/migrate", "app/models", "app/helpers", "gen", "storage", "tmp", "test"]
9
-
10
10
  # dir is the directory to create ("blog" or "path/to/blog"; its last
11
11
  # component names the package). framework_dep is the TOML value that
12
12
  # spin.toml's `cybertrain =` line gets: `{ path = "/abs/cybertrain" }` or
@@ -18,7 +18,7 @@ module Cybertrain
18
18
  files = [
19
19
  ["spin.toml", Templates.spin_toml(package, framework_dep)],
20
20
  [".gitignore", Templates.gitignore],
21
- ["README.md", Templates.readme(title)],
21
+ ["README.md", Templates.readme(title, package)],
22
22
  ["config/app.rb", Templates.config_app],
23
23
  ["config/routes.rb", Templates.routes],
24
24
  ["db/schema.rb", Templates.schema],
@@ -30,7 +30,7 @@ module Cybertrain
30
30
  ["public/404.html", Templates.error_page("404", "Not Found")],
31
31
  ["public/500.html", Templates.error_page("500", "Internal Server Error")],
32
32
  ["public/style.css", Templates.style_css],
33
- ["bin/server.rb", Templates.bin_server],
33
+ ["bin/#{package}.rb", Templates.bin_app(package)],
34
34
  ["bin/gen.rb", Templates.bin_gen],
35
35
  ["bin/db.rb", Templates.bin_db],
36
36
  ["gen/.keep", ""],
@@ -47,28 +47,33 @@ module Cybertrain
47
47
 
48
48
  # What `rails new` does with `bundle install`: resolve and lock the
49
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.
50
+ # gen/ so `spin build` works straight away. Installs Spinel first when
51
+ # this machine has none of the pinned release (Toolchain.ensure!).
52
+ # Returns false when a step failed.
52
53
  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
54
+ unless Toolchain.ensure!
55
+ puts "skip spin lock / spin run gen: Spinel #{Cybertrain::SPINEL_TAG} is not available"
56
+ puts " fix the problem above (`cybertrain doctor` lists the checks), then: cybertrain setup && #{recovery_command(dir)}"
57
+ return false
57
58
  end
58
59
 
59
60
  puts "run spin lock && spin run gen"
60
61
  return true if system(bootstrap_command(dir))
61
62
 
62
- puts "error: bootstrapping #{dir} failed; fix the cause, then run: #{bootstrap_command(dir)}"
63
+ puts "error: bootstrapping #{dir} failed; fix the cause, then run: #{recovery_command(dir)}"
63
64
  false
64
65
  end
65
66
 
67
+ # Run by bootstrap once Toolchain.ensure! has put spin on this
68
+ # process's PATH.
66
69
  def self.bootstrap_command(dir)
67
- "cd #{shell_quote(dir)} && spin lock && spin run gen"
70
+ "cd #{Build.shell_quote(dir)} && spin lock && spin run gen"
68
71
  end
69
72
 
70
- def self.shell_quote(text)
71
- "'#{text.gsub("'", "'\\\\''")}'"
73
+ # The same steps for the user to run by hand: through `cybertrain
74
+ # spin`, since the Spinel cybertrain installed is not on their PATH.
75
+ def self.recovery_command(dir)
76
+ "cd #{Build.shell_quote(dir)} && cybertrain spin lock && cybertrain spin run gen"
72
77
  end
73
78
  end
74
79
  end
@@ -92,6 +92,13 @@ module Cybertrain
92
92
  @fields = fields
93
93
  end
94
94
 
95
+ # The index route helper's name without _path, as Rails' scaffold
96
+ # names it: the plural, or "<plural>_index" for a word that is its own
97
+ # plural (sheep_index_path), matching the routes DSL.
98
+ def index_helper
99
+ @plural == @singular ? "#{@plural}_index" : @plural
100
+ end
101
+
95
102
  # The column the model validates for presence ("" when there is none).
96
103
  def first_string_field
97
104
  @fields.each { |f| return f.field_name if f.field_type == "string" }
@@ -76,6 +76,7 @@ module Cybertrain
76
76
  def self.gitignore
77
77
  <<~TEXT
78
78
  /build/
79
+ /dist/
79
80
  /storage/*.sqlite3*
80
81
  /tmp/*
81
82
  !/tmp/.keep
@@ -83,7 +84,7 @@ module Cybertrain
83
84
  TEXT
84
85
  end
85
86
 
86
- def self.readme(title)
87
+ def self.readme(title, package)
87
88
  <<~MARKDOWN
88
89
  # #{title}
89
90
 
@@ -91,16 +92,22 @@ module Cybertrain
91
92
 
92
93
  ```sh
93
94
  cybertrain generate scaffold post title:string body:text
94
- spin run gen # pick up the new migration (gen/migrations.rb)
95
- spin run db -- migrate # apply db/migrate, rewrite db/schema.rb
96
- spin run gen # regenerate gen/ from db/schema.rb, config/routes.rb and app/
97
- spin run server # http://127.0.0.1:3000
95
+ cybertrain db migrate # gen, apply db/migrate, gen again
96
+ cybertrain server # http://127.0.0.1:3000 (`cybertrain server 4000` for another port)
97
+ cybertrain build # dist/: the binary (views embedded) + public/
98
98
  ```
99
99
 
100
- Run `spin run gen` after changing the schema, the routes or a
101
- controller's instance variables and callbacks, and commit `gen/`.
102
- Views under `app/views/` are read at run time: edit them without
103
- rebuilding.
100
+ `spin run gen` (which `db`, `server` and `build` run for you)
101
+ regenerates `gen/` from db/schema.rb, config/routes.rb and app/;
102
+ commit `gen/`. In development views under `app/views/` are read at
103
+ run time: edit them without rebuilding. `cybertrain db status` and
104
+ `cybertrain db rollback 1` reach the other database commands.
105
+ `cybertrain db migrate` runs migrations through `bin/db.rb`; a
106
+ deployed `dist/#{package}` runs them with `./#{package} migrate`.
107
+
108
+ To deploy, copy `dist/` to a machine with the same OS and CPU, then
109
+ `cd dist && ./#{package} migrate && ./#{package}` (production by default;
110
+ set CYBERTRAIN_SECRET_KEY_BASE).
104
111
  MARKDOWN
105
112
  end
106
113
 
@@ -204,17 +211,18 @@ module Cybertrain
204
211
  CSS
205
212
  end
206
213
 
207
- def self.bin_server
214
+ def self.bin_app(package)
208
215
  <<~RUBY
209
216
  require "cybertrain"
210
- require_relative "../config/app"
217
+ require_relative "../gen/views" # embedded build: sets CYBERTRAIN_ENV=production by default
218
+ require_relative "../config/app" # so it comes before the config
211
219
  require_relative "../gen/app"
220
+ require_relative "../gen/migrations"
212
221
 
213
- app = Cybertrain::Application.new(
214
- router: Gen::Routes.build(Cybertrain::Router.new),
215
- url_resolver: Gen::Routes.url_resolver
216
- )
217
- app.run
222
+ exit(Cybertrain::Main.run("#{package}", ARGV,
223
+ router: Gen::Routes.build(Cybertrain::Router.new),
224
+ url_resolver: Gen::Routes.url_resolver,
225
+ views: Gen::Views::SOURCES))
218
226
  RUBY
219
227
  end
220
228
 
@@ -228,6 +236,7 @@ module Cybertrain
228
236
  RUBY
229
237
  end
230
238
 
239
+ # Development only, so migrations run before gen/models exist; production uses `./NAME migrate`.
231
240
  def self.bin_db
232
241
  <<~RUBY
233
242
  require "cybertrain"
@@ -317,7 +326,7 @@ module Cybertrain
317
326
  def destroy
318
327
  @#{s}.destroy
319
328
  flash[:notice] = "#{human} was successfully destroyed."
320
- redirect_to #{p}_path, status: :see_other
329
+ redirect_to #{res.index_helper}_path, status: :see_other
321
330
  end
322
331
 
323
332
  private
@@ -362,7 +371,7 @@ module Cybertrain
362
371
  end
363
372
  buf << "<p>\n"
364
373
  buf << " <%= link_to \"Edit this #{words}\", edit_#{s}_path(@#{s}) %> |\n"
365
- buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
374
+ buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.index_helper}_path %>\n"
366
375
  buf << "</p>\n\n"
367
376
  buf << "<%= button_to \"Destroy this #{words}\", #{s}_path(@#{s}), method: :delete %>\n"
368
377
  buf
@@ -373,7 +382,7 @@ module Cybertrain
373
382
  buf = +""
374
383
  buf << "<h1>New #{s.tr("_", " ")}</h1>\n\n"
375
384
  buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
376
- buf << "<p><%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %></p>\n"
385
+ buf << "<p><%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.index_helper}_path %></p>\n"
377
386
  buf
378
387
  end
379
388
 
@@ -384,7 +393,7 @@ module Cybertrain
384
393
  buf << "<%= render \"form\", #{s}: @#{s} %>\n\n"
385
394
  buf << "<p>\n"
386
395
  buf << " <%= link_to \"Show this #{s.tr("_", " ")}\", #{s}_path(@#{s}) %> |\n"
387
- buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.plural}_path %>\n"
396
+ buf << " <%= link_to \"Back to #{res.plural.tr("_", " ")}\", #{res.index_helper}_path %>\n"
388
397
  buf << "</p>\n"
389
398
  buf
390
399
  end