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.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +60 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +270 -0
  6. data/Rakefile +10 -0
  7. data/exe/talon +6 -0
  8. data/lib/dr_talon/cli/engine.rb +274 -0
  9. data/lib/dr_talon/cli/generate.rb +29 -0
  10. data/lib/dr_talon/cli.rb +343 -0
  11. data/lib/dr_talon/config.rb +29 -0
  12. data/lib/dr_talon/credentials.rb +94 -0
  13. data/lib/dr_talon/deps/dependency.rb +238 -0
  14. data/lib/dr_talon/deps/git.rb +69 -0
  15. data/lib/dr_talon/deps/installer.rb +207 -0
  16. data/lib/dr_talon/deps/manifest.rb +220 -0
  17. data/lib/dr_talon/deps/tree.rb +97 -0
  18. data/lib/dr_talon/deps.rb +62 -0
  19. data/lib/dr_talon/doctor.rb +161 -0
  20. data/lib/dr_talon/engine/fetchers.rb +251 -0
  21. data/lib/dr_talon/engine/git_ignore.rb +63 -0
  22. data/lib/dr_talon/engine/importer.rb +103 -0
  23. data/lib/dr_talon/engine/inspector.rb +113 -0
  24. data/lib/dr_talon/engine/installation.rb +77 -0
  25. data/lib/dr_talon/engine/materializer.rb +172 -0
  26. data/lib/dr_talon/engine/spec.rb +38 -0
  27. data/lib/dr_talon/engine/store.rb +119 -0
  28. data/lib/dr_talon/engine.rb +50 -0
  29. data/lib/dr_talon/errors.rb +5 -0
  30. data/lib/dr_talon/file_copy.rb +49 -0
  31. data/lib/dr_talon/generators/new_project.rb +289 -0
  32. data/lib/dr_talon/generators/talon_code.rb +92 -0
  33. data/lib/dr_talon/http.rb +120 -0
  34. data/lib/dr_talon/paths.rb +115 -0
  35. data/lib/dr_talon/project.rb +101 -0
  36. data/lib/dr_talon/runner.rb +188 -0
  37. data/lib/dr_talon/talon_framework.rb +103 -0
  38. data/lib/dr_talon/templates/bare/main_tests.rb.tt +7 -0
  39. data/lib/dr_talon/templates/bare/rubocop.yml.tt +8 -0
  40. data/lib/dr_talon/templates/bare/solargraph.yml.tt +24 -0
  41. data/lib/dr_talon/templates/lib/app/main.rb.tt +13 -0
  42. data/lib/dr_talon/templates/lib/lib/library.rb.tt +4 -0
  43. data/lib/dr_talon/templates/lib/lib/version.rb.tt +3 -0
  44. data/lib/dr_talon/templates/lib/rubocop.yml.tt +9 -0
  45. data/lib/dr_talon/templates/lib/solargraph.yml.tt +23 -0
  46. data/lib/dr_talon/templates/lib/test/test_helper.rb.tt +1 -0
  47. data/lib/dr_talon/templates/lib/test/tests.rb.tt +8 -0
  48. data/lib/dr_talon/templates/shared/Gemfile.tt +10 -0
  49. data/lib/dr_talon/templates/shared/README.md.tt +45 -0
  50. data/lib/dr_talon/templates/shared/editorconfig.tt +9 -0
  51. data/lib/dr_talon/templates/shared/gitignore.tt +19 -0
  52. data/lib/dr_talon/templates/talon/game/dependencies.rb.tt +3 -0
  53. data/lib/dr_talon/templates/talon/game/main.rb.tt +9 -0
  54. data/lib/dr_talon/templates/talon/game/spin.rb.tt +7 -0
  55. data/lib/dr_talon/templates/talon/game/title.rb.tt +8 -0
  56. data/lib/dr_talon/templates/talon/game/title_tests.rb.tt +16 -0
  57. data/lib/dr_talon/templates/talon/generate/component.rb.tt +19 -0
  58. data/lib/dr_talon/templates/talon/generate/component_tests.rb.tt +15 -0
  59. data/lib/dr_talon/templates/talon/generate/prefab.rb.tt +11 -0
  60. data/lib/dr_talon/templates/talon/generate/prefab_tests.rb.tt +15 -0
  61. data/lib/dr_talon/templates/talon/generate/scene.rb.tt +14 -0
  62. data/lib/dr_talon/templates/talon/generate/scene_tests.rb.tt +13 -0
  63. data/lib/dr_talon/test_report.rb +76 -0
  64. data/lib/dr_talon/version.rb +5 -0
  65. data/lib/dr_talon.rb +17 -0
  66. data/sig/dr_talon.rbs +524 -0
  67. 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