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,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DrTalon
|
|
4
|
+
module Deps
|
|
5
|
+
# One pinned dependency, stored as a tab-separated line below +__END__+:
|
|
6
|
+
#
|
|
7
|
+
# key ref destination commit checksum
|
|
8
|
+
#
|
|
9
|
+
# +ref+ and +commit+ are "-" when absent; +checksum+ covers the vendored
|
|
10
|
+
# files so local edits can be detected.
|
|
11
|
+
Lock = Data.define(:key, :ref, :destination, :commit, :checksum) do
|
|
12
|
+
def self.parse(line)
|
|
13
|
+
fields = line.split("\t").map { |field| (field == "-") ? nil : field }
|
|
14
|
+
new(*fields) if fields.size == members.size
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_line
|
|
18
|
+
to_a.map { |field| field || "-" }.join("\t")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_a
|
|
22
|
+
[key, ref, destination, commit, checksum]
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# +dependencies.rb+ in the game directory: a Ruby DSL above +__END__+,
|
|
27
|
+
# owned by the user, and lock lines below it, owned by talon. The DSL is
|
|
28
|
+
# compatible with Foodchain's (+github :owner, :repo, "path", destination:+).
|
|
29
|
+
class Manifest
|
|
30
|
+
FILE = "dependencies.rb"
|
|
31
|
+
SEPARATOR = /^__END__\r?\n?/
|
|
32
|
+
LOCK_HEADER = <<~TEXT
|
|
33
|
+
# The lines below pin the versions of your installed dependencies.
|
|
34
|
+
# Managed by `talon install` / `talon update`; do not edit by hand.
|
|
35
|
+
TEXT
|
|
36
|
+
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :path
|
|
39
|
+
# @return [String] everything above __END__
|
|
40
|
+
attr_reader :config
|
|
41
|
+
# @return [Array<Dependency>]
|
|
42
|
+
attr_reader :dependencies
|
|
43
|
+
# @return [Array<Lock>]
|
|
44
|
+
attr_reader :locks
|
|
45
|
+
# @return [Boolean] whether the lock section had Foodchain's ETag lines
|
|
46
|
+
attr_reader :foodchain_locks
|
|
47
|
+
|
|
48
|
+
# @param project [Project]
|
|
49
|
+
# @return [Manifest]
|
|
50
|
+
def self.for(project)
|
|
51
|
+
new(File.join(project.game_dir, FILE))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param path [String] need not exist yet
|
|
55
|
+
# @raise [ManifestError] if the DSL is invalid
|
|
56
|
+
def initialize(path)
|
|
57
|
+
@path = path
|
|
58
|
+
content = File.file?(path) ? File.read(path) : ""
|
|
59
|
+
config, separator, locks = content.partition(SEPARATOR)
|
|
60
|
+
@config = config
|
|
61
|
+
@had_separator = !separator.empty?
|
|
62
|
+
@locks, @foodchain_locks = parse_locks(locks)
|
|
63
|
+
@dependencies = DSL.evaluate(config, path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Boolean]
|
|
67
|
+
def exist?
|
|
68
|
+
File.file?(path)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @param name [String] a dependency's name or key
|
|
72
|
+
# @return [Dependency, nil]
|
|
73
|
+
def find(name)
|
|
74
|
+
dependencies.find { |dependency| dependency.name == name || dependency.key == name }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Rewrites the lock section, keeping everything above __END__ as is.
|
|
78
|
+
#
|
|
79
|
+
# @param locks [Array<Lock>]
|
|
80
|
+
# @return [void]
|
|
81
|
+
def write_locks(locks)
|
|
82
|
+
@locks = locks.sort_by(&:key)
|
|
83
|
+
body = [config.rstrip, "", "__END__", "", LOCK_HEADER, *@locks.map(&:to_line)].join("\n")
|
|
84
|
+
File.write(path, body.sub(/\A\n+/, "") + "\n")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Appends a declaration to the DSL section and checks it parses.
|
|
88
|
+
#
|
|
89
|
+
# @param line [String]
|
|
90
|
+
# @return [void]
|
|
91
|
+
# @raise [ManifestError] (leaving the file unchanged) if it doesn't
|
|
92
|
+
def add(line)
|
|
93
|
+
update_config(config.rstrip.then { |c| c.empty? ? "#{line}\n" : "#{c}\n#{line}\n" })
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Deletes +dependency+'s declaration from the DSL section.
|
|
97
|
+
#
|
|
98
|
+
# @param dependency [Dependency]
|
|
99
|
+
# @return [void]
|
|
100
|
+
# @raise [ManifestError] if the declaration spans several lines
|
|
101
|
+
def remove(dependency)
|
|
102
|
+
lines = config.lines
|
|
103
|
+
index = dependency.line && dependency.line - 1
|
|
104
|
+
text = index && lines[index]
|
|
105
|
+
declares = text && DSL::METHODS.any? { |method| text.lstrip.start_with?("#{method} ", "#{method}(") }
|
|
106
|
+
continues = text&.rstrip&.end_with?(",", "\\", "(")
|
|
107
|
+
unless declares && !continues
|
|
108
|
+
raise ManifestError, "Can't remove #{dependency.name} automatically; edit #{FILE} by hand."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
lines.delete_at(index)
|
|
112
|
+
update_config(lines.join)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# Validates before writing, so a bad edit leaves the file alone.
|
|
118
|
+
def update_config(new_config)
|
|
119
|
+
@dependencies = DSL.evaluate(new_config, path)
|
|
120
|
+
@config = new_config
|
|
121
|
+
write_locks(locks)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Foodchain's two-column ETag lines can't be reused; they're dropped and
|
|
125
|
+
# the dependencies re-resolved.
|
|
126
|
+
def parse_locks(text)
|
|
127
|
+
lines = text.lines(chomp: true).reject { |line| line.strip.empty? || line.start_with?("#") }
|
|
128
|
+
locks = lines.filter_map { |line| Lock.parse(line) }
|
|
129
|
+
[locks, locks.size < lines.size]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Evaluates the DSL section of +dependencies.rb+.
|
|
134
|
+
class DSL
|
|
135
|
+
METHODS = %w[github git url path].freeze
|
|
136
|
+
|
|
137
|
+
# @param source [String]
|
|
138
|
+
# @param file [String] for line numbers in errors
|
|
139
|
+
# @return [Array<Dependency>]
|
|
140
|
+
# @raise [ManifestError]
|
|
141
|
+
def self.evaluate(source, file)
|
|
142
|
+
dsl = new(file)
|
|
143
|
+
dsl.instance_eval(source, file, 1)
|
|
144
|
+
dsl.dependencies
|
|
145
|
+
rescue ManifestError
|
|
146
|
+
raise
|
|
147
|
+
rescue ScriptError, StandardError => e
|
|
148
|
+
location = e.backtrace_locations&.find { |l| l.path == file }&.lineno
|
|
149
|
+
raise ManifestError, "#{File.basename(file)}#{":#{location}" if location}: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
attr_reader :dependencies
|
|
153
|
+
|
|
154
|
+
def initialize(file)
|
|
155
|
+
@file = file
|
|
156
|
+
@dependencies = []
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Foodchain files start with `require "vendor/foodchain"`; requires
|
|
160
|
+
# don't mean anything to talon.
|
|
161
|
+
def require(*)
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# github "owner/repo", path: "lib", ref: "v1.0", as: "name"
|
|
166
|
+
# github :owner, :repo, "lib", ref: "v1.0", destination: "vendor/name" (Foodchain)
|
|
167
|
+
def github(*args, path: nil, ref: nil, as: nil, destination: nil)
|
|
168
|
+
owner, repo, positional_path =
|
|
169
|
+
case args.size
|
|
170
|
+
when 1 then [*args.first.to_s.split("/", 2), nil]
|
|
171
|
+
when 2, 3 then args
|
|
172
|
+
else raise ArgumentError, "github expects \"owner/repo\" or owner, repo[, path]"
|
|
173
|
+
end
|
|
174
|
+
raise ArgumentError, "github needs \"owner/repo\"" if owner.to_s.empty? || repo.to_s.empty?
|
|
175
|
+
|
|
176
|
+
add(GitHubDependency, owner, repo, subpath: path || positional_path, ref: ref, as: as, destination: destination)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# git "https://host/repo.git", path: "lib", ref: "main", as: "name"
|
|
180
|
+
def git(url, path: nil, ref: nil, as: nil, destination: nil)
|
|
181
|
+
add(GitDependency, url, subpath: path, ref: ref, as: as, destination: destination)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# url "https://example.com/tween.rb", as: "tween.rb"
|
|
185
|
+
def url(url, as: nil, destination: nil)
|
|
186
|
+
add(URLDependency, url, as: as, destination: destination)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# path "../my_library/lib", as: "my_library"
|
|
190
|
+
def path(dir, as: nil, destination: nil)
|
|
191
|
+
add(PathDependency, dir, base: File.dirname(@file), as: as, destination: destination)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
def caller_line
|
|
197
|
+
caller_locations.find { |location| location.path == @file }&.lineno
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def add(klass, *args, as:, destination:, **options)
|
|
201
|
+
line = caller_line
|
|
202
|
+
dependency = begin
|
|
203
|
+
klass.new(*args, name: as&.to_s, destination: destination&.to_s, line: line, **options)
|
|
204
|
+
rescue ManifestError => e
|
|
205
|
+
raise ManifestError, "#{File.basename(@file)}:#{line}: #{e.message}"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
[[:name, "name"], [:key, "source"], [:destination, "destination"]].each do |attribute, label|
|
|
209
|
+
clash = @dependencies.find { |other| other.public_send(attribute) == dependency.public_send(attribute) }
|
|
210
|
+
next unless clash
|
|
211
|
+
|
|
212
|
+
raise ManifestError, "#{File.basename(@file)}:#{line}: #{dependency.name} has the same #{label} " \
|
|
213
|
+
"as #{clash.name} (line #{clash.line}). Use as: to give one a different name."
|
|
214
|
+
end
|
|
215
|
+
@dependencies << dependency
|
|
216
|
+
dependency
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "rubygems/package"
|
|
6
|
+
require "stringio"
|
|
7
|
+
require "zlib"
|
|
8
|
+
|
|
9
|
+
module DrTalon
|
|
10
|
+
module Deps
|
|
11
|
+
# File tree helpers: checksums of vendored files, copying a subpath out of
|
|
12
|
+
# a source tree, and unpacking tarballs.
|
|
13
|
+
module Tree
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# A digest of a file or directory's contents and executable bits, so
|
|
17
|
+
# local edits to vendored files can be detected.
|
|
18
|
+
#
|
|
19
|
+
# @param path [String]
|
|
20
|
+
# @return [String, nil] "sha256:<hex>", or nil if +path+ doesn't exist
|
|
21
|
+
def checksum(path)
|
|
22
|
+
return nil unless File.exist?(path)
|
|
23
|
+
return "sha256:#{Digest::SHA256.file(path).hexdigest}" if File.file?(path)
|
|
24
|
+
|
|
25
|
+
digest = Digest::SHA256.new
|
|
26
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: path).sort.each do |relative|
|
|
27
|
+
full = File.join(path, relative)
|
|
28
|
+
next unless File.file?(full)
|
|
29
|
+
|
|
30
|
+
digest << relative << "\0" << (File.executable?(full) ? "x" : "-") << Digest::SHA256.file(full).hexdigest << "\n"
|
|
31
|
+
end
|
|
32
|
+
"sha256:#{digest.hexdigest}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Copies +subpath+ of +root+ (a file or directory, or all of +root+ when
|
|
36
|
+
# empty) to +target+, skipping .git and symlinks.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
# @raise [FetchError] if +subpath+ doesn't exist
|
|
40
|
+
def copy_subpath(root, subpath, target)
|
|
41
|
+
source = subpath.to_s.empty? ? root : File.join(root, subpath)
|
|
42
|
+
raise FetchError, "#{subpath} doesn't exist in the dependency." unless File.exist?(source)
|
|
43
|
+
|
|
44
|
+
if File.file?(source)
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
46
|
+
FileUtils.cp(source, target, preserve: true)
|
|
47
|
+
return
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Dir.glob("**/*", File::FNM_DOTMATCH, base: source).sort.each do |relative|
|
|
51
|
+
next if relative.split("/").include?(".git") || File.basename(relative).match?(/\A\.\.?\z/)
|
|
52
|
+
|
|
53
|
+
from = File.join(source, relative)
|
|
54
|
+
to = File.join(target, relative)
|
|
55
|
+
if File.symlink?(from)
|
|
56
|
+
next
|
|
57
|
+
elsif File.directory?(from)
|
|
58
|
+
FileUtils.mkdir_p(to)
|
|
59
|
+
else
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(to))
|
|
61
|
+
FileUtils.cp(from, to, preserve: true)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
FileUtils.mkdir_p(target)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Unpacks a .tar.gz into +dir+, dropping the single top-level directory
|
|
68
|
+
# GitHub wraps archives in. Skips links and anything that would land
|
|
69
|
+
# outside +dir+.
|
|
70
|
+
#
|
|
71
|
+
# @param data [String] gzipped tar bytes
|
|
72
|
+
# @param dir [String]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def untar(data, dir, strip: 1)
|
|
75
|
+
Zlib::GzipReader.wrap(StringIO.new(data)) do |gzip|
|
|
76
|
+
Gem::Package::TarReader.new(gzip) do |tar|
|
|
77
|
+
tar.each do |entry|
|
|
78
|
+
parts = entry.full_name.split("/").reject(&:empty?).drop(strip)
|
|
79
|
+
next if parts.empty? || parts.include?("..")
|
|
80
|
+
|
|
81
|
+
target = File.join(dir, *parts)
|
|
82
|
+
if entry.directory?
|
|
83
|
+
FileUtils.mkdir_p(target)
|
|
84
|
+
elsif entry.file?
|
|
85
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
86
|
+
File.binwrite(target, entry.read.to_s)
|
|
87
|
+
File.chmod(entry.header.mode & 0o755 | 0o600, target)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
rescue Zlib::Error, Gem::Package::TarInvalidError => e
|
|
93
|
+
raise FetchError, "Couldn't unpack the archive: #{e.message}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DrTalon
|
|
4
|
+
# DragonRuby dependencies: declared in +dependencies.rb+ in the game
|
|
5
|
+
# directory, vendored into +vendor/+, locked below +__END__+.
|
|
6
|
+
module Deps
|
|
7
|
+
# Downloading or resolving a dependency failed.
|
|
8
|
+
class FetchError < Error; end
|
|
9
|
+
|
|
10
|
+
# +dependencies.rb+ is invalid.
|
|
11
|
+
class ManifestError < Error; end
|
|
12
|
+
|
|
13
|
+
# Vendored files were changed outside talon.
|
|
14
|
+
class ModifiedError < Error; end
|
|
15
|
+
|
|
16
|
+
# Parses a `talon add` source: github:owner/repo, git:URL, url:URL or
|
|
17
|
+
# path:DIR.
|
|
18
|
+
#
|
|
19
|
+
# @return [Array(Symbol, String)] the DSL method and its first argument
|
|
20
|
+
# @raise [Error]
|
|
21
|
+
def self.parse_source(source)
|
|
22
|
+
kind, value = source.to_s.split(":", 2)
|
|
23
|
+
if %w[github git url path].include?(kind) && !value.to_s.empty?
|
|
24
|
+
return [kind.to_sym, value] unless kind == "github" && !value.match?(%r{\A[\w.-]+/[\w.-]+\z})
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
raise Error, "Unknown source #{source.inspect}. Use github:owner/repo, git:URL, url:URL or path:DIR."
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A dependencies.rb line for `talon add`.
|
|
31
|
+
#
|
|
32
|
+
# @return [String]
|
|
33
|
+
def self.declaration(kind, value, path: nil, ref: nil, as: nil)
|
|
34
|
+
options = {path: path, ref: ref, as: as}.compact
|
|
35
|
+
options.delete(:path) if kind == :url || kind == :path
|
|
36
|
+
options.delete(:ref) if kind == :url || kind == :path
|
|
37
|
+
[%(#{kind} #{value.inspect}), *options.map { |key, val| "#{key}: #{val.inspect}" }].join(", ")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The line to require a vendored dependency from game code.
|
|
41
|
+
#
|
|
42
|
+
# @param project [Project]
|
|
43
|
+
# @param dependency [Dependency]
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def self.require_hint(project, dependency)
|
|
46
|
+
target = File.join(project.game_dir, dependency.destination)
|
|
47
|
+
return %(require "#{dependency.destination}") if File.file?(target)
|
|
48
|
+
return nil unless File.directory?(target)
|
|
49
|
+
|
|
50
|
+
base = dependency.name.delete_suffix(".rb").tr("-", "_")
|
|
51
|
+
entry = ["#{base}.rb", "lib/#{base}.rb", "main.rb"].find { |candidate| File.file?(File.join(target, candidate)) }
|
|
52
|
+
entry ||= Dir.glob("*.rb", base: target).min
|
|
53
|
+
entry && %(require "#{dependency.destination}/#{entry}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
require_relative "deps/git"
|
|
59
|
+
require_relative "deps/tree"
|
|
60
|
+
require_relative "deps/dependency"
|
|
61
|
+
require_relative "deps/manifest"
|
|
62
|
+
require_relative "deps/installer"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DrTalon
|
|
4
|
+
# Health checks for a project, reported by +talon doctor+.
|
|
5
|
+
class Doctor
|
|
6
|
+
# @!attribute [r] status
|
|
7
|
+
# @return [Symbol] +:ok+, +:warning+ or +:error+
|
|
8
|
+
# @!attribute [r] message
|
|
9
|
+
# @return [String]
|
|
10
|
+
Check = Data.define(:status, :message)
|
|
11
|
+
|
|
12
|
+
# Values DragonRuby ships in +game_metadata.txt+ that must be changed
|
|
13
|
+
# before publishing.
|
|
14
|
+
METADATA_PLACEHOLDERS = {
|
|
15
|
+
"devid" => "your_itchio_username",
|
|
16
|
+
"devtitle" => "Your Itch.io Display Name",
|
|
17
|
+
"gameid" => "your_itchio_game_id",
|
|
18
|
+
"gametitle" => "Your Game Title"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# Publishing fields in +game_metadata.txt+ that are missing, commented out
|
|
22
|
+
# (as DragonRuby ships them) or still DragonRuby's placeholder.
|
|
23
|
+
#
|
|
24
|
+
# @param project [Project]
|
|
25
|
+
# @return [Array<String>, nil] nil when there's no metadata file
|
|
26
|
+
def self.unset_metadata(project)
|
|
27
|
+
path = File.join(project.game_dir, "metadata", "game_metadata.txt")
|
|
28
|
+
return nil unless File.file?(path)
|
|
29
|
+
|
|
30
|
+
values = File.readlines(path, chomp: true).filter_map do |line|
|
|
31
|
+
line.strip.split("=", 2) if !line.lstrip.start_with?("#") && line.include?("=")
|
|
32
|
+
end.to_h
|
|
33
|
+
|
|
34
|
+
METADATA_PLACEHOLDERS.select { |key, placeholder| values[key].nil? || values[key] == placeholder }.keys
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param project [Project]
|
|
38
|
+
# @param store [Engine::Store]
|
|
39
|
+
# @param host_platform [String]
|
|
40
|
+
def initialize(project, store, host_platform: Engine.host_platform)
|
|
41
|
+
@project = project
|
|
42
|
+
@store = store
|
|
43
|
+
@host_platform = host_platform
|
|
44
|
+
@materializer = Engine::Materializer.new(project)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Array<Check>]
|
|
48
|
+
def checks
|
|
49
|
+
@checks = []
|
|
50
|
+
check_game
|
|
51
|
+
check_engine
|
|
52
|
+
check_dependencies
|
|
53
|
+
check_metadata
|
|
54
|
+
@checks
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def ok(message) = @checks << Check.new(status: :ok, message: message)
|
|
60
|
+
|
|
61
|
+
def warning(message) = @checks << Check.new(status: :warning, message: message)
|
|
62
|
+
|
|
63
|
+
def error(message) = @checks << Check.new(status: :error, message: message)
|
|
64
|
+
|
|
65
|
+
def check_game
|
|
66
|
+
layout = @project.layout
|
|
67
|
+
if layout
|
|
68
|
+
ok "Game found in #{(layout == :full) ? "mygame/" : "the project root"} (#{layout.to_s.tr("_", "-")} layout)"
|
|
69
|
+
else
|
|
70
|
+
error "No mygame/app/main.rb or app/main.rb found"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def check_engine
|
|
75
|
+
pin = begin
|
|
76
|
+
@project.pin
|
|
77
|
+
rescue Error => e
|
|
78
|
+
return error(e.message)
|
|
79
|
+
end
|
|
80
|
+
return error("No #{Project::PIN_FILE}. Pin an engine with `talon engine use VERSION`.") unless pin
|
|
81
|
+
|
|
82
|
+
expected = expected_engine(pin)
|
|
83
|
+
return unless expected
|
|
84
|
+
|
|
85
|
+
manifest = begin
|
|
86
|
+
@materializer.manifest
|
|
87
|
+
rescue Error => e
|
|
88
|
+
return error(e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if manifest.nil?
|
|
92
|
+
error "#{expected.id} isn't installed in the project. Run `talon engine use`."
|
|
93
|
+
elsif manifest.engine != expected.id
|
|
94
|
+
error "The project has #{manifest.engine} installed but #{Project::PIN_FILE} pins #{pin}. Run `talon engine use`."
|
|
95
|
+
else
|
|
96
|
+
check_installed_files(manifest)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def expected_engine(pin)
|
|
101
|
+
spec = Engine::Spec.new(version: pin.version, tier: pin.tier, platform: @host_platform)
|
|
102
|
+
installation = @store.find(spec)
|
|
103
|
+
ok "#{Project::PIN_FILE} pins #{pin}; #{installation.id} is cached"
|
|
104
|
+
installation
|
|
105
|
+
rescue Engine::NotFound
|
|
106
|
+
error "#{Project::PIN_FILE} pins #{pin}, but no matching #{@host_platform} engine is cached. " \
|
|
107
|
+
"Import it with `talon engine import <zip>`."
|
|
108
|
+
nil
|
|
109
|
+
rescue Engine::Ambiguous => e
|
|
110
|
+
error e.message
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def check_installed_files(manifest)
|
|
115
|
+
missing = manifest.files.reject { |relative| File.exist?(@project.path(relative)) }
|
|
116
|
+
if missing.any?
|
|
117
|
+
count = (missing.size == 1) ? "1 engine file is" : "#{missing.size} engine files are"
|
|
118
|
+
return error("#{count} missing (e.g. #{missing.first}). Run `talon engine use`.")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
binary = %w[dragonruby dragonruby.exe].map { |name| @project.path(name) }.find { |path| File.file?(path) }
|
|
122
|
+
if binary.nil?
|
|
123
|
+
error "No dragonruby binary in the project root"
|
|
124
|
+
elsif !File.executable?(binary)
|
|
125
|
+
error "#{File.basename(binary)} isn't executable"
|
|
126
|
+
else
|
|
127
|
+
ok "#{manifest.engine} is installed (#{manifest.files.size} files)"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def check_dependencies
|
|
132
|
+
manifest = Deps::Manifest.for(@project)
|
|
133
|
+
return unless manifest.exist?
|
|
134
|
+
|
|
135
|
+
count = manifest.dependencies.size
|
|
136
|
+
problems = Deps::Installer.new(@project).problems
|
|
137
|
+
problems.each do |entry|
|
|
138
|
+
dependency = entry[:dependency]
|
|
139
|
+
case entry[:problem]
|
|
140
|
+
when :unlocked then error "#{dependency.name} isn't installed or locked yet. Run `talon install`."
|
|
141
|
+
when :missing then error "#{dependency.name} is locked but #{dependency.destination} is missing. Run `talon install`."
|
|
142
|
+
when :modified then warning "#{dependency.destination} has local changes; `talon install --force` restores it"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
ok "#{count} dependenc#{(count == 1) ? "y" : "ies"} installed" if problems.empty?
|
|
146
|
+
rescue Error => e
|
|
147
|
+
error e.message
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def check_metadata
|
|
151
|
+
unset = self.class.unset_metadata(@project)
|
|
152
|
+
if unset.nil?
|
|
153
|
+
warning("No metadata/game_metadata.txt; it's needed to publish") if @project.layout == :full
|
|
154
|
+
elsif unset.empty?
|
|
155
|
+
ok "game_metadata.txt is filled in"
|
|
156
|
+
else
|
|
157
|
+
warning "game_metadata.txt doesn't set #{unset.join(", ")} yet (needed to publish)"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|