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,289 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
module Generators
|
|
7
|
+
# +talon new PATH+: creates a game (+bare+ template, DragonRuby's full
|
|
8
|
+
# layout) or a library (+lib+ template, game-only layout) with a pinned,
|
|
9
|
+
# installed engine.
|
|
10
|
+
#
|
|
11
|
+
# Each public method is a step, run in order by {Thor::Group#invoke_all}.
|
|
12
|
+
class NewProject < Thor::Group
|
|
13
|
+
include Thor::Actions
|
|
14
|
+
|
|
15
|
+
TEMPLATES = %w[bare lib talon].freeze
|
|
16
|
+
NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9_-]*\z/
|
|
17
|
+
|
|
18
|
+
argument :path, type: :string, desc: "Directory to create; its name is the project name"
|
|
19
|
+
|
|
20
|
+
class_option :template, type: :string,
|
|
21
|
+
desc: "bare (a game), talon (a game on the Talon framework) or lib (a library); default from config.yml, else bare"
|
|
22
|
+
class_option :lib, type: :boolean, default: false, desc: "Shorthand for --template lib"
|
|
23
|
+
class_option :engine, type: :string, desc: "Engine to pin, e.g. 7.23-pro (default: the newest cached)"
|
|
24
|
+
class_option :commit_engine, type: :boolean,
|
|
25
|
+
desc: "Commit the engine to git instead of ignoring it and restoring with `talon engine use` (asked if omitted)"
|
|
26
|
+
class_option :title, type: :string, desc: "Game title for game_metadata.txt (default: from the name)"
|
|
27
|
+
class_option :dev_id, type: :string, desc: "itch.io username for game_metadata.txt (default: from config.yml)"
|
|
28
|
+
class_option :dev_title, type: :string, desc: "Developer display name for game_metadata.txt (default: from config.yml)"
|
|
29
|
+
class_option :skip_git, type: :boolean, default: false, desc: "Don't run git init"
|
|
30
|
+
class_option :skip_bundle, type: :boolean, default: false, desc: "Don't run bundle install"
|
|
31
|
+
class_option :skip_deps, type: :boolean, default: false, desc: "Don't vendor dependencies (the talon template's Talon)"
|
|
32
|
+
|
|
33
|
+
def self.source_root
|
|
34
|
+
File.expand_path("../templates", __dir__)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_destination
|
|
38
|
+
raise Error, "Invalid project name \"#{name}\". Use letters, digits, - and _, starting with a letter." unless NAME_PATTERN.match?(name)
|
|
39
|
+
raise Error, "Unknown template \"#{template_name}\". Expected one of #{TEMPLATES.join(", ")}." unless TEMPLATES.include?(template_name)
|
|
40
|
+
|
|
41
|
+
root = File.expand_path(path)
|
|
42
|
+
raise Error, "#{root} already exists and isn't empty." if File.exist?(root) && !(File.directory?(root) && Dir.empty?(root))
|
|
43
|
+
|
|
44
|
+
self.destination_root = root
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def resolve_engine
|
|
48
|
+
engine
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ask_about_committing_engine
|
|
52
|
+
commit_engine?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def create_game
|
|
56
|
+
(layout == :full) ? copy_stock_game : create_library
|
|
57
|
+
template "bare/main_tests.rb.tt", "mygame/tests/main_tests.rb" if template_name == "bare"
|
|
58
|
+
create_talon_game if talon?
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def create_tooling
|
|
62
|
+
template "shared/Gemfile.tt", "Gemfile"
|
|
63
|
+
template "shared/editorconfig.tt", ".editorconfig"
|
|
64
|
+
template "shared/gitignore.tt", ".gitignore"
|
|
65
|
+
template "shared/README.md.tt", "README.md"
|
|
66
|
+
template "#{tooling_template}/rubocop.yml.tt", ".rubocop.yml"
|
|
67
|
+
template "#{tooling_template}/solargraph.yml.tt", ".solargraph.yml"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def fill_metadata
|
|
71
|
+
return unless layout == :full
|
|
72
|
+
|
|
73
|
+
file = File.join(destination_root, "mygame", "metadata", "game_metadata.txt")
|
|
74
|
+
return unless File.file?(file)
|
|
75
|
+
|
|
76
|
+
values = {
|
|
77
|
+
"devid" => options[:dev_id] || config.dev_id ||
|
|
78
|
+
ask_in_terminal("Your itch.io username, for game_metadata.txt (blank to set it later):"),
|
|
79
|
+
"devtitle" => options[:dev_title] || config.dev_title ||
|
|
80
|
+
ask_in_terminal("Your name as shown in builds (blank to set it later):"),
|
|
81
|
+
"gameid" => name.downcase.tr("_", "-"),
|
|
82
|
+
"gametitle" => title,
|
|
83
|
+
"version" => "0.1",
|
|
84
|
+
"icon" => "metadata/icon.png"
|
|
85
|
+
}.compact
|
|
86
|
+
|
|
87
|
+
content = values.reduce(File.read(file)) do |text, (key, value)|
|
|
88
|
+
# Set the first assignment, uncommenting DragonRuby's "# key=..." form.
|
|
89
|
+
text.sub(/^[ \t]*(?:#[ \t]*)?#{Regexp.escape(key)}=.*$/) { "#{key}=#{value}" }
|
|
90
|
+
end
|
|
91
|
+
File.write(file, content)
|
|
92
|
+
say_status :update, "mygame/metadata/game_metadata.txt"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def install_engine
|
|
96
|
+
project = Project.new(destination_root)
|
|
97
|
+
materializer = Engine::Materializer.new(project)
|
|
98
|
+
result = materializer.install(engine)
|
|
99
|
+
project.pin!(engine)
|
|
100
|
+
say_status :engine, "#{engine.id} (#{result.copied} files)"
|
|
101
|
+
say_status :create, Project::PIN_FILE
|
|
102
|
+
|
|
103
|
+
Engine::GitIgnore.write(File.join(destination_root, ".gitignore"), materializer.manifest.files) unless commit_engine?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def install_dependencies
|
|
107
|
+
return unless talon? && !options[:skip_deps]
|
|
108
|
+
|
|
109
|
+
changes = Deps::Installer.new(Project.new(destination_root)).install
|
|
110
|
+
changes.each { |change| say_status :vendor, "#{change.name} (#{change.dependency&.key})" }
|
|
111
|
+
rescue DrTalon::Error => e
|
|
112
|
+
say_status :skip, "vendoring Talon failed (#{e.message}); run `talon install` when you're online", :yellow
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def git_init
|
|
116
|
+
return if options[:skip_git]
|
|
117
|
+
return say_status(:skip, "git init (git not found)", :yellow) unless executable?("git")
|
|
118
|
+
|
|
119
|
+
inside(destination_root) { run "git init --quiet", abort_on_failure: false }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def bundle_install
|
|
123
|
+
return if options[:skip_bundle]
|
|
124
|
+
return say_status(:skip, "bundle install (bundler not found)", :yellow) unless executable?("bundle")
|
|
125
|
+
|
|
126
|
+
# Run against the new Gemfile, not whichever bundle talon runs in.
|
|
127
|
+
if defined?(Bundler)
|
|
128
|
+
Bundler.with_unbundled_env { bundle_in_destination }
|
|
129
|
+
else
|
|
130
|
+
bundle_in_destination
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def print_next_steps
|
|
135
|
+
relative = path
|
|
136
|
+
say ""
|
|
137
|
+
say "Created #{name} with DragonRuby #{engine.id}.", :green
|
|
138
|
+
say " cd #{relative}"
|
|
139
|
+
say((layout == :full) ? " talon run" : " talon test")
|
|
140
|
+
say " talon generate scene NAME" if talon?
|
|
141
|
+
say " talon doctor"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
no_commands do
|
|
145
|
+
def name
|
|
146
|
+
File.basename(File.expand_path(path))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [String] the name as a Ruby file name: my-lib -> my_lib
|
|
150
|
+
def file_name
|
|
151
|
+
name.tr("-", "_")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def module_name
|
|
155
|
+
name.split(/[-_]/).map { |part| part[0].upcase + part[1..] }.join
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def title
|
|
159
|
+
options[:title] || name.split(/[-_]/).map(&:capitalize).join(" ")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def template_name
|
|
163
|
+
@template_name ||= options[:lib] ? "lib" : (options[:template] || config.default_template)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def layout
|
|
167
|
+
(template_name == "lib") ? :game_only : :full
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def talon?
|
|
171
|
+
template_name == "talon"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The talon template is a bare game plus Talon.
|
|
175
|
+
def tooling_template
|
|
176
|
+
talon? ? "bare" : template_name
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def talon_require
|
|
180
|
+
TalonFramework::REQUIRE
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def talon_repository
|
|
184
|
+
TalonFramework::REPOSITORY
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def requires_block
|
|
188
|
+
[TalonFramework::BEGIN_MARKER, %(require "app/components/spin.rb"), %(require "app/scenes/title.rb"),
|
|
189
|
+
TalonFramework::END_MARKER].join("\n")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def dependencies_hint
|
|
193
|
+
(layout == :full) ? "mygame/vendor/" : "vendor/"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def engine
|
|
197
|
+
@engine ||=
|
|
198
|
+
if options[:engine]
|
|
199
|
+
store.find_native(Engine::Spec.parse(options[:engine]))
|
|
200
|
+
else
|
|
201
|
+
store.latest_native || raise(Engine::NotFound,
|
|
202
|
+
"No #{Engine.host_platform} engine is cached. Import one first: talon engine import <zip>")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Asked once unless --commit-engine/--no-commit-engine was given.
|
|
207
|
+
# Libraries default to not committing: DragonRuby asks that public
|
|
208
|
+
# repos not include its tooling.
|
|
209
|
+
def commit_engine?
|
|
210
|
+
return @commit_engine unless @commit_engine.nil?
|
|
211
|
+
|
|
212
|
+
@commit_engine =
|
|
213
|
+
if !options[:commit_engine].nil?
|
|
214
|
+
options[:commit_engine]
|
|
215
|
+
elsif layout == :game_only
|
|
216
|
+
false
|
|
217
|
+
else
|
|
218
|
+
yes?("Commit the DragonRuby engine to git? DragonRuby recommends it for private/commercial games. " \
|
|
219
|
+
"If not, it's gitignored and restored with `talon engine use`. [y/N]")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def config
|
|
224
|
+
@config ||= Config.load
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def store
|
|
228
|
+
@store ||= Engine::Store.new(Paths.new.engines_dir)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def copy_stock_game
|
|
235
|
+
source = File.join(engine.path, "mygame")
|
|
236
|
+
raise Engine::InvalidEngine, "#{engine.id} has no mygame/ to start from." unless File.directory?(source)
|
|
237
|
+
|
|
238
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: source).sort.each do |relative|
|
|
239
|
+
next if File.basename(relative).match?(/\A\.\.?\z/) || File.basename(relative) == ".DS_Store"
|
|
240
|
+
|
|
241
|
+
from = File.join(source, relative)
|
|
242
|
+
to = File.join(destination_root, "mygame", relative)
|
|
243
|
+
if File.directory?(from)
|
|
244
|
+
FileUtils.mkdir_p(to)
|
|
245
|
+
else
|
|
246
|
+
FileUtils.mkdir_p(File.dirname(to))
|
|
247
|
+
FileCopy.copy(from, to)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
say_status :create, "mygame (from #{engine.id})"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def create_talon_game
|
|
254
|
+
template "talon/game/main.rb.tt", "mygame/app/main.rb", force: true
|
|
255
|
+
template "talon/game/title.rb.tt", "mygame/app/scenes/title.rb"
|
|
256
|
+
template "talon/game/spin.rb.tt", "mygame/app/components/spin.rb"
|
|
257
|
+
template "talon/game/title_tests.rb.tt", "mygame/tests/scenes/title_tests.rb"
|
|
258
|
+
template "talon/game/dependencies.rb.tt", "mygame/dependencies.rb"
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def create_library
|
|
262
|
+
template "lib/app/main.rb.tt", "app/main.rb"
|
|
263
|
+
template "lib/lib/library.rb.tt", "lib/#{file_name}.rb"
|
|
264
|
+
template "lib/lib/version.rb.tt", "lib/#{file_name}/version.rb"
|
|
265
|
+
template "lib/test/test_helper.rb.tt", "test/test_helper.rb"
|
|
266
|
+
template "lib/test/tests.rb.tt", "test/#{file_name}_tests.rb"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# dragonruby-publish needs devid and devtitle, so ask for them in a
|
|
270
|
+
# terminal; scripts and CI aren't prompted.
|
|
271
|
+
def ask_in_terminal(question)
|
|
272
|
+
return nil unless $stdin.tty?
|
|
273
|
+
|
|
274
|
+
answer = ask(question).to_s.strip
|
|
275
|
+
answer.empty? ? nil : answer
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def bundle_in_destination
|
|
279
|
+
inside(destination_root) { run "bundle install --quiet", abort_on_failure: false }
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def executable?(command)
|
|
283
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |dir|
|
|
284
|
+
File.executable?(File.join(dir, command)) && !File.directory?(File.join(dir, command))
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
module Generators
|
|
7
|
+
# +talon generate scene|component|prefab NAME+: a Talon class in
|
|
8
|
+
# app/<kind>s/, a DragonRuby test in tests/<kind>s/, and its require in
|
|
9
|
+
# app/main.rb's managed block.
|
|
10
|
+
class TalonCode < Thor::Group
|
|
11
|
+
include Thor::Actions
|
|
12
|
+
|
|
13
|
+
KINDS = %w[scene component prefab].freeze
|
|
14
|
+
|
|
15
|
+
argument :kind, type: :string, desc: KINDS.join(", ")
|
|
16
|
+
argument :name, type: :string, desc: "e.g. MainMenu or main_menu"
|
|
17
|
+
|
|
18
|
+
class_option :skip_test, type: :boolean, default: false, desc: "Don't create a test"
|
|
19
|
+
class_option :force, type: :boolean, default: false, desc: "Overwrite existing files"
|
|
20
|
+
|
|
21
|
+
def self.source_root
|
|
22
|
+
File.expand_path("../templates/talon/generate", __dir__)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def check
|
|
26
|
+
raise Error, "Unknown kind \"#{kind}\". Expected one of #{KINDS.join(", ")}." unless KINDS.include?(kind)
|
|
27
|
+
|
|
28
|
+
TalonFramework.require_used!(project)
|
|
29
|
+
self.destination_root = project.game_dir
|
|
30
|
+
|
|
31
|
+
existing = files.select { |path| File.exist?(File.join(destination_root, path)) }
|
|
32
|
+
return if existing.empty? || options[:force]
|
|
33
|
+
|
|
34
|
+
raise Error, "#{existing.join(" and ")} already exist#{"s" if existing.one?}. Pass --force to overwrite."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def create_code
|
|
38
|
+
template "#{kind}.rb.tt", code_path, force: options[:force]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_test
|
|
42
|
+
template "#{kind}_tests.rb.tt", test_path, force: options[:force] unless options[:skip_test]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add_require
|
|
46
|
+
main = File.join(destination_root, "app", "main.rb")
|
|
47
|
+
line = %(require "#{file_require}")
|
|
48
|
+
|
|
49
|
+
case TalonFramework.add_require(main, line)
|
|
50
|
+
when :added then say_status :update, "app/main.rb (#{line})"
|
|
51
|
+
when :present then say_status :identical, "app/main.rb (#{line})", :blue
|
|
52
|
+
else
|
|
53
|
+
say "\nAdd this to app/main.rb (it has no `talon generate` block):\n #{line}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
no_commands do
|
|
58
|
+
def project
|
|
59
|
+
@project ||= Project.locate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def class_name
|
|
63
|
+
TalonFramework.names(name).first
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def file_name
|
|
67
|
+
TalonFramework.names(name).last
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def code_path
|
|
71
|
+
"app/#{kind}s/#{file_name}.rb"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_path
|
|
75
|
+
"tests/#{kind}s/#{file_name}_tests.rb"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def file_require
|
|
79
|
+
code_path
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def talon_require
|
|
83
|
+
TalonFramework.require_path(project)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def files
|
|
87
|
+
options[:skip_test] ? [code_path] : [code_path, test_path]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module DrTalon
|
|
7
|
+
# Minimal HTTP(S) GETs with redirects, for dependencies and engine
|
|
8
|
+
# downloads. Basic auth is only sent to the host it was given for.
|
|
9
|
+
class HTTP
|
|
10
|
+
MAX_REDIRECTS = 5
|
|
11
|
+
|
|
12
|
+
# A request failed or returned a non-2xx status.
|
|
13
|
+
class Error < DrTalon::Error; end
|
|
14
|
+
|
|
15
|
+
# The server answered 401.
|
|
16
|
+
class Unauthorized < Error; end
|
|
17
|
+
|
|
18
|
+
Auth = Data.define(:username, :password)
|
|
19
|
+
|
|
20
|
+
# @param url [String]
|
|
21
|
+
# @param auth [Auth, nil]
|
|
22
|
+
# @return [String] the response body
|
|
23
|
+
# @raise [Error]
|
|
24
|
+
def get(url, auth: nil)
|
|
25
|
+
follow(url, auth) { |response| return response.body.to_s }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Posts a form and returns the body (no redirects).
|
|
29
|
+
#
|
|
30
|
+
# @param url [String]
|
|
31
|
+
# @param form [Hash{String => String}]
|
|
32
|
+
# @return [String]
|
|
33
|
+
# @raise [Error]
|
|
34
|
+
def post_form(url, form)
|
|
35
|
+
uri = http_uri(url)
|
|
36
|
+
request = Net::HTTP::Post.new(uri, headers)
|
|
37
|
+
request.set_form_data(form)
|
|
38
|
+
perform(uri, request) do |response|
|
|
39
|
+
check!(uri, response)
|
|
40
|
+
raise Error, "POST #{uri} returned #{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
41
|
+
|
|
42
|
+
return response.body.to_s
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Streams a response body to +path+.
|
|
47
|
+
#
|
|
48
|
+
# @param url [String]
|
|
49
|
+
# @param path [String]
|
|
50
|
+
# @param auth [Auth, nil]
|
|
51
|
+
# @yieldparam received [Integer] bytes so far
|
|
52
|
+
# @yieldparam total [Integer, nil] Content-Length, if sent
|
|
53
|
+
# @return [String] +path+
|
|
54
|
+
# @raise [Error]
|
|
55
|
+
def download(url, path, auth: nil)
|
|
56
|
+
follow(url, auth) do |response|
|
|
57
|
+
total = response.content_length
|
|
58
|
+
received = 0
|
|
59
|
+
File.open(path, "wb") do |file|
|
|
60
|
+
response.read_body do |chunk|
|
|
61
|
+
file.write(chunk)
|
|
62
|
+
received += chunk.bytesize
|
|
63
|
+
yield received, total if block_given?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
return path
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Yields the first successful response; the caller reads its body.
|
|
73
|
+
def follow(url, auth)
|
|
74
|
+
uri = http_uri(url)
|
|
75
|
+
origin = uri.host
|
|
76
|
+
|
|
77
|
+
(MAX_REDIRECTS + 1).times do
|
|
78
|
+
redirect = nil
|
|
79
|
+
request = Net::HTTP::Get.new(uri, headers)
|
|
80
|
+
request.basic_auth(auth.username, auth.password) if auth && uri.host == origin
|
|
81
|
+
perform(uri, request) do |response|
|
|
82
|
+
check!(uri, response)
|
|
83
|
+
case response
|
|
84
|
+
when Net::HTTPSuccess then yield response
|
|
85
|
+
when Net::HTTPRedirection then redirect = http_uri(URI.join(uri, response["location"]))
|
|
86
|
+
else raise Error, "GET #{uri} returned #{response.code} #{response.message}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
uri = redirect
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise Error, "GET #{url} redirected more than #{MAX_REDIRECTS} times"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def http_uri(url)
|
|
96
|
+
uri = URI(url)
|
|
97
|
+
raise Error, "Only http(s) URLs are supported: #{url}" unless uri.is_a?(URI::HTTP)
|
|
98
|
+
|
|
99
|
+
uri
|
|
100
|
+
rescue URI::InvalidURIError => e
|
|
101
|
+
raise Error, "Invalid URL #{url}: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def headers
|
|
105
|
+
{"User-Agent" => "dr_talon/#{VERSION}"}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def check!(uri, response)
|
|
109
|
+
raise Unauthorized, "#{uri.host} rejected the username or password (401)" if response.is_a?(Net::HTTPUnauthorized)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def perform(uri, request, &block)
|
|
113
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 15, read_timeout: 120) do |http|
|
|
114
|
+
http.request(request, &block)
|
|
115
|
+
end
|
|
116
|
+
rescue SocketError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError => e
|
|
117
|
+
raise Error, "#{request.method} #{uri} failed: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module DrTalon
|
|
6
|
+
# Resolves where talon keeps its global state: cached engines, downloads,
|
|
7
|
+
# config and credentials.
|
|
8
|
+
#
|
|
9
|
+
# - Linux/BSD: XDG base directories (+XDG_DATA_HOME+, +XDG_CACHE_HOME+,
|
|
10
|
+
# +XDG_CONFIG_HOME+), defaulting to +~/.local/share+, +~/.cache+ and
|
|
11
|
+
# +~/.config+.
|
|
12
|
+
# - macOS: +~/Library/Application Support+ and +~/Library/Caches+, unless
|
|
13
|
+
# the XDG variables are set explicitly.
|
|
14
|
+
# - Windows: +%LOCALAPPDATA%+ for data and cache, +%APPDATA%+ for config.
|
|
15
|
+
#
|
|
16
|
+
# Setting +DR_TALON_HOME+ puts everything under that one directory, which is
|
|
17
|
+
# handy for tests and for isolating experiments.
|
|
18
|
+
class Paths
|
|
19
|
+
APP_NAME = "dr_talon"
|
|
20
|
+
|
|
21
|
+
# @param env [Hash{String => String}] environment to read overrides from
|
|
22
|
+
# @param host_os [String] value of +RbConfig::CONFIG["host_os"]+
|
|
23
|
+
# @param home [String] the user's home directory
|
|
24
|
+
def initialize(env: ENV, host_os: RbConfig::CONFIG["host_os"], home: Dir.home)
|
|
25
|
+
@env = env
|
|
26
|
+
@platform = platform_for(host_os)
|
|
27
|
+
@home = home
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [String] root for persistent data such as cached engines
|
|
31
|
+
def data_dir
|
|
32
|
+
@data_dir ||= resolve(:data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [String] root for disposable files such as downloaded zips
|
|
36
|
+
def cache_dir
|
|
37
|
+
@cache_dir ||= resolve(:cache)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String] root for user configuration
|
|
41
|
+
def config_dir
|
|
42
|
+
@config_dir ||= resolve(:config)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [String] where unpacked engines are stored
|
|
46
|
+
def engines_dir
|
|
47
|
+
File.join(data_dir, "engines")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [String] where downloaded engine zips are kept
|
|
51
|
+
def downloads_dir
|
|
52
|
+
File.join(cache_dir, "downloads")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String]
|
|
56
|
+
def config_file
|
|
57
|
+
File.join(config_dir, "config.yml")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String] dragonruby.org credentials, written with mode 0600
|
|
61
|
+
def credentials_file
|
|
62
|
+
File.join(config_dir, "credentials.yml")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
XDG_VARS = {data: "XDG_DATA_HOME", cache: "XDG_CACHE_HOME", config: "XDG_CONFIG_HOME"}.freeze
|
|
68
|
+
XDG_DEFAULTS = {data: [".local", "share"], cache: [".cache"], config: [".config"]}.freeze
|
|
69
|
+
|
|
70
|
+
def resolve(kind)
|
|
71
|
+
if (root = present(@env["DR_TALON_HOME"]))
|
|
72
|
+
return File.join(root, kind.to_s)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
case @platform
|
|
76
|
+
when :windows then windows_dir(kind)
|
|
77
|
+
when :macos then present(@env[XDG_VARS[kind]]) ? xdg_dir(kind) : macos_dir(kind)
|
|
78
|
+
else xdg_dir(kind)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def xdg_dir(kind)
|
|
83
|
+
base = present(@env[XDG_VARS[kind]]) || File.join(@home, *XDG_DEFAULTS[kind])
|
|
84
|
+
File.join(base, APP_NAME)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def macos_dir(kind)
|
|
88
|
+
base = (kind == :cache) ? "Caches" : "Application Support"
|
|
89
|
+
File.join(@home, "Library", base, APP_NAME)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def windows_dir(kind)
|
|
93
|
+
local = present(@env["LOCALAPPDATA"]) || File.join(@home, "AppData", "Local")
|
|
94
|
+
roaming = present(@env["APPDATA"]) || File.join(@home, "AppData", "Roaming")
|
|
95
|
+
|
|
96
|
+
case kind
|
|
97
|
+
when :data then File.join(local, APP_NAME)
|
|
98
|
+
when :cache then File.join(local, APP_NAME, "cache")
|
|
99
|
+
when :config then File.join(roaming, APP_NAME)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def platform_for(host_os)
|
|
104
|
+
case host_os
|
|
105
|
+
when /darwin/ then :macos
|
|
106
|
+
when /mswin|mingw|cygwin/ then :windows
|
|
107
|
+
else :unix
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def present(value)
|
|
112
|
+
value unless value.nil? || value.empty?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|