dr_talon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +60 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +270 -0
- data/Rakefile +10 -0
- data/exe/talon +6 -0
- data/lib/dr_talon/cli/engine.rb +274 -0
- data/lib/dr_talon/cli/generate.rb +29 -0
- data/lib/dr_talon/cli.rb +343 -0
- data/lib/dr_talon/config.rb +29 -0
- data/lib/dr_talon/credentials.rb +94 -0
- data/lib/dr_talon/deps/dependency.rb +238 -0
- data/lib/dr_talon/deps/git.rb +69 -0
- data/lib/dr_talon/deps/installer.rb +207 -0
- data/lib/dr_talon/deps/manifest.rb +220 -0
- data/lib/dr_talon/deps/tree.rb +97 -0
- data/lib/dr_talon/deps.rb +62 -0
- data/lib/dr_talon/doctor.rb +161 -0
- data/lib/dr_talon/engine/fetchers.rb +251 -0
- data/lib/dr_talon/engine/git_ignore.rb +63 -0
- data/lib/dr_talon/engine/importer.rb +103 -0
- data/lib/dr_talon/engine/inspector.rb +113 -0
- data/lib/dr_talon/engine/installation.rb +77 -0
- data/lib/dr_talon/engine/materializer.rb +172 -0
- data/lib/dr_talon/engine/spec.rb +38 -0
- data/lib/dr_talon/engine/store.rb +119 -0
- data/lib/dr_talon/engine.rb +50 -0
- data/lib/dr_talon/errors.rb +5 -0
- data/lib/dr_talon/file_copy.rb +49 -0
- data/lib/dr_talon/generators/new_project.rb +289 -0
- data/lib/dr_talon/generators/talon_code.rb +92 -0
- data/lib/dr_talon/http.rb +120 -0
- data/lib/dr_talon/paths.rb +115 -0
- data/lib/dr_talon/project.rb +101 -0
- data/lib/dr_talon/runner.rb +188 -0
- data/lib/dr_talon/talon_framework.rb +103 -0
- data/lib/dr_talon/templates/bare/main_tests.rb.tt +7 -0
- data/lib/dr_talon/templates/bare/rubocop.yml.tt +8 -0
- data/lib/dr_talon/templates/bare/solargraph.yml.tt +24 -0
- data/lib/dr_talon/templates/lib/app/main.rb.tt +13 -0
- data/lib/dr_talon/templates/lib/lib/library.rb.tt +4 -0
- data/lib/dr_talon/templates/lib/lib/version.rb.tt +3 -0
- data/lib/dr_talon/templates/lib/rubocop.yml.tt +9 -0
- data/lib/dr_talon/templates/lib/solargraph.yml.tt +23 -0
- data/lib/dr_talon/templates/lib/test/test_helper.rb.tt +1 -0
- data/lib/dr_talon/templates/lib/test/tests.rb.tt +8 -0
- data/lib/dr_talon/templates/shared/Gemfile.tt +10 -0
- data/lib/dr_talon/templates/shared/README.md.tt +45 -0
- data/lib/dr_talon/templates/shared/editorconfig.tt +9 -0
- data/lib/dr_talon/templates/shared/gitignore.tt +19 -0
- data/lib/dr_talon/templates/talon/game/dependencies.rb.tt +3 -0
- data/lib/dr_talon/templates/talon/game/main.rb.tt +9 -0
- data/lib/dr_talon/templates/talon/game/spin.rb.tt +7 -0
- data/lib/dr_talon/templates/talon/game/title.rb.tt +8 -0
- data/lib/dr_talon/templates/talon/game/title_tests.rb.tt +16 -0
- data/lib/dr_talon/templates/talon/generate/component.rb.tt +19 -0
- data/lib/dr_talon/templates/talon/generate/component_tests.rb.tt +15 -0
- data/lib/dr_talon/templates/talon/generate/prefab.rb.tt +11 -0
- data/lib/dr_talon/templates/talon/generate/prefab_tests.rb.tt +15 -0
- data/lib/dr_talon/templates/talon/generate/scene.rb.tt +14 -0
- data/lib/dr_talon/templates/talon/generate/scene_tests.rb.tt +13 -0
- data/lib/dr_talon/test_report.rb +76 -0
- data/lib/dr_talon/version.rb +5 -0
- data/lib/dr_talon.rb +17 -0
- data/sig/dr_talon.rbs +524 -0
- metadata +142 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
# +talon generate ...+: Talon framework code, Rails-generator style.
|
|
8
|
+
class Generate < Thor
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"scene" => "A Talon::Scene: one screen or level, entered with game.change_scene",
|
|
15
|
+
"component" => "A Talon::Component: behaviour attached to game objects with go.add",
|
|
16
|
+
"prefab" => "A prefab: a reusable game object setup, passed to spawn"
|
|
17
|
+
}.each do |kind, description|
|
|
18
|
+
desc "#{kind} NAME", description
|
|
19
|
+
method_option :skip_test, type: :boolean, default: false, desc: "Don't create a test"
|
|
20
|
+
method_option :force, type: :boolean, default: false, desc: "Overwrite existing files"
|
|
21
|
+
define_method(kind) do |name|
|
|
22
|
+
Generators::TalonCode.new([kind, name], options, shell: shell).invoke_all
|
|
23
|
+
rescue DrTalon::Error => e
|
|
24
|
+
raise Thor::Error, e.message
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/dr_talon/cli.rb
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "cli/engine"
|
|
5
|
+
require_relative "cli/generate"
|
|
6
|
+
require_relative "generators/new_project"
|
|
7
|
+
require_relative "generators/talon_code"
|
|
8
|
+
|
|
9
|
+
module DrTalon
|
|
10
|
+
# Entry point for the +talon+ executable.
|
|
11
|
+
class CLI < Thor
|
|
12
|
+
package_name "talon"
|
|
13
|
+
|
|
14
|
+
map %w[-v --version] => :version
|
|
15
|
+
|
|
16
|
+
# Exit with a non-zero status on errors and unknown commands.
|
|
17
|
+
def self.exit_on_failure?
|
|
18
|
+
true
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "version", "Print the talon version"
|
|
22
|
+
def version
|
|
23
|
+
say "talon #{VERSION}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
desc "new PATH", "Create a DragonRuby game (or a library with --lib) with a pinned engine"
|
|
27
|
+
long_desc <<~DESC
|
|
28
|
+
Creates PATH with a game in DragonRuby's layout (mygame/, started from the
|
|
29
|
+
engine's own sample game) or, with --lib, a library whose root is the game
|
|
30
|
+
directory. Pins and installs a cached engine (the newest unless --engine),
|
|
31
|
+
fills in game_metadata.txt, adds editor tooling, then runs git init and
|
|
32
|
+
bundle install.
|
|
33
|
+
|
|
34
|
+
You're asked whether to commit the engine unless you pass --commit-engine
|
|
35
|
+
or --no-commit-engine. If not committed, it's gitignored and anyone cloning
|
|
36
|
+
the project restores it with `talon engine use`.
|
|
37
|
+
DESC
|
|
38
|
+
method_options.merge!(Generators::NewProject.class_options)
|
|
39
|
+
def new(path)
|
|
40
|
+
Generators::NewProject.new([path], options, shell: shell).invoke_all
|
|
41
|
+
rescue DrTalon::Error => e
|
|
42
|
+
raise Thor::Error, e.message
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `run` is reserved by Thor, so the command method has another name.
|
|
46
|
+
map "run" => :run_game
|
|
47
|
+
stop_on_unknown_option! :run_game
|
|
48
|
+
|
|
49
|
+
desc "run [ARGS...]", "Run the game (extra arguments go to dragonruby, e.g. --record)"
|
|
50
|
+
def run_game(*args)
|
|
51
|
+
exec_in_project { |runner| runner.run_command(args) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
desc "test [FILES...]", "Run DragonRuby tests (*_tests.rb in test/ and tests/ by default)"
|
|
55
|
+
long_desc <<~DESC
|
|
56
|
+
Runs each test file with `dragonruby --test` and reports passes and
|
|
57
|
+
failures. DragonRuby always exits 0, so talon reads its output and exits
|
|
58
|
+
non-zero when a test fails, a file doesn't load, a file has no tests, or a
|
|
59
|
+
run times out.
|
|
60
|
+
|
|
61
|
+
FILES may be files or directories inside the game directory. By default
|
|
62
|
+
every *_tests.rb under test/ and tests/ runs. Tests run without a window
|
|
63
|
+
unless you pass --no-headless.
|
|
64
|
+
DESC
|
|
65
|
+
method_option :headless, type: :boolean, default: true, desc: "Run without a window or audio (SDL dummy drivers)"
|
|
66
|
+
method_option :timeout, type: :numeric, default: 120, desc: "Seconds before a test file's run is stopped"
|
|
67
|
+
method_option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Print DragonRuby's full output"
|
|
68
|
+
def test(*paths)
|
|
69
|
+
project = with_errors { Project.locate }
|
|
70
|
+
runner = Runner.new(project)
|
|
71
|
+
files = with_errors { runner.test_files(paths) }
|
|
72
|
+
if files.empty?
|
|
73
|
+
raise Thor::Error, "No #{Runner::TEST_PATTERN} files in #{Runner::TEST_DIRS.join(" or ")} under #{project.game_dir}."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
reports = files.map do |file|
|
|
77
|
+
report = with_errors { runner.run_test(file, timeout: options[:timeout], headless: options[:headless]) }
|
|
78
|
+
print_test_report(file, report)
|
|
79
|
+
report
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
print_test_summary(reports)
|
|
83
|
+
exit(1) unless reports.all?(&:passed?)
|
|
84
|
+
rescue Interrupt
|
|
85
|
+
say "\nInterrupted.", :yellow
|
|
86
|
+
exit(130)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
desc "build [ARGS...]", "Package the game for every platform into builds/ (dragonruby-publish --only-package)"
|
|
90
|
+
stop_on_unknown_option! :build
|
|
91
|
+
def build(*args)
|
|
92
|
+
exec_in_project do |runner, project|
|
|
93
|
+
unset = Doctor.unset_metadata(project)
|
|
94
|
+
if unset&.any?
|
|
95
|
+
say "Warning: game_metadata.txt doesn't set #{unset.join(", ")}; dragonruby-publish needs them.", :yellow
|
|
96
|
+
end
|
|
97
|
+
runner.build_command(args)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
desc "serve [DIR]", "Serve an HTML5 build locally with dragonruby-httpd (default: the newest in builds/)"
|
|
102
|
+
method_option :port, type: :numeric, default: 8080, desc: "Port to listen on"
|
|
103
|
+
def serve(dir = nil)
|
|
104
|
+
exec_in_project do |runner|
|
|
105
|
+
dir ||= runner.html5_build || raise(Error, "No HTML5 build in builds/. Run `talon build` first.")
|
|
106
|
+
say "Serving #{dir} at http://localhost:#{options[:port]}/"
|
|
107
|
+
runner.serve_command(dir, options[:port])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
desc "clean", "Delete builds/, logs/, tmp/ and errors/ (and their mygame/ counterparts)"
|
|
112
|
+
def clean
|
|
113
|
+
project = with_errors { Project.locate }
|
|
114
|
+
removed = Runner.new(project).clean
|
|
115
|
+
if removed.empty?
|
|
116
|
+
say "Nothing to clean."
|
|
117
|
+
else
|
|
118
|
+
removed.each { |dir| say_status :remove, dir, :red }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
desc "install", "Install the pinned engine (if missing) and the dependencies in dependencies.rb"
|
|
123
|
+
long_desc <<~DESC
|
|
124
|
+
Run after cloning a project. Installs the engine pinned in .dragonruby-version
|
|
125
|
+
if it isn't installed yet, then vendors every dependency in the game
|
|
126
|
+
directory's dependencies.rb at its locked version. Dependencies without a
|
|
127
|
+
lock yet are resolved and locked.
|
|
128
|
+
|
|
129
|
+
Vendored files edited since talon installed them stop the install unless
|
|
130
|
+
you pass --force.
|
|
131
|
+
DESC
|
|
132
|
+
method_option :force, type: :boolean, default: false, desc: "Replace or delete locally modified vendored files"
|
|
133
|
+
def install
|
|
134
|
+
project = with_errors { Project.locate }
|
|
135
|
+
with_errors do
|
|
136
|
+
engine = install_pinned_engine(project)
|
|
137
|
+
say "Installed engine #{engine.id}" if engine
|
|
138
|
+
end
|
|
139
|
+
run_dependency_install(project)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
desc "update [NAMES...]", "Re-resolve dependencies (all, or the named ones) and install the new versions"
|
|
143
|
+
method_option :force, type: :boolean, default: false, desc: "Replace locally modified vendored files"
|
|
144
|
+
def update(*names)
|
|
145
|
+
run_dependency_install(with_errors { Project.locate }, update: names.empty? || names)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
desc "add SOURCE", "Add a dependency to dependencies.rb and install it"
|
|
149
|
+
long_desc <<~DESC
|
|
150
|
+
SOURCE is one of:
|
|
151
|
+
|
|
152
|
+
github:OWNER/REPO a GitHub repository (downloaded as a tarball)
|
|
153
|
+
git:URL any git repository
|
|
154
|
+
url:URL a single file
|
|
155
|
+
path:DIR a local file or directory, copied on every install
|
|
156
|
+
|
|
157
|
+
Examples:
|
|
158
|
+
|
|
159
|
+
talon add github:asimmons91/talon --path lib
|
|
160
|
+
talon add github:someone/tweens --ref v1.2.0 --as tweens
|
|
161
|
+
talon add url:https://example.com/easing.rb
|
|
162
|
+
|
|
163
|
+
The files are vendored into vendor/NAME in the game directory.
|
|
164
|
+
DESC
|
|
165
|
+
method_option :path, type: :string, desc: "File or directory inside the repository to vendor (default: all of it)"
|
|
166
|
+
method_option :ref, type: :string, desc: "Branch, tag or full commit SHA (default: the default branch)"
|
|
167
|
+
method_option :as, type: :string, desc: "Name, and directory under vendor/ (default: from the source)"
|
|
168
|
+
def add(source)
|
|
169
|
+
project = with_errors { Project.locate }
|
|
170
|
+
manifest = with_errors { Deps::Manifest.for(project) }
|
|
171
|
+
original = File.file?(manifest.path) ? File.read(manifest.path) : nil
|
|
172
|
+
kind, value = with_errors { Deps.parse_source(source) }
|
|
173
|
+
line = Deps.declaration(kind, value, path: options[:path], ref: options[:ref], as: options[:as])
|
|
174
|
+
|
|
175
|
+
begin
|
|
176
|
+
with_errors { manifest.add(line) }
|
|
177
|
+
say_status :add, "#{relative_to_root(project, manifest.path)}: #{line}"
|
|
178
|
+
run_dependency_install(project)
|
|
179
|
+
rescue Thor::Error, SystemExit
|
|
180
|
+
original ? File.write(manifest.path, original) : FileUtils.rm_f(manifest.path)
|
|
181
|
+
raise
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
dependency = Deps::Manifest.for(project).dependencies.last
|
|
185
|
+
hint = Deps.require_hint(project, dependency)
|
|
186
|
+
say "\nLoad it from your game with:\n #{hint}" if hint
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
desc "remove NAME", "Remove a dependency from dependencies.rb and delete its vendored files"
|
|
190
|
+
method_option :force, type: :boolean, default: false, desc: "Delete the files even if they were modified"
|
|
191
|
+
def remove(name)
|
|
192
|
+
project = with_errors { Project.locate }
|
|
193
|
+
manifest = with_errors { Deps::Manifest.for(project) }
|
|
194
|
+
dependency = manifest.find(name) || raise(Thor::Error, "No dependency named #{name} in #{Deps::Manifest::FILE}.")
|
|
195
|
+
|
|
196
|
+
with_errors { manifest.remove(dependency) }
|
|
197
|
+
say_status :remove, "#{relative_to_root(project, manifest.path)}: #{dependency.name}", :red
|
|
198
|
+
run_dependency_install(project)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
desc "outdated", "List dependencies whose branch, tag or URL now points at something newer"
|
|
202
|
+
def outdated
|
|
203
|
+
project = with_errors { Project.locate }
|
|
204
|
+
rows = with_errors { Deps::Installer.new(project).outdated }
|
|
205
|
+
|
|
206
|
+
if rows.empty?
|
|
207
|
+
say "All dependencies are up to date."
|
|
208
|
+
else
|
|
209
|
+
table = rows.map do |row|
|
|
210
|
+
[row[:dependency].name, short(row[:locked]) || "(not locked)", short(row[:latest]), row[:dependency].ref || "default branch"]
|
|
211
|
+
end
|
|
212
|
+
print_table([%w[NAME LOCKED LATEST REF], *table])
|
|
213
|
+
say "\nRun `talon update NAME` to install the latest."
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
desc "doctor", "Check this project's engine, game and metadata"
|
|
218
|
+
def doctor
|
|
219
|
+
project = Project.locate
|
|
220
|
+
store = DrTalon::Engine::Store.new(Paths.new.engines_dir)
|
|
221
|
+
checks = Doctor.new(project, store).checks
|
|
222
|
+
|
|
223
|
+
symbols = {ok: "✓", warning: "!", error: "✗"}
|
|
224
|
+
checks.each { |check| say "#{symbols.fetch(check.status)} #{check.message}" }
|
|
225
|
+
|
|
226
|
+
errors = checks.count { |check| check.status == :error }
|
|
227
|
+
raise Thor::Error, "#{errors} problem#{"s" unless errors == 1} found" if errors.positive?
|
|
228
|
+
rescue DrTalon::Error => e
|
|
229
|
+
raise Thor::Error, e.message
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
desc "engine SUBCOMMAND", "Manage cached DragonRuby engines"
|
|
233
|
+
subcommand "engine", Engine
|
|
234
|
+
|
|
235
|
+
map "g" => :generate
|
|
236
|
+
desc "generate KIND NAME", "Generate Talon framework code: scene, component or prefab (alias: g)"
|
|
237
|
+
subcommand "generate", Generate
|
|
238
|
+
|
|
239
|
+
no_commands do
|
|
240
|
+
def with_errors
|
|
241
|
+
yield
|
|
242
|
+
rescue DrTalon::Error => e
|
|
243
|
+
raise Thor::Error, e.message
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Yields the project's runner (and project) for a command line, then
|
|
247
|
+
# replaces this process with it so signals and the exit status are the
|
|
248
|
+
# engine's own.
|
|
249
|
+
def exec_in_project
|
|
250
|
+
project = with_errors { Project.locate }
|
|
251
|
+
argv = with_errors { yield Runner.new(project), project }
|
|
252
|
+
Runner.exec_process(argv, chdir: project.root)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def install_pinned_engine(project)
|
|
256
|
+
pin = project.pin or return nil
|
|
257
|
+
installation = DrTalon::Engine::Store.new(Paths.new.engines_dir).find_native(pin)
|
|
258
|
+
materializer = DrTalon::Engine::Materializer.new(project)
|
|
259
|
+
return nil if materializer.manifest&.engine == installation.id
|
|
260
|
+
|
|
261
|
+
materializer.install(installation)
|
|
262
|
+
DrTalon::Engine::GitIgnore.refresh(project.path(".gitignore"), materializer.manifest.files)
|
|
263
|
+
installation
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def run_dependency_install(project, update: nil)
|
|
267
|
+
manifest = with_errors { Deps::Manifest.for(project) }
|
|
268
|
+
unless manifest.exist?
|
|
269
|
+
say "No #{Deps::Manifest::FILE} in #{relative_to_root(project, project.game_dir)}/; add a dependency with `talon add`."
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
if manifest.foodchain_locks
|
|
273
|
+
say "Found Foodchain's ETag locks; talon re-resolves those dependencies and locks them to commits.", :yellow
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
changes = with_errors { Deps::Installer.new(project, force: options[:force]).install(update: update) }
|
|
277
|
+
changes.each { |change| say_change(change) }
|
|
278
|
+
say "No dependencies in #{Deps::Manifest::FILE}." if changes.empty?
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def say_change(change)
|
|
282
|
+
source = change.dependency && "#{change.dependency.key} @ #{short(change.commit) || "local"}"
|
|
283
|
+
case change.action
|
|
284
|
+
when :installed then say_status :install, "#{change.name} (#{source})"
|
|
285
|
+
when :updated then say_status :update, "#{change.name} #{short(change.previous) || "-"} → #{short(change.commit) || "local"}"
|
|
286
|
+
when :unchanged then say_status :using, "#{change.name} (#{source})", :blue
|
|
287
|
+
when :restored then say_status :restore, "#{change.name} (#{source}); local changes replaced", :yellow
|
|
288
|
+
when :removed then say_status :remove, change.name, :red
|
|
289
|
+
when :kept then say_status :kept, "#{change.name}: no longer declared, but modified locally; delete it yourself", :yellow
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# 7 characters of a commit, or the first 8 hex digits of a sha256.
|
|
294
|
+
def short(commit)
|
|
295
|
+
return nil unless commit
|
|
296
|
+
|
|
297
|
+
commit.start_with?("sha256:") ? commit[0, 15] : commit[0, 7]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def relative_to_root(project, path)
|
|
301
|
+
path.delete_prefix("#{project.root}/")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def print_test_report(file, report)
|
|
305
|
+
counts = ["#{report.passed} passed"]
|
|
306
|
+
counts << "#{report.failed} failed" if report.failed.positive?
|
|
307
|
+
counts << "#{report.inconclusive} inconclusive" if report.inconclusive.positive?
|
|
308
|
+
|
|
309
|
+
case report.status
|
|
310
|
+
when :passed then say "✓ #{file} (#{counts.join(", ")})", :green
|
|
311
|
+
when :failed then say "✗ #{file} (#{counts.join(", ")})", :red
|
|
312
|
+
when :error then say "✗ #{file} didn't load or raised outside a test", :red
|
|
313
|
+
when :no_tests then say "✗ #{file} has no tests (methods named test_*)", :red
|
|
314
|
+
when :timeout then say "✗ #{file} timed out after #{options[:timeout]}s", :red
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
if options[:verbose]
|
|
318
|
+
say report.output
|
|
319
|
+
elsif !report.details.empty?
|
|
320
|
+
say report.details.gsub(/^/, " ")
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def print_test_summary(reports)
|
|
325
|
+
passed = reports.sum(&:passed)
|
|
326
|
+
failed = reports.sum(&:failed)
|
|
327
|
+
bad_files = reports.count { |report| !report.passed? }
|
|
328
|
+
summary = "#{plural(reports.size, "file")}, #{plural(passed, "test")} passed"
|
|
329
|
+
|
|
330
|
+
say ""
|
|
331
|
+
if bad_files.zero?
|
|
332
|
+
say summary, :green
|
|
333
|
+
else
|
|
334
|
+
say "#{summary}, #{failed} failed, #{plural(bad_files, "file")} with problems", :red
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def plural(count, word)
|
|
339
|
+
"#{count} #{word}#{"s" unless count == 1}"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
Config = Data.define(:default_template, :dev_id, :dev_title)
|
|
7
|
+
|
|
8
|
+
# User settings from +config.yml+ (see {Paths#config_file}):
|
|
9
|
+
#
|
|
10
|
+
# default_template: bare # template for `talon new`
|
|
11
|
+
# dev_id: my_itchio_username # devid in game_metadata.txt
|
|
12
|
+
# dev_title: My Name # devtitle in game_metadata.txt
|
|
13
|
+
class Config
|
|
14
|
+
DEFAULTS = {default_template: "bare", dev_id: nil, dev_title: nil}.freeze
|
|
15
|
+
|
|
16
|
+
# @param path [String]
|
|
17
|
+
# @return [Config] defaults for anything the file doesn't set
|
|
18
|
+
# @raise [Error] if the file isn't a YAML mapping
|
|
19
|
+
def self.load(path = Paths.new.config_file)
|
|
20
|
+
data = File.file?(path) ? YAML.safe_load_file(path) : {}
|
|
21
|
+
data ||= {}
|
|
22
|
+
raise Error, "#{path} should contain a YAML mapping." unless data.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
new(**DEFAULTS.to_h { |key, default| [key, data.fetch(key.to_s, default)&.to_s] })
|
|
25
|
+
rescue Psych::SyntaxError => e
|
|
26
|
+
raise Error, "Can't read #{path}: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module DrTalon
|
|
7
|
+
Credentials = Data.define(:username, :password, :source)
|
|
8
|
+
|
|
9
|
+
# A dragonruby.org login for downloading Pro/Indie engines. Looked up in
|
|
10
|
+
# +DRAGONRUBY_USERNAME+/+DRAGONRUBY_PASSWORD+, then {Paths#credentials_file}
|
|
11
|
+
# (which must be readable only by its owner).
|
|
12
|
+
#
|
|
13
|
+
# @!attribute [r] source
|
|
14
|
+
# @return [Symbol] +:env+, +:file+ or +:prompt+
|
|
15
|
+
class Credentials
|
|
16
|
+
ENV_USERNAME = "DRAGONRUBY_USERNAME"
|
|
17
|
+
ENV_PASSWORD = "DRAGONRUBY_PASSWORD"
|
|
18
|
+
|
|
19
|
+
# The credentials file can be read by other users.
|
|
20
|
+
class InsecureFile < Error; end
|
|
21
|
+
|
|
22
|
+
# @param env [Hash]
|
|
23
|
+
# @param path [String]
|
|
24
|
+
# @return [Credentials, nil]
|
|
25
|
+
# @raise [InsecureFile, Error]
|
|
26
|
+
def self.lookup(env: ENV, path: Paths.new.credentials_file)
|
|
27
|
+
from_env(env) || load(path)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Credentials, nil] only when both variables are set
|
|
31
|
+
def self.from_env(env = ENV)
|
|
32
|
+
username, password = env.values_at(ENV_USERNAME, ENV_PASSWORD)
|
|
33
|
+
return nil if username.to_s.empty? || password.to_s.empty?
|
|
34
|
+
|
|
35
|
+
new(username: username, password: password, source: :env)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @param path [String]
|
|
39
|
+
# @return [Credentials, nil]
|
|
40
|
+
# @raise [InsecureFile] if group or others can read the file
|
|
41
|
+
# @raise [Error] if it isn't a mapping with username and password
|
|
42
|
+
def self.load(path)
|
|
43
|
+
return nil unless File.file?(path)
|
|
44
|
+
|
|
45
|
+
mode = File.stat(path).mode & 0o777
|
|
46
|
+
if !Gem.win_platform? && mode.anybits?(0o077)
|
|
47
|
+
raise InsecureFile, "#{path} is readable by other users (mode #{format("%o", mode)}). " \
|
|
48
|
+
"Run `chmod 600 #{path}` or `talon engine login` again."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
data = YAML.safe_load_file(path)
|
|
52
|
+
unless data.is_a?(Hash) && !data["username"].to_s.empty? && !data["password"].to_s.empty?
|
|
53
|
+
raise Error, "#{path} should contain username: and password:."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
new(username: data["username"].to_s, password: data["password"].to_s, source: :file)
|
|
57
|
+
rescue Psych::SyntaxError => e
|
|
58
|
+
raise Error, "Can't read #{path}: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param path [String]
|
|
62
|
+
# @return [Boolean] whether a file was deleted
|
|
63
|
+
def self.delete(path)
|
|
64
|
+
return false unless File.exist?(path)
|
|
65
|
+
|
|
66
|
+
File.unlink(path)
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Writes the file readable only by its owner (mode 0600).
|
|
71
|
+
#
|
|
72
|
+
# @param path [String]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def save(path)
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
76
|
+
File.unlink(path) if File.exist?(path)
|
|
77
|
+
File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
78
|
+
file.write(YAML.dump({"username" => username, "password" => password}))
|
|
79
|
+
end
|
|
80
|
+
File.chmod(0o600, path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [HTTP::Auth]
|
|
84
|
+
def to_auth
|
|
85
|
+
HTTP::Auth.new(username: username, password: password)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Never print the password.
|
|
89
|
+
def inspect
|
|
90
|
+
"#<#{self.class.name} username=#{username.inspect} source=#{source.inspect}>"
|
|
91
|
+
end
|
|
92
|
+
alias_method :to_s, :inspect
|
|
93
|
+
end
|
|
94
|
+
end
|