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,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "tmpdir"
5
+ require "uri"
6
+
7
+ module DrTalon
8
+ module Deps
9
+ # Where sources fetch from; tests swap in local fakes.
10
+ Context = Data.define(:http, :git, :github_url, :codeload_url) do
11
+ def self.default
12
+ new(http: DrTalon::HTTP.new, git: Git.new, github_url: "https://github.com", codeload_url: "https://codeload.github.com")
13
+ end
14
+ end
15
+
16
+ # One entry in +dependencies.rb+: where it comes from and where it's
17
+ # vendored inside the game directory.
18
+ class Dependency
19
+ VENDOR = "vendor"
20
+
21
+ # @return [String] short name, used by `talon add/remove/update`
22
+ attr_reader :name
23
+ # @return [String] game-relative path the files are written to
24
+ attr_reader :destination
25
+ # @return [String, nil] requested branch, tag or commit
26
+ attr_reader :ref
27
+ # @return [Integer, nil] line in dependencies.rb that declared it
28
+ attr_reader :line
29
+
30
+ # @param name [String, nil] defaults to {#default_name}
31
+ # @param destination [String, nil] defaults to vendor/NAME
32
+ def initialize(name: nil, destination: nil, ref: nil, line: nil)
33
+ @ref = ref&.to_s
34
+ @line = line
35
+ @destination = destination&.to_s || "#{VENDOR}/#{name || default_name}"
36
+ @name = (name || (destination ? File.basename(@destination) : default_name)).to_s
37
+ validate_destination!
38
+ end
39
+
40
+ # @return [String] the lock key, in Foodchain's format where one exists
41
+ def key
42
+ raise NotImplementedError
43
+ end
44
+
45
+ # @return [Boolean] whether {#resolve} pins a version (path deps don't)
46
+ def lockable?
47
+ true
48
+ end
49
+
50
+ # The commit (or content digest) to install for the current {#ref}.
51
+ #
52
+ # @param context [Context]
53
+ # @return [String, nil]
54
+ def resolve(context)
55
+ raise NotImplementedError
56
+ end
57
+
58
+ # Writes the dependency's files for +commit+ to +target+.
59
+ #
60
+ # @param context [Context]
61
+ # @param commit [String, nil]
62
+ # @param target [String] absolute path; doesn't exist yet
63
+ # @return [void]
64
+ def fetch(context, commit, target)
65
+ raise NotImplementedError
66
+ end
67
+
68
+ # @return [String] e.g. "github:asimmons91/talon (lib)"
69
+ def to_s
70
+ key
71
+ end
72
+
73
+ private
74
+
75
+ def default_name
76
+ raise NotImplementedError
77
+ end
78
+
79
+ def validate_destination!
80
+ parts = @destination.split("/")
81
+ return if parts.first == VENDOR && parts.size > 1 && !parts.include?("..") && !parts.include?("") &&
82
+ !@destination.start_with?("/")
83
+
84
+ raise ManifestError, "Dependency destination #{@destination.inspect} must be inside #{VENDOR}/, " \
85
+ "e.g. #{VENDOR}/#{File.basename(@destination)}. talon replaces it on install."
86
+ end
87
+
88
+ def subpath_name(subpath, fallback)
89
+ base = File.basename(subpath.to_s)
90
+ (subpath.to_s.empty? || !base.include?(".")) ? fallback : base
91
+ end
92
+ end
93
+
94
+ # A path inside a GitHub repository, downloaded as a tarball of the
95
+ # resolved commit.
96
+ class GitHubDependency < Dependency
97
+ attr_reader :owner, :repo, :subpath
98
+
99
+ def initialize(owner, repo, subpath: nil, **options)
100
+ @owner = owner.to_s
101
+ @repo = repo.to_s
102
+ @subpath = subpath.to_s.delete_prefix("/").delete_suffix("/")
103
+ super(**options)
104
+ end
105
+
106
+ def key
107
+ ["github:#{owner}/#{repo}", (subpath unless subpath.empty?)].compact.join("/")
108
+ end
109
+
110
+ def resolve(context)
111
+ context.git.resolve("#{context.github_url}/#{owner}/#{repo}.git", ref)
112
+ end
113
+
114
+ def fetch(context, commit, target)
115
+ data = context.http.get("#{context.codeload_url}/#{owner}/#{repo}/tar.gz/#{commit}")
116
+ Dir.mktmpdir("talon-github") do |dir|
117
+ Tree.untar(data, dir)
118
+ Tree.copy_subpath(dir, subpath, target)
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def default_name
125
+ subpath_name(subpath, repo)
126
+ end
127
+ end
128
+
129
+ # A path inside any git repository, fetched with the git CLI.
130
+ class GitDependency < Dependency
131
+ attr_reader :url, :subpath
132
+
133
+ def initialize(url, subpath: nil, **options)
134
+ @url = url.to_s
135
+ @subpath = subpath.to_s.delete_prefix("/").delete_suffix("/")
136
+ super(**options)
137
+ end
138
+
139
+ def key
140
+ subpath.empty? ? "git:#{url}" : "git:#{url}?path=#{subpath}"
141
+ end
142
+
143
+ def resolve(context)
144
+ context.git.resolve(url, ref)
145
+ end
146
+
147
+ def fetch(context, commit, target)
148
+ Dir.mktmpdir("talon-git") do |dir|
149
+ checkout = File.join(dir, "repo")
150
+ context.git.checkout(url, commit, checkout)
151
+ Tree.copy_subpath(checkout, subpath, target)
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ def default_name
158
+ subpath_name(subpath, File.basename(url.chomp("/"), ".git"))
159
+ end
160
+ end
161
+
162
+ # A single file downloaded from a URL, pinned by its SHA-256.
163
+ class URLDependency < Dependency
164
+ attr_reader :url
165
+
166
+ def initialize(url, **options)
167
+ @url = url.to_s
168
+ super(**options)
169
+ end
170
+
171
+ def key
172
+ url
173
+ end
174
+
175
+ def resolve(context)
176
+ @body = context.http.get(url)
177
+ "sha256:#{Digest::SHA256.hexdigest(@body)}"
178
+ end
179
+
180
+ def fetch(context, commit, target)
181
+ body = @body || context.http.get(url)
182
+ digest = "sha256:#{Digest::SHA256.hexdigest(body)}"
183
+ if commit && digest != commit
184
+ raise FetchError, "#{url} has changed since it was locked. Run `talon update #{name}` to accept the new version."
185
+ end
186
+
187
+ FileUtils.mkdir_p(File.dirname(target))
188
+ File.binwrite(target, body)
189
+ end
190
+
191
+ private
192
+
193
+ def default_name
194
+ File.basename(URI(url).path)
195
+ rescue URI::InvalidURIError
196
+ raise ManifestError, "Invalid URL: #{url}"
197
+ end
198
+ end
199
+
200
+ # A local file or directory, copied on every install. For developing a
201
+ # library alongside a game.
202
+ class PathDependency < Dependency
203
+ attr_reader :path
204
+
205
+ # @param path [String] as written in dependencies.rb
206
+ # @param base [String] what a relative +path+ is relative to
207
+ def initialize(path, base:, **options)
208
+ @path = path.to_s
209
+ @absolute = File.expand_path(@path, base)
210
+ super(**options)
211
+ end
212
+
213
+ def key
214
+ "path:#{path}"
215
+ end
216
+
217
+ def lockable?
218
+ false
219
+ end
220
+
221
+ def resolve(_context)
222
+ nil
223
+ end
224
+
225
+ def fetch(_context, _commit, target)
226
+ raise FetchError, "#{@absolute} doesn't exist." unless File.exist?(@absolute)
227
+
228
+ Tree.copy_subpath(@absolute, "", target)
229
+ end
230
+
231
+ private
232
+
233
+ def default_name
234
+ File.basename(@path.chomp("/"))
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module DrTalon
7
+ module Deps
8
+ # Runs the git CLI, for resolving refs and fetching git dependencies.
9
+ class Git
10
+ FULL_SHA = /\A\h{40}\z/
11
+
12
+ # Resolves +ref+ (a branch, tag or full commit SHA; nil for the default
13
+ # branch) to a commit SHA without cloning.
14
+ #
15
+ # @param url [String]
16
+ # @param ref [String, nil]
17
+ # @return [String]
18
+ # @raise [FetchError]
19
+ def resolve(url, ref)
20
+ return ref.downcase if ref&.match?(FULL_SHA)
21
+
22
+ refs = ls_remote(url).to_h { |line| line.split("\t", 2).reverse }
23
+ candidates =
24
+ if ref.nil?
25
+ ["HEAD"]
26
+ else
27
+ ["refs/tags/#{ref}^{}", "refs/tags/#{ref}", "refs/heads/#{ref}"]
28
+ end
29
+ sha = candidates.filter_map { |name| refs[name] }.first
30
+ return sha if sha
31
+
32
+ hint = ref&.match?(/\A\h{4,39}\z/) ? " Abbreviated SHAs aren't supported; use all 40 characters." : ""
33
+ raise FetchError, "#{url} has no branch or tag named #{ref.inspect}.#{hint}"
34
+ end
35
+
36
+ # Checks out +commit+ of +url+ into +dir+ (which must not exist).
37
+ #
38
+ # @return [void]
39
+ # @raise [FetchError]
40
+ def checkout(url, commit, dir)
41
+ run("init", "--quiet", dir)
42
+ run("-C", dir, "fetch", "--quiet", "--depth", "1", url, commit)
43
+ run("-C", dir, "-c", "advice.detachedHead=false", "checkout", "--quiet", "FETCH_HEAD")
44
+ rescue FetchError
45
+ # Some servers don't allow fetching an arbitrary commit; fall back to a
46
+ # full clone.
47
+ FileUtils.rm_rf(dir)
48
+ run("clone", "--quiet", "--no-checkout", url, dir)
49
+ run("-C", dir, "-c", "advice.detachedHead=false", "checkout", "--quiet", commit)
50
+ end
51
+
52
+ private
53
+
54
+ def ls_remote(url)
55
+ run("ls-remote", url).lines(chomp: true)
56
+ end
57
+
58
+ def run(*args)
59
+ out, err, status = Open3.capture3("git", *args)
60
+ # git's first line says what went wrong; the rest is generic advice.
61
+ raise FetchError, "git #{args.first} failed: #{err.lines.map(&:strip).find { |l| !l.empty? }}" unless status.success?
62
+
63
+ out
64
+ rescue Errno::ENOENT
65
+ raise FetchError, "git isn't installed; it's needed for git and GitHub dependencies."
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tmpdir"
5
+
6
+ module DrTalon
7
+ module Deps
8
+ # Brings vendored files in line with +dependencies.rb+ and its locks.
9
+ #
10
+ # - Locked dependencies install their locked commit; new ones, ones whose
11
+ # ref or destination changed, and ones being updated are resolved again.
12
+ # - Vendored files are replaced only when they're unchanged since talon
13
+ # wrote them (or identical to what would be written); otherwise the
14
+ # install stops unless +force+.
15
+ # - Dependencies removed from the manifest have their files deleted.
16
+ # - Locks are written even when a later dependency fails, so finished work
17
+ # isn't reported as modified next time.
18
+ class Installer
19
+ # @!attribute [r] name
20
+ # @return [String]
21
+ # @!attribute [r] action
22
+ # @return [Symbol] +:installed+, +:updated+, +:unchanged+, +:restored+
23
+ # (local changes replaced with --force), +:removed+ or +:kept+
24
+ # (removed from the manifest but locally modified)
25
+ # @!attribute [r] dependency
26
+ # @return [Dependency, nil] nil for removals
27
+ # @!attribute [r] commit
28
+ # @return [String, nil]
29
+ # @!attribute [r] previous
30
+ # @return [String, nil] the commit before an update
31
+ Change = Data.define(:name, :action, :dependency, :commit, :previous)
32
+
33
+ # @param project [Project]
34
+ # @param context [Context]
35
+ # @param force [Boolean] overwrite or delete locally modified vendored files
36
+ def initialize(project, context: Context.default, force: false)
37
+ @project = project
38
+ @context = context
39
+ @force = force
40
+ end
41
+
42
+ # @param update [Array<String>, true, nil] names to re-resolve, or true for all
43
+ # @return [Array<Change>]
44
+ # @raise [ModifiedError] if vendored files were edited and +force+ is off
45
+ # @raise [Error] if +update+ names an unknown dependency
46
+ def install(update: nil)
47
+ manifest = Manifest.for(@project)
48
+ check_update_names!(manifest, update)
49
+ previous = manifest.locks.to_h { |lock| [lock.key, lock] }
50
+ written = {}
51
+ changes = []
52
+
53
+ begin
54
+ manifest.dependencies.each do |dependency|
55
+ lock, change = install_dependency(dependency, previous[dependency.key], updating?(dependency, update))
56
+ written[dependency.key] = lock
57
+ changes << change
58
+ end
59
+
60
+ (previous.keys - manifest.dependencies.map(&:key)).each do |key|
61
+ changes << remove(previous[key])
62
+ previous.delete(key)
63
+ end
64
+ ensure
65
+ # Keep locks for anything not reached (including removals not done
66
+ # yet), so a failure doesn't forget them.
67
+ unreached = previous.reject { |key, _| written.key?(key) }
68
+ manifest.write_locks(written.values + unreached.values) if manifest.exist? || written.any?
69
+ end
70
+
71
+ changes
72
+ end
73
+
74
+ # @return [Array<Hash>] +dependency+, +locked+ and +latest+ commits of
75
+ # dependencies whose source has moved on
76
+ def outdated
77
+ manifest = Manifest.for(@project)
78
+ locks = manifest.locks.to_h { |lock| [lock.key, lock] }
79
+
80
+ manifest.dependencies.select(&:lockable?).filter_map do |dependency|
81
+ locked = locks[dependency.key]&.commit
82
+ latest = dependency.resolve(@context)
83
+ {dependency: dependency, locked: locked, latest: latest} if locked != latest
84
+ end
85
+ end
86
+
87
+ # Vendored dependencies whose files no longer match their locks.
88
+ #
89
+ # @return [Array<Hash>] +dependency+ and +problem+ (+:missing+,
90
+ # +:modified+ or +:unlocked+)
91
+ def problems
92
+ manifest = Manifest.for(@project)
93
+ locks = manifest.locks.to_h { |lock| [lock.key, lock] }
94
+
95
+ manifest.dependencies.filter_map do |dependency|
96
+ lock = locks[dependency.key]
97
+ checksum = Tree.checksum(game_path(dependency.destination))
98
+ problem =
99
+ if lock.nil? || lock.destination != dependency.destination then :unlocked
100
+ elsif checksum.nil? then :missing
101
+ elsif checksum != lock.checksum then :modified
102
+ end
103
+ {dependency: dependency, problem: problem} if problem
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def check_update_names!(manifest, update)
110
+ return unless update.is_a?(Array)
111
+
112
+ unknown = update.reject { |name| manifest.find(name) }
113
+ raise Error, "Unknown dependenc#{(unknown.size == 1) ? "y" : "ies"}: #{unknown.join(", ")}" if unknown.any?
114
+ end
115
+
116
+ def updating?(dependency, update)
117
+ update == true || (update.is_a?(Array) && update.any? { |name| [dependency.name, dependency.key].include?(name) })
118
+ end
119
+
120
+ def install_dependency(dependency, lock, updating)
121
+ reusable = lock && !updating && dependency.lockable? &&
122
+ lock.ref == dependency.ref && lock.destination == dependency.destination
123
+ target = game_path(dependency.destination)
124
+ current = Tree.checksum(target)
125
+
126
+ if reusable && current && current == lock.checksum
127
+ return [lock, change(dependency, :unchanged, lock.commit)]
128
+ end
129
+
130
+ commit = reusable ? lock.commit : dependency.resolve(@context)
131
+ managed = lock && lock.destination == dependency.destination
132
+
133
+ staged_checksum = stage(dependency, commit) do |staged|
134
+ checksum = Tree.checksum(staged)
135
+ if current && current != checksum && (!managed || current != lock.checksum) && !@force
136
+ raise ModifiedError, "#{relative(target)} has changes talon didn't make. " \
137
+ "Move them aside, or pass --force to replace them with #{dependency.name}."
138
+ end
139
+
140
+ replace(target, staged) unless current == checksum
141
+ checksum
142
+ end
143
+
144
+ remove_path(game_path(lock.destination)) if lock && lock.destination != dependency.destination
145
+
146
+ new_lock = Lock.new(key: dependency.key, ref: dependency.ref, destination: dependency.destination,
147
+ commit: commit, checksum: staged_checksum)
148
+ action =
149
+ if lock.nil? || current.nil? then :installed
150
+ elsif lock.commit != commit then :updated
151
+ elsif current == staged_checksum then :unchanged
152
+ elsif dependency.lockable? then :restored
153
+ else :updated
154
+ end
155
+ [new_lock, change(dependency, action, commit, lock&.commit)]
156
+ end
157
+
158
+ def remove(lock)
159
+ target = game_path(lock.destination)
160
+ current = Tree.checksum(target)
161
+ if current && current != lock.checksum && !@force
162
+ return Change.new(name: File.basename(lock.destination), action: :kept, dependency: nil, commit: nil, previous: nil)
163
+ end
164
+
165
+ remove_path(target)
166
+ Change.new(name: File.basename(lock.destination), action: :removed, dependency: nil, commit: nil, previous: lock.commit)
167
+ end
168
+
169
+ def change(dependency, action, commit, previous = nil)
170
+ Change.new(name: dependency.name, action: action, dependency: dependency, commit: commit, previous: previous)
171
+ end
172
+
173
+ # Fetches into a scratch directory on the project's filesystem and
174
+ # yields the fetched path.
175
+ def stage(dependency, commit)
176
+ scratch = @project.path(".talon")
177
+ FileUtils.mkdir_p(scratch)
178
+ Dir.mktmpdir("deps-", scratch) do |dir|
179
+ staged = File.join(dir, File.basename(dependency.destination))
180
+ dependency.fetch(@context, commit, staged)
181
+ yield staged
182
+ end
183
+ end
184
+
185
+ def replace(target, staged)
186
+ FileUtils.rm_rf(target)
187
+ FileUtils.mkdir_p(File.dirname(target))
188
+ File.rename(staged, target)
189
+ end
190
+
191
+ def remove_path(path)
192
+ FileUtils.rm_rf(path)
193
+ # Tidy up an emptied vendor/ (but never the game directory itself).
194
+ dir = File.dirname(path)
195
+ Dir.rmdir(dir) if dir != @project.game_dir && File.directory?(dir) && Dir.empty?(dir)
196
+ end
197
+
198
+ def game_path(relative)
199
+ File.join(@project.game_dir, relative)
200
+ end
201
+
202
+ def relative(path)
203
+ path.delete_prefix("#{@project.root}/")
204
+ end
205
+ end
206
+ end
207
+ end