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