cybertrain 0.1.1 → 0.2.1
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 +4 -4
- data/README.md +233 -67
- data/cybertrain/app_name.rb +36 -0
- data/cybertrain/cli/build.rb +93 -16
- data/cybertrain/cli/new_app.rb +22 -12
- data/cybertrain/cli/scaffold.rb +78 -11
- data/cybertrain/cli/templates.rb +16 -20
- data/cybertrain/cli/toolchain.rb +787 -0
- data/cybertrain/cli.rb +114 -25
- data/cybertrain/generator/inflector.rb +154 -88
- data/cybertrain/ident.rb +171 -0
- data/cybertrain/version.rb +5 -1
- metadata +8 -3
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
# The Spinel toolchain the CLI runs `spin` with. `cybertrain new`, `db`,
|
|
2
|
+
# `server`, `build` and `spin` call Toolchain.ensure! before their first
|
|
3
|
+
# spin command: it uses a `spinel`/`spin` of the pinned release already on
|
|
4
|
+
# PATH, or the copy it keeps under ~/.cybertrain, or builds that copy from
|
|
5
|
+
# the release tag (git clone, make deps, make, make install), the same steps
|
|
6
|
+
# CI runs. `cybertrain setup` does it explicitly; `cybertrain doctor`
|
|
7
|
+
# reports.
|
|
8
|
+
#
|
|
9
|
+
# Plain Ruby: runs under CRuby (the gem) and compiles under Spinel
|
|
10
|
+
# (bin/cybertrain.rb), so it shells out with `system` and backticks and
|
|
11
|
+
# reads files instead of using Open3, RbConfig or Etc.
|
|
12
|
+
require "cybertrain/version"
|
|
13
|
+
|
|
14
|
+
module Cybertrain
|
|
15
|
+
module CLI
|
|
16
|
+
module Toolchain
|
|
17
|
+
SPINEL_GIT = "https://github.com/matz/spinel.git"
|
|
18
|
+
LOCK_WAIT_SECONDS = 3600
|
|
19
|
+
|
|
20
|
+
# ---- locations -----------------------------------------------------
|
|
21
|
+
|
|
22
|
+
# CYBERTRAIN_HOME, or ~/.cybertrain, as an absolute path (a leading
|
|
23
|
+
# `~` and relative paths are expanded). "" when neither CYBERTRAIN_HOME
|
|
24
|
+
# nor HOME is set.
|
|
25
|
+
def self.home
|
|
26
|
+
dir = ENV["CYBERTRAIN_HOME"].to_s
|
|
27
|
+
base = ENV["HOME"].to_s
|
|
28
|
+
dir = File.join(base, ".cybertrain") if dir == "" && base != ""
|
|
29
|
+
return "" if dir == ""
|
|
30
|
+
return "" if dir.start_with?("~") && base == ""
|
|
31
|
+
|
|
32
|
+
File.expand_path(dir)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.tag
|
|
36
|
+
Cybertrain::SPINEL_TAG
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `make install PREFIX=` target of the managed copy.
|
|
40
|
+
def self.prefix
|
|
41
|
+
File.join(home, "spinel", tag)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.bin_dir
|
|
45
|
+
File.join(prefix, "bin")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ~/.cybertrain/bin: `spinel` and `spin` symlinks to the managed release
|
|
49
|
+
# in use, so one PATH entry keeps working across a SPINEL_TAG bump.
|
|
50
|
+
def self.stable_bin_dir
|
|
51
|
+
File.join(home, "bin")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Written when an install finished and checked out; a half-updated
|
|
55
|
+
# prefix (an interrupted `setup --force`) has none and is rebuilt.
|
|
56
|
+
def self.stamp
|
|
57
|
+
File.join(prefix, ".cybertrain-complete")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.src_dir
|
|
61
|
+
File.join(home, "src", "spinel-#{tag}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.log_path
|
|
65
|
+
File.join(home, "log", "spinel-#{tag}-build.log")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.lock_dir
|
|
69
|
+
File.join(home, "spinel", "#{tag}.lock")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ---- discovery -----------------------------------------------------
|
|
73
|
+
|
|
74
|
+
# The release inside the parentheses of `spinel --version`, which come
|
|
75
|
+
# before the compiler field: "spinel 112bae85c1a2 (2026.09.12) [cc
|
|
76
|
+
# (Ubuntu 13.3.0) 13.3.0]" -> "2026.09.12". "" when there is none.
|
|
77
|
+
def self.release_of(version_line)
|
|
78
|
+
head = version_line.split(" [")[0].to_s
|
|
79
|
+
open = head.index("(")
|
|
80
|
+
close = head.index(")")
|
|
81
|
+
return "" if open.nil? || close.nil? || close <= open
|
|
82
|
+
|
|
83
|
+
head[open + 1, close - open - 1].to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The release the spinel binary at `path` reports; "" when it cannot run.
|
|
87
|
+
def self.release_at(path)
|
|
88
|
+
release_of(sh_read("#{shell_quote(path)} --version"))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# dir holds an executable spinel and spin, and that spinel is the pinned
|
|
92
|
+
# release.
|
|
93
|
+
def self.usable?(dir)
|
|
94
|
+
spinel = File.join(dir, "spinel")
|
|
95
|
+
spin = File.join(dir, "spin")
|
|
96
|
+
return false unless File.file?(spinel) && File.executable?(spinel)
|
|
97
|
+
return false unless File.file?(spin) && File.executable?(spin)
|
|
98
|
+
|
|
99
|
+
release_at(spinel) == tag
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The managed copy is complete and of the pinned release.
|
|
103
|
+
def self.managed_ok?
|
|
104
|
+
home != "" && usable?(bin_dir) && File.exist?(stamp)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The spinel PATH resolves to, as an absolute path; "" when none.
|
|
108
|
+
def self.spinel_on_path
|
|
109
|
+
found = sh_read("command -v spinel")
|
|
110
|
+
found == "" ? "" : File.expand_path(found)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A spinel of the pinned release, with the spin from the same
|
|
114
|
+
# directory, is already on PATH.
|
|
115
|
+
def self.on_path?
|
|
116
|
+
spinel = spinel_on_path
|
|
117
|
+
return false if spinel == ""
|
|
118
|
+
|
|
119
|
+
spin = sh_read("command -v spin")
|
|
120
|
+
return false if spin == ""
|
|
121
|
+
return false unless File.dirname(File.expand_path(spin)) == File.dirname(spinel)
|
|
122
|
+
|
|
123
|
+
release_at(spinel) == tag
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# The bin directory CYBERTRAIN_SPINEL_HOME names, absolute: the
|
|
127
|
+
# prefix's bin/, or the directory itself when it holds spinel directly.
|
|
128
|
+
# "" when the variable is unset.
|
|
129
|
+
def self.explicit_bin_dir
|
|
130
|
+
given = ENV["CYBERTRAIN_SPINEL_HOME"].to_s
|
|
131
|
+
return "" if given == ""
|
|
132
|
+
|
|
133
|
+
given = File.expand_path(given)
|
|
134
|
+
return given if File.file?(File.join(given, "spinel"))
|
|
135
|
+
|
|
136
|
+
File.join(given, "bin")
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.explicit_error(explicit)
|
|
140
|
+
"error: CYBERTRAIN_SPINEL_HOME=#{ENV["CYBERTRAIN_SPINEL_HOME"]} has no spinel #{tag} and spin in #{explicit}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ---- ensuring ------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
# Puts a toolchain of the pinned release on this process's PATH (so
|
|
146
|
+
# `spin` in a `system` call is that one), installing the managed copy
|
|
147
|
+
# first when nothing else qualifies. Returns false, after explaining,
|
|
148
|
+
# when it could not.
|
|
149
|
+
def self.ensure!
|
|
150
|
+
explicit = explicit_bin_dir
|
|
151
|
+
unless explicit == ""
|
|
152
|
+
return use(explicit) if usable?(explicit)
|
|
153
|
+
|
|
154
|
+
puts explicit_error(explicit)
|
|
155
|
+
return false
|
|
156
|
+
end
|
|
157
|
+
return true if on_path?
|
|
158
|
+
return use(bin_dir) if managed_ok?
|
|
159
|
+
return false unless install(false)
|
|
160
|
+
|
|
161
|
+
use(bin_dir)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# `cybertrain setup [--force]`, given the words after `setup`: the
|
|
165
|
+
# exit code.
|
|
166
|
+
def self.setup(args)
|
|
167
|
+
force = false
|
|
168
|
+
args.each do |arg|
|
|
169
|
+
if arg == "--force"
|
|
170
|
+
force = true
|
|
171
|
+
else
|
|
172
|
+
puts "usage: cybertrain setup [--force]"
|
|
173
|
+
return 1
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
explicit = explicit_bin_dir
|
|
177
|
+
unless explicit == ""
|
|
178
|
+
puts "note: --force is ignored while CYBERTRAIN_SPINEL_HOME is set" if force
|
|
179
|
+
if usable?(explicit) && path_problem(explicit) == ""
|
|
180
|
+
puts "spinel #{tag} is in #{explicit} (CYBERTRAIN_SPINEL_HOME); nothing to install"
|
|
181
|
+
return 0
|
|
182
|
+
end
|
|
183
|
+
puts(usable?(explicit) ? "error: #{path_problem(explicit)}" : explicit_error(explicit))
|
|
184
|
+
puts " unset it to let cybertrain install its own copy under #{prefix}"
|
|
185
|
+
return 1
|
|
186
|
+
end
|
|
187
|
+
if !force && on_path?
|
|
188
|
+
puts "spinel #{tag} is on PATH: #{spinel_on_path}"
|
|
189
|
+
puts "nothing to install"
|
|
190
|
+
return 0
|
|
191
|
+
end
|
|
192
|
+
if !force && managed_ok?
|
|
193
|
+
linked = link_stable_bin
|
|
194
|
+
puts "spinel #{tag} is installed in #{bin_dir}; nothing to install (--force rebuilds it)"
|
|
195
|
+
print_path_hint(linked)
|
|
196
|
+
return 0
|
|
197
|
+
end
|
|
198
|
+
install(force) ? 0 : 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Prepends dir to this process's PATH. False, after explaining, for a
|
|
202
|
+
# directory PATH cannot hold.
|
|
203
|
+
def self.use(dir)
|
|
204
|
+
problem = path_problem(dir)
|
|
205
|
+
unless problem == ""
|
|
206
|
+
puts "error: #{problem}"
|
|
207
|
+
return false
|
|
208
|
+
end
|
|
209
|
+
ENV["PATH"] = "#{dir}:#{ENV["PATH"]}"
|
|
210
|
+
true
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Why dir cannot go on PATH (a ':' in its name splits it into two
|
|
214
|
+
# entries), or "".
|
|
215
|
+
def self.path_problem(dir)
|
|
216
|
+
dir.include?(":") ? "#{dir} cannot go on PATH: its name contains ':'" : ""
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Points ~/.cybertrain/bin/{spinel,spin} at the managed release. False
|
|
220
|
+
# when the directory or a link could not be written (the toolchain
|
|
221
|
+
# itself still works: cybertrain puts bin_dir on PATH by itself).
|
|
222
|
+
def self.link_stable_bin
|
|
223
|
+
return false unless system("mkdir -p #{shell_quote(stable_bin_dir)} 2>/dev/null")
|
|
224
|
+
|
|
225
|
+
ok = true
|
|
226
|
+
["spinel", "spin"].each do |name|
|
|
227
|
+
ok = false unless system("ln -sfn #{shell_quote(File.join(bin_dir, name))} #{shell_quote(File.join(stable_bin_dir, name))} 2>/dev/null")
|
|
228
|
+
end
|
|
229
|
+
ok
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# `linked`: whether link_stable_bin succeeded; otherwise the hint names
|
|
233
|
+
# the release directory itself.
|
|
234
|
+
def self.print_path_hint(linked)
|
|
235
|
+
if linked
|
|
236
|
+
puts "cybertrain uses it by itself; `cybertrain spin ...` runs spin with it, or put it on PATH:"
|
|
237
|
+
puts " export PATH=\"#{stable_bin_dir}:$PATH\""
|
|
238
|
+
else
|
|
239
|
+
puts "cybertrain uses it by itself; `cybertrain spin ...` runs spin with it."
|
|
240
|
+
puts "note: could not write the links in #{stable_bin_dir}; to run spin directly, put the release on PATH:"
|
|
241
|
+
puts " export PATH=\"#{bin_dir}:$PATH\""
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The note for a spinel of another release on PATH, which is left
|
|
246
|
+
# alone (the framework is tested against one release); "" when PATH
|
|
247
|
+
# has none or has the pinned one.
|
|
248
|
+
def self.other_release_note
|
|
249
|
+
spinel = spinel_on_path
|
|
250
|
+
return "" if spinel == ""
|
|
251
|
+
|
|
252
|
+
release = release_at(spinel)
|
|
253
|
+
return "" if release == tag
|
|
254
|
+
|
|
255
|
+
shown = release == "" ? "an unknown release" : "release #{release}"
|
|
256
|
+
"note: #{spinel} is #{shown}; cybertrain #{Cybertrain::VERSION} is pinned to Spinel #{tag} and keeps its own copy in #{prefix}"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def self.note_other_release
|
|
260
|
+
note = other_release_note
|
|
261
|
+
puts note unless note == ""
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# ---- installing ----------------------------------------------------
|
|
265
|
+
|
|
266
|
+
# Builds and installs the pinned release into prefix. Returns false
|
|
267
|
+
# after printing why when a prerequisite is missing or a step fails.
|
|
268
|
+
def self.install(force)
|
|
269
|
+
unless home_problem == ""
|
|
270
|
+
puts "error: cannot build Spinel #{tag}: missing #{home_problem}"
|
|
271
|
+
return false
|
|
272
|
+
end
|
|
273
|
+
problems = missing_requirements
|
|
274
|
+
unless problems.empty?
|
|
275
|
+
puts "error: cannot build Spinel #{tag}: missing #{problems.join(", ")}"
|
|
276
|
+
install_hints.each { |line| puts " #{line}" }
|
|
277
|
+
return false
|
|
278
|
+
end
|
|
279
|
+
note_other_release
|
|
280
|
+
return false unless take_lock
|
|
281
|
+
|
|
282
|
+
begin
|
|
283
|
+
return true if !force && managed_ok? # another process finished it while we waited
|
|
284
|
+
|
|
285
|
+
puts "Installing Spinel #{tag} into #{prefix} (one-time; a few minutes)"
|
|
286
|
+
puts " log: #{log_path}"
|
|
287
|
+
system("mkdir -p #{shell_quote(File.dirname(log_path))} #{shell_quote(File.dirname(src_dir))} 2>/dev/null")
|
|
288
|
+
system("rm -rf #{shell_quote(src_dir)}")
|
|
289
|
+
unless prepare_files
|
|
290
|
+
puts "error: cannot write under #{home} (#{log_path}); set CYBERTRAIN_HOME to a writable directory"
|
|
291
|
+
return false
|
|
292
|
+
end
|
|
293
|
+
steps = [
|
|
294
|
+
["clone", "git clone -q --depth 1 --branch #{tag} #{SPINEL_GIT} #{shell_quote(src_dir)}"],
|
|
295
|
+
["deps", "cd #{shell_quote(src_dir)} && make deps"],
|
|
296
|
+
["build", "cd #{shell_quote(src_dir)} && make -j#{jobs}"],
|
|
297
|
+
["install", "cd #{shell_quote(src_dir)} && make install PREFIX=#{shell_quote(prefix)}"]
|
|
298
|
+
]
|
|
299
|
+
steps.each do |step|
|
|
300
|
+
return false unless still_holding_lock
|
|
301
|
+
puts " #{step[0]}: #{step[1]}"
|
|
302
|
+
next if system("(#{step[1]}) >> #{shell_quote(log_path)} 2>&1")
|
|
303
|
+
|
|
304
|
+
puts "error: Spinel #{step[0]} failed; the last lines of #{log_path}:"
|
|
305
|
+
tail(log_path, 25).each { |line| puts " | #{line}" }
|
|
306
|
+
return false
|
|
307
|
+
end
|
|
308
|
+
unless ENV["CYBERTRAIN_KEEP_SRC"].to_s == "1"
|
|
309
|
+
system("rm -rf #{shell_quote(src_dir)}")
|
|
310
|
+
system("rmdir #{shell_quote(File.dirname(src_dir))} 2>/dev/null")
|
|
311
|
+
end
|
|
312
|
+
unless usable?(bin_dir)
|
|
313
|
+
puts "error: the install finished but #{bin_dir} does not hold spinel #{tag} and spin (see #{log_path})"
|
|
314
|
+
return false
|
|
315
|
+
end
|
|
316
|
+
return false unless still_holding_lock
|
|
317
|
+
unless write_stamp
|
|
318
|
+
puts "error: cannot write #{stamp}; set CYBERTRAIN_HOME to a writable directory"
|
|
319
|
+
return false
|
|
320
|
+
end
|
|
321
|
+
linked = link_stable_bin
|
|
322
|
+
puts ""
|
|
323
|
+
puts "installed spinel #{tag} in #{bin_dir}"
|
|
324
|
+
print_path_hint(linked)
|
|
325
|
+
true
|
|
326
|
+
ensure
|
|
327
|
+
release_lock
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# What stops the managed copy from living under `home`: no home at
|
|
332
|
+
# all, or a path Spinel's Makefile or PATH cannot take. "" when fine.
|
|
333
|
+
def self.home_problem
|
|
334
|
+
return "a directory to keep Spinel in (set CYBERTRAIN_HOME or HOME)" if home == ""
|
|
335
|
+
return "" if prefix.match(/[\s'"`$\\;&|<>()*?:]/).nil?
|
|
336
|
+
|
|
337
|
+
"a CYBERTRAIN_HOME without spaces, quotes, $ or : in its path (now #{home})"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Drops the old stamp and empties the log; false when either cannot
|
|
341
|
+
# be written (a read-only log/ or prefix left by another user) or the
|
|
342
|
+
# source directory's parent is missing.
|
|
343
|
+
def self.prepare_files
|
|
344
|
+
begin
|
|
345
|
+
File.delete(stamp) if File.exist?(stamp)
|
|
346
|
+
File.write(log_path, "")
|
|
347
|
+
rescue StandardError
|
|
348
|
+
return false
|
|
349
|
+
end
|
|
350
|
+
File.directory?(File.dirname(src_dir))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def self.write_stamp
|
|
354
|
+
begin
|
|
355
|
+
File.write(stamp, "#{tag}\n")
|
|
356
|
+
rescue StandardError
|
|
357
|
+
return false
|
|
358
|
+
end
|
|
359
|
+
true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# False, after explaining, when this process no longer holds the lock
|
|
363
|
+
# (someone removed it by hand and another cybertrain took it): this
|
|
364
|
+
# build stops instead of racing the other into the same prefix.
|
|
365
|
+
def self.still_holding_lock
|
|
366
|
+
return true if holding_lock?
|
|
367
|
+
|
|
368
|
+
puts "error: #{lock_dir} is no longer this cybertrain's lock (another build of Spinel #{tag} took it over); stopping"
|
|
369
|
+
false
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def self.holding_lock?
|
|
373
|
+
lock_pid == Process.pid.to_s
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# One build at a time per home. The lock is a directory holding the
|
|
377
|
+
# builder's pid, taken by renaming a staging directory (already
|
|
378
|
+
# holding the pid) onto the lock path: rename is atomic and refuses to
|
|
379
|
+
# replace a non-empty directory, so a lock is never seen half-made.
|
|
380
|
+
#
|
|
381
|
+
# A lock whose holder is gone (lock_stale?) gets taken over. Removing
|
|
382
|
+
# a lock, whether by its holder or by a waiter taking it over, first
|
|
383
|
+
# needs the `reclaim` marker inside it: an atomic mkdir that only one
|
|
384
|
+
# process wins per lock directory. A waiter that saw a stale lock
|
|
385
|
+
# therefore cannot move a fresh lock that replaced it in the meantime
|
|
386
|
+
# (it holds no marker in that one), and it checks the staleness again
|
|
387
|
+
# once it holds the marker, when nothing else can touch the directory.
|
|
388
|
+
# A marker left behind by a killed process keeps that lock in place
|
|
389
|
+
# until someone removes the lock by hand, as the waiting message says.
|
|
390
|
+
def self.take_lock
|
|
391
|
+
parent = File.dirname(lock_dir)
|
|
392
|
+
unless system("mkdir -p #{shell_quote(parent)} 2>/dev/null") && File.directory?(parent)
|
|
393
|
+
puts "error: cannot create #{parent}; set CYBERTRAIN_HOME to a writable directory"
|
|
394
|
+
return false
|
|
395
|
+
end
|
|
396
|
+
staging = "#{lock_dir}.#{Process.pid}"
|
|
397
|
+
system("rm -rf #{shell_quote(staging)}")
|
|
398
|
+
begin
|
|
399
|
+
Dir.mkdir(staging)
|
|
400
|
+
File.write(File.join(staging, "pid"), Process.pid.to_s)
|
|
401
|
+
File.write(File.join(staging, "start"), process_start(Process.pid.to_s))
|
|
402
|
+
rescue StandardError
|
|
403
|
+
puts "error: cannot write in #{parent}; set CYBERTRAIN_HOME to a writable directory"
|
|
404
|
+
return false
|
|
405
|
+
end
|
|
406
|
+
waited = 0
|
|
407
|
+
while true
|
|
408
|
+
taken = false
|
|
409
|
+
begin
|
|
410
|
+
File.rename(staging, lock_dir)
|
|
411
|
+
taken = true
|
|
412
|
+
rescue StandardError
|
|
413
|
+
taken = false # held by someone: rename cannot replace a non-empty directory
|
|
414
|
+
end
|
|
415
|
+
return true if taken
|
|
416
|
+
next if reclaim_stale_lock
|
|
417
|
+
|
|
418
|
+
puts waiting_message if waited == 0
|
|
419
|
+
if waited >= LOCK_WAIT_SECONDS
|
|
420
|
+
puts "error: gave up waiting; remove #{lock_dir} if no cybertrain is building Spinel"
|
|
421
|
+
system("rm -rf #{shell_quote(staging)}")
|
|
422
|
+
return false
|
|
423
|
+
end
|
|
424
|
+
sleep 5
|
|
425
|
+
waited += 5
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def self.waiting_message
|
|
430
|
+
if lock_stale?
|
|
431
|
+
"a stale lock (#{lock_dir}) is being removed by another cybertrain; waiting (remove it if nothing is running)"
|
|
432
|
+
else
|
|
433
|
+
"another cybertrain (pid #{lock_pid}) is installing Spinel #{tag}; waiting (remove #{lock_dir} if no build is running)"
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Removes the lock when it is stale and this process wins its marker;
|
|
438
|
+
# true when it did, and the lock path is free for the moment.
|
|
439
|
+
def self.reclaim_stale_lock
|
|
440
|
+
return false unless lock_stale?
|
|
441
|
+
return false unless mark_lock
|
|
442
|
+
# Checked again with the marker held: the directory at lock_dir is
|
|
443
|
+
# now the marked one, and only this process can move it.
|
|
444
|
+
return remove_lock if lock_stale?
|
|
445
|
+
|
|
446
|
+
unmark_lock
|
|
447
|
+
false
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def self.marker
|
|
451
|
+
File.join(lock_dir, "reclaim")
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
# True when this process now holds the lock's marker.
|
|
455
|
+
def self.mark_lock
|
|
456
|
+
begin
|
|
457
|
+
Dir.mkdir(marker)
|
|
458
|
+
rescue StandardError
|
|
459
|
+
return false # marked by another process, or the lock is gone
|
|
460
|
+
end
|
|
461
|
+
true
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def self.unmark_lock
|
|
465
|
+
begin
|
|
466
|
+
Dir.rmdir(marker)
|
|
467
|
+
rescue StandardError
|
|
468
|
+
nil
|
|
469
|
+
end
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Moves the (marked) lock aside and deletes it; true when it was there.
|
|
473
|
+
def self.remove_lock
|
|
474
|
+
aside = "#{lock_dir}.old.#{Process.pid}"
|
|
475
|
+
system("rm -rf #{shell_quote(aside)}")
|
|
476
|
+
begin
|
|
477
|
+
File.rename(lock_dir, aside)
|
|
478
|
+
rescue StandardError
|
|
479
|
+
return false
|
|
480
|
+
end
|
|
481
|
+
system("rm -rf #{shell_quote(aside)}")
|
|
482
|
+
true
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# The lock exists but its holder is gone or unrecorded. A holder that
|
|
486
|
+
# is alive keeps the lock however long its build takes: waiters wait
|
|
487
|
+
# LOCK_WAIT_SECONDS, then say which lock to remove by hand.
|
|
488
|
+
def self.lock_stale?
|
|
489
|
+
return false unless File.directory?(lock_dir)
|
|
490
|
+
|
|
491
|
+
pid = lock_pid
|
|
492
|
+
return true if pid.match(/\A[1-9][0-9]*\z/).nil?
|
|
493
|
+
|
|
494
|
+
!holder_alive?(pid, lock_start)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
# Whether the process that took the lock is still running. When ps can
|
|
498
|
+
# say when pid started, that must be the start time the lock recorded:
|
|
499
|
+
# a pid another process got after the holder died is not the holder.
|
|
500
|
+
# Without ps, or without a recorded start, the pid existing has to do.
|
|
501
|
+
def self.holder_alive?(pid, start)
|
|
502
|
+
now = process_start(pid)
|
|
503
|
+
return now == start unless now == "" || start == ""
|
|
504
|
+
|
|
505
|
+
# An explicit Boolean: under Spinel 2026.09.12 a method that returns
|
|
506
|
+
# `now == start` on one path and system's result on another returns
|
|
507
|
+
# false for the latter.
|
|
508
|
+
system("kill -0 #{pid} 2>/dev/null") ? true : false
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# When pid started, as ps prints it; "" when there is no such process,
|
|
512
|
+
# or no ps that can tell.
|
|
513
|
+
def self.process_start(pid)
|
|
514
|
+
sh_read("ps -o lstart= -p #{pid}")
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Releases the lock this process holds. Like any removal it needs the
|
|
518
|
+
# marker; a waiter that marked the lock while checking its staleness
|
|
519
|
+
# unmarks it right away, so a few short retries cover that. A marker
|
|
520
|
+
# that stays means a taker-over owns the lock now and removes it.
|
|
521
|
+
def self.release_lock
|
|
522
|
+
return unless holding_lock?
|
|
523
|
+
|
|
524
|
+
tries = 0
|
|
525
|
+
while tries < 5
|
|
526
|
+
if mark_lock
|
|
527
|
+
if holding_lock?
|
|
528
|
+
remove_lock
|
|
529
|
+
else
|
|
530
|
+
unmark_lock
|
|
531
|
+
end
|
|
532
|
+
return
|
|
533
|
+
end
|
|
534
|
+
return unless File.directory?(lock_dir)
|
|
535
|
+
|
|
536
|
+
sleep 0.2
|
|
537
|
+
tries += 1
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
# The pid recorded in the lock, and its holder's start time; "" when
|
|
542
|
+
# there is none (or the lock went away while being looked at).
|
|
543
|
+
def self.lock_pid
|
|
544
|
+
lock_file("pid")
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def self.lock_start
|
|
548
|
+
lock_file("start")
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def self.lock_file(name)
|
|
552
|
+
path = File.join(lock_dir, name)
|
|
553
|
+
return "" unless File.exist?(path)
|
|
554
|
+
|
|
555
|
+
begin
|
|
556
|
+
File.read(path).strip
|
|
557
|
+
rescue StandardError
|
|
558
|
+
""
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# `make -j` width: the CPU count, at least 1.
|
|
563
|
+
def self.jobs
|
|
564
|
+
n = sh_read("nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null").to_i
|
|
565
|
+
n < 1 ? 1 : n
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
# ---- requirements --------------------------------------------------
|
|
569
|
+
|
|
570
|
+
# The C compiler command, possibly with arguments ("ccache gcc").
|
|
571
|
+
def self.cc
|
|
572
|
+
given = ENV["CC"].to_s
|
|
573
|
+
given == "" ? "cc" : given
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def self.cc_program
|
|
577
|
+
cc.split(" ")[0].to_s
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
def self.have_command?(name)
|
|
581
|
+
sh_read("command -v #{shell_quote(name)}") != ""
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# A C program using `include` and `link` compiles and links: the
|
|
585
|
+
# header and the library are both installed.
|
|
586
|
+
def self.compiles?(include, body, link)
|
|
587
|
+
dir = sh_read("mktemp -d 2>/dev/null")
|
|
588
|
+
return false if dir == ""
|
|
589
|
+
|
|
590
|
+
source = File.join(dir, "probe.c")
|
|
591
|
+
File.write(source, "#include <#{include}>\nint main(void) { return #{body}; }\n")
|
|
592
|
+
ok = system("#{cc} #{shell_quote(source)} -o #{shell_quote(File.join(dir, "probe"))} #{link} > /dev/null 2>&1") ? true : false
|
|
593
|
+
system("rm -rf #{shell_quote(dir)}")
|
|
594
|
+
ok
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def self.sqlite_ok?
|
|
598
|
+
compiles?("sqlite3.h", "sqlite3_libversion_number() > 0 ? 0 : 1", "-lsqlite3")
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Spinel's own Makefile finds a Homebrew OpenSSL by itself; this probe
|
|
602
|
+
# only decides whether to warn.
|
|
603
|
+
def self.openssl_ok?
|
|
604
|
+
return true if compiles?("openssl/ssl.h", "TLS_client_method() != 0 ? 0 : 1", "-lssl -lcrypto")
|
|
605
|
+
|
|
606
|
+
brew = sh_read("brew --prefix openssl@3 2>/dev/null")
|
|
607
|
+
return false if brew == ""
|
|
608
|
+
|
|
609
|
+
compiles?("openssl/ssl.h", "TLS_client_method() != 0 ? 0 : 1",
|
|
610
|
+
"-I#{shell_quote(File.join(brew, "include"))} -L#{shell_quote(File.join(brew, "lib"))} -lssl -lcrypto")
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# What a Spinel build cannot do without, by name. OpenSSL is not in
|
|
614
|
+
# this list: without its headers Spinel builds without its openssl
|
|
615
|
+
# package, which cybertrain does not use.
|
|
616
|
+
def self.missing_requirements
|
|
617
|
+
missing = Array.new(0) { "" }
|
|
618
|
+
if platform == "darwin" && !system("xcode-select -p > /dev/null 2>&1")
|
|
619
|
+
missing << "the Xcode Command Line Tools (xcode-select --install)"
|
|
620
|
+
return missing
|
|
621
|
+
end
|
|
622
|
+
missing << "git" unless have_command?("git")
|
|
623
|
+
missing << "make" unless have_command?("make")
|
|
624
|
+
missing << "curl" unless have_command?("curl")
|
|
625
|
+
missing_app_requirements.each { |name| missing << name }
|
|
626
|
+
missing
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
# What building an application needs even once Spinel is installed.
|
|
630
|
+
def self.missing_app_requirements
|
|
631
|
+
missing = Array.new(0) { "" }
|
|
632
|
+
unless have_command?(cc_program)
|
|
633
|
+
missing << "a C compiler (#{cc})"
|
|
634
|
+
return missing
|
|
635
|
+
end
|
|
636
|
+
missing << "the SQLite 3 headers and library" unless sqlite_ok?
|
|
637
|
+
missing
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# "linux", "darwin" or "" from uname, plus the distribution family from
|
|
641
|
+
# /etc/os-release (debian, rhel, arch, alpine, suse) for the hint.
|
|
642
|
+
def self.platform
|
|
643
|
+
os = sh_read("uname -s").downcase
|
|
644
|
+
return "darwin" if os.start_with?("darwin")
|
|
645
|
+
return "" unless os.start_with?("linux")
|
|
646
|
+
|
|
647
|
+
os_release = File.exist?("/etc/os-release") ? File.read("/etc/os-release").downcase : ""
|
|
648
|
+
return "debian" if os_release.include?("debian") || os_release.include?("ubuntu")
|
|
649
|
+
return "rhel" if os_release.include?("rhel") || os_release.include?("fedora") || os_release.include?("centos")
|
|
650
|
+
return "arch" if os_release.include?("arch")
|
|
651
|
+
return "alpine" if os_release.include?("alpine")
|
|
652
|
+
return "suse" if os_release.include?("suse")
|
|
653
|
+
|
|
654
|
+
"linux"
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# The command that installs every requirement on this platform.
|
|
658
|
+
def self.install_hints
|
|
659
|
+
case platform
|
|
660
|
+
when "debian"
|
|
661
|
+
["sudo apt-get update && sudo apt-get install -y build-essential git curl libsqlite3-dev libssl-dev"]
|
|
662
|
+
when "rhel"
|
|
663
|
+
["sudo dnf install -y gcc make git curl sqlite-devel openssl-devel"]
|
|
664
|
+
when "arch"
|
|
665
|
+
["sudo pacman -S --needed base-devel git curl sqlite openssl"]
|
|
666
|
+
when "alpine"
|
|
667
|
+
["sudo apk add build-base git curl sqlite-dev openssl-dev"]
|
|
668
|
+
when "suse"
|
|
669
|
+
["sudo zypper install -y gcc make git curl sqlite3-devel libopenssl-devel"]
|
|
670
|
+
when "darwin"
|
|
671
|
+
["xcode-select --install # cc, make, git, curl and the SQLite headers",
|
|
672
|
+
"brew install openssl@3 # optional: Spinel's openssl package"]
|
|
673
|
+
else
|
|
674
|
+
["install git, make, curl, a C compiler and the SQLite 3 development headers with your package manager"]
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# ---- doctor --------------------------------------------------------
|
|
679
|
+
|
|
680
|
+
# `cybertrain doctor`: prints one line per check. The exit code is 0
|
|
681
|
+
# when a Spinel of the pinned release is usable and applications can
|
|
682
|
+
# be built, or when nothing stops `cybertrain setup` from building
|
|
683
|
+
# one; 1 otherwise.
|
|
684
|
+
def self.doctor
|
|
685
|
+
puts "cybertrain #{Cybertrain::VERSION}, pinned to Spinel #{tag}"
|
|
686
|
+
puts "home #{home == "" ? "(unset: set CYBERTRAIN_HOME or HOME)" : home}"
|
|
687
|
+
report("git", have_command?("git"), sh_read("command -v git"))
|
|
688
|
+
report("make", have_command?("make"), sh_read("command -v make"))
|
|
689
|
+
report("curl", have_command?("curl"), sh_read("command -v curl"))
|
|
690
|
+
report("cc", have_command?(cc_program), sh_read("#{cc} --version 2>/dev/null | head -n 1"))
|
|
691
|
+
if have_command?(cc_program)
|
|
692
|
+
report("sqlite3", sqlite_ok?, sqlite_ok? ? "headers and library found" : "sqlite3.h or -lsqlite3 missing")
|
|
693
|
+
if openssl_ok?
|
|
694
|
+
report("openssl", true, "headers and library found")
|
|
695
|
+
else
|
|
696
|
+
info("openssl", "not found: Spinel builds without its openssl package, which cybertrain does not use")
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
ready = doctor_spinel
|
|
700
|
+
problems = doctor_problems(ready)
|
|
701
|
+
return 0 if problems.empty?
|
|
702
|
+
|
|
703
|
+
puts ""
|
|
704
|
+
puts "missing: #{problems.join(", ")}"
|
|
705
|
+
install_hints.each { |line| puts " #{line}" }
|
|
706
|
+
1
|
|
707
|
+
end
|
|
708
|
+
|
|
709
|
+
# What `cybertrain setup` would still miss: with a usable Spinel
|
|
710
|
+
# (ready), only what building an application needs; without one, the
|
|
711
|
+
# build tools and somewhere to keep the managed copy.
|
|
712
|
+
def self.doctor_problems(ready)
|
|
713
|
+
return missing_app_requirements if ready
|
|
714
|
+
|
|
715
|
+
problems = missing_requirements
|
|
716
|
+
problems << "a usable CYBERTRAIN_SPINEL_HOME" unless explicit_bin_dir == ""
|
|
717
|
+
problems << home_problem unless home_problem == ""
|
|
718
|
+
problems
|
|
719
|
+
end
|
|
720
|
+
|
|
721
|
+
# The spinel lines; true when one of the pinned release is usable.
|
|
722
|
+
def self.doctor_spinel
|
|
723
|
+
explicit = explicit_bin_dir
|
|
724
|
+
unless explicit == ""
|
|
725
|
+
ok = usable?(explicit) && path_problem(explicit) == ""
|
|
726
|
+
detail = "#{explicit} (CYBERTRAIN_SPINEL_HOME, #{release_label(File.join(explicit, "spinel"))})"
|
|
727
|
+
detail = "#{detail}; #{path_problem(explicit)}" unless path_problem(explicit) == ""
|
|
728
|
+
report("spinel", ok, detail)
|
|
729
|
+
return ok
|
|
730
|
+
end
|
|
731
|
+
found = false
|
|
732
|
+
on_path = spinel_on_path
|
|
733
|
+
if on_path != ""
|
|
734
|
+
if on_path?
|
|
735
|
+
report("spinel", true, "#{on_path} on PATH (#{release_label(on_path)})")
|
|
736
|
+
found = true
|
|
737
|
+
else
|
|
738
|
+
info("spinel", "#{on_path} on PATH is #{release_label(on_path)}; not used (pinned to #{tag})")
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
if managed_ok?
|
|
742
|
+
report("spinel", true, "#{bin_dir} (managed, #{tag})")
|
|
743
|
+
found = true
|
|
744
|
+
elsif !found && home == ""
|
|
745
|
+
info("spinel", "not installed; set CYBERTRAIN_HOME or HOME, then `cybertrain setup` builds #{tag}")
|
|
746
|
+
elsif !found
|
|
747
|
+
info("spinel", "not installed; `cybertrain setup` builds #{tag} into #{prefix}")
|
|
748
|
+
end
|
|
749
|
+
found
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
def self.release_label(spinel)
|
|
753
|
+
release = release_at(spinel)
|
|
754
|
+
release == "" ? "release unknown" : "release #{release}"
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def self.report(name, ok, detail)
|
|
758
|
+
puts "#{name.ljust(9)} #{(ok ? "ok" : "MISSING").ljust(7)} #{detail}"
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
def self.info(name, detail)
|
|
762
|
+
puts "#{name.ljust(9)} #{"--".ljust(7)} #{detail}"
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# ---- shell helpers -------------------------------------------------
|
|
766
|
+
|
|
767
|
+
# stdout of a shell command (a pipeline or an `a || b` list too),
|
|
768
|
+
# stripped; "" when the command fails, whatever it printed (a spinel
|
|
769
|
+
# that prints its version and then dies is not a usable one).
|
|
770
|
+
def self.sh_read(command)
|
|
771
|
+
`out=$( (#{command}) 2>/dev/null ) && printf '%s' "$out"`.strip
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def self.tail(path, count)
|
|
775
|
+
return Array.new(0) { "" } unless File.exist?(path)
|
|
776
|
+
|
|
777
|
+
lines = File.read(path).split("\n")
|
|
778
|
+
from = lines.size > count ? lines.size - count : 0
|
|
779
|
+
lines[from, lines.size - from]
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def self.shell_quote(text)
|
|
783
|
+
"'#{text.gsub("'", "'\\\\''")}'"
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|