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,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi/escape"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+ require "tmpdir"
8
+
9
+ module DrTalon
10
+ module Engine
11
+ # Download the latest engine of a tier for this machine. Each fetcher's
12
+ # #fetch returns a zip or directory to hand to {Importer#import}; the
13
+ # caller deletes it afterwards.
14
+ module Fetchers
15
+ ZIP_MAGIC = "PK\x03\x04".b
16
+
17
+ # @param tier [String] one of {TIERS}
18
+ # @param options [Hash] passed to the fetcher (http:, downloads_dir:, platform:, ...)
19
+ # @return [DragonRubyAPI, ItchJam, Butler]
20
+ def self.for(tier, **options)
21
+ case tier
22
+ when "pro", "indie" then DragonRubyAPI.new(tier, **options)
23
+ when "jam" then ItchJam.new(**options.except(:butler))
24
+ when "standard" then Butler.new(**options.slice(:platform, :downloads_dir, :butler))
25
+ else raise Error, "Unknown tier \"#{tier}\". Expected one of #{TIERS.join(", ")}."
26
+ end
27
+ end
28
+
29
+ # @param path [String]
30
+ # @return [Boolean]
31
+ def self.zip?(path)
32
+ File.file?(path) && File.binread(path, 4) == ZIP_MAGIC
33
+ end
34
+
35
+ # Downloads +url+ to +path+, deleting a partial or non-zip result.
36
+ #
37
+ # @return [String] +path+
38
+ # @raise [Error]
39
+ def self.download_zip(http, url, path, auth: nil, &progress)
40
+ FileUtils.mkdir_p(File.dirname(path))
41
+ http.download(url, path, auth: auth, &progress)
42
+ raise Error, "The download from #{URI(url).host} isn't a zip." unless zip?(path)
43
+
44
+ path
45
+ rescue Exception # rubocop:disable Lint/RescueException -- also clean up on Ctrl-C
46
+ FileUtils.rm_f(path)
47
+ raise
48
+ end
49
+
50
+ # Pro and Indie, from dragonruby.org's subscription API with the
51
+ # account's login. The API answers with the zip's URL (as its body or a
52
+ # redirect) or with the zip itself; all three are handled.
53
+ class DragonRubyAPI
54
+ API = "https://dragonruby.org/api/download_%<tier>s_subscription_%<os>s"
55
+
56
+ # dragonruby.org's name for each platform it has downloads for.
57
+ OS_NAMES = {"linux-amd64" => "linux", "macos" => "mac", "windows-amd64" => "windows"}.freeze
58
+
59
+ # @return [String]
60
+ attr_reader :tier
61
+
62
+ # @param tier [String] "pro" or "indie"
63
+ # @param http [HTTP]
64
+ # @param downloads_dir [String] where the zip is saved
65
+ # @param platform [String]
66
+ # @param api [String] format string for the API URL (+tier+, +os+)
67
+ def initialize(tier, http: HTTP.new, downloads_dir: Paths.new.downloads_dir, platform: Engine.host_platform, api: API)
68
+ @tier = tier
69
+ @http = http
70
+ @downloads_dir = downloads_dir
71
+ @platform = platform
72
+ @api = api
73
+ end
74
+
75
+ # @return [Boolean]
76
+ def credentials?
77
+ true
78
+ end
79
+
80
+ # @return [String] the API URL for this machine
81
+ # @raise [Error] if dragonruby.org has no download for the platform
82
+ def api_url
83
+ os = OS_NAMES.fetch(@platform) do
84
+ raise Error, "dragonruby.org has no #{tier} download for #{@platform}. " \
85
+ "Download it by hand and run `talon engine import`."
86
+ end
87
+ format(@api, tier: tier, os: os)
88
+ end
89
+
90
+ # Checks a login without downloading the engine.
91
+ #
92
+ # @param credentials [Credentials]
93
+ # @return [void]
94
+ # @raise [HTTP::Unauthorized, HTTP::Error]
95
+ def verify(credentials)
96
+ Dir.mktmpdir("talon-login") do |dir|
97
+ # Stop early in case the answer is the zip itself.
98
+ catch(:enough) do
99
+ @http.download(api_url, File.join(dir, "response"), auth: credentials.to_auth) do |received, _total|
100
+ throw :enough if received > 64 * 1024
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ # @param credentials [Credentials]
107
+ # @return [String] path to the downloaded zip
108
+ # @raise [HTTP::Unauthorized, HTTP::Error, Error]
109
+ def fetch(credentials:, &progress)
110
+ raise Error, "A dragonruby.org login is needed for #{tier}." unless credentials
111
+
112
+ path = File.join(@downloads_dir, "dragonruby-#{tier}-#{@platform}.zip")
113
+ FileUtils.mkdir_p(@downloads_dir)
114
+ @http.download(api_url, path, auth: credentials.to_auth, &progress)
115
+ return path if Fetchers.zip?(path)
116
+
117
+ url = File.read(path, 4096).to_s.strip
118
+ FileUtils.rm_f(path)
119
+ unless url.match?(%r{\Ahttps?://\S+\z})
120
+ raise Error, "dragonruby.org answered with something other than a download link or zip."
121
+ end
122
+
123
+ Fetchers.download_zip(@http, url, path, &progress)
124
+ rescue Exception # rubocop:disable Lint/RescueException -- also clean up on Ctrl-C
125
+ FileUtils.rm_f(path) if path
126
+ raise
127
+ end
128
+ end
129
+
130
+ # Jam, DragonRuby's free license, from the demo uploads on its itch.io
131
+ # page. No account is needed: the page's download button posts to
132
+ # itch.io for a short-lived link, and so does this.
133
+ class ItchJam
134
+ PAGE = "https://dragonruby.itch.io/dragonruby-gtk"
135
+
136
+ # The itch.io upload names for each platform.
137
+ UPLOADS = {
138
+ "linux-amd64" => "x64 Linux Jam License",
139
+ "linux-arm64" => "arm64 Linux Jam License",
140
+ "macos" => "macOS Jam License",
141
+ "windows-amd64" => "Windows Jam License"
142
+ }.freeze
143
+
144
+ # @param http [HTTP]
145
+ # @param downloads_dir [String]
146
+ # @param platform [String]
147
+ # @param page [String] the itch.io game page
148
+ def initialize(http: HTTP.new, downloads_dir: Paths.new.downloads_dir, platform: Engine.host_platform, page: PAGE)
149
+ @http = http
150
+ @downloads_dir = downloads_dir
151
+ @platform = platform
152
+ @page = page
153
+ end
154
+
155
+ # @return [String]
156
+ def tier
157
+ "jam"
158
+ end
159
+
160
+ # @return [Boolean]
161
+ def credentials?
162
+ false
163
+ end
164
+
165
+ # @return [String] path to the downloaded zip
166
+ # @raise [HTTP::Error, Error]
167
+ def fetch(credentials: nil, &progress)
168
+ name = UPLOADS.fetch(@platform) { raise Error, "There's no Jam license download for #{@platform}." }
169
+ html = @http.get(@page)
170
+ token = html.scan(/<(?:meta|input)\b[^>]*>/).map { |tag| attributes(tag) }
171
+ .find { |attrs| attrs["name"] == "csrf_token" }&.dig("value")
172
+ id = demo_uploads(html)[name]
173
+ unless token && id
174
+ raise Error, "Couldn't find the \"#{name}\" download on #{@page}; itch.io's page may have changed. " \
175
+ "Download it by hand and run `talon engine import`."
176
+ end
177
+
178
+ answer = JSON.parse(@http.post_form("#{@page}/file/#{id}?source=view_game&as_props=1&after_download_lightbox=true",
179
+ "csrf_token" => token))
180
+ url = answer["url"] if answer.is_a?(Hash)
181
+ raise Error, "itch.io didn't return a download link for \"#{name}\"." unless url.is_a?(String)
182
+
183
+ Fetchers.download_zip(@http, url, File.join(@downloads_dir, "dragonruby-gtk-#{@platform}-jam.zip"), &progress)
184
+ rescue JSON::ParserError
185
+ raise Error, "itch.io answered the download request with something other than JSON."
186
+ end
187
+
188
+ private
189
+
190
+ # @return [Hash{String => String}] upload name => upload id, from the
191
+ # page's "Download demo" section. itch.io doesn't keep attribute
192
+ # order stable between requests, so tags are parsed attribute by
193
+ # attribute.
194
+ def demo_uploads(html)
195
+ section = html[/class="[^"]*\bfor_demo\b.*?(?=<section\b|\z)/m].to_s
196
+ section.split(/(?=<div\b[^>]*\bclass="upload")/).filter_map do |upload|
197
+ id = upload.scan(/<a\b[^>]*>/).filter_map { |tag| attributes(tag)["data-upload_id"] }.first
198
+ title = upload.scan(/<strong\b[^>]*>/).filter_map { |tag| attributes(tag)["title"] }.first
199
+ [CGI.unescapeHTML(title), id] if id && title
200
+ end.to_h
201
+ end
202
+
203
+ # @return [Hash{String => String}]
204
+ def attributes(tag)
205
+ tag.scan(/([\w-]+)="([^"]*)"/).to_h
206
+ end
207
+ end
208
+
209
+ # Standard, from itch.io with butler (after `butler login`).
210
+ class Butler
211
+ GAME = "dragonruby/dragonruby-gtk"
212
+
213
+ # @param platform [String] also the itch.io channel name
214
+ # @param downloads_dir [String] where butler's directory is created
215
+ # @param butler [String] the butler executable
216
+ def initialize(platform: Engine.host_platform, downloads_dir: Paths.new.downloads_dir, butler: "butler")
217
+ @platform = platform
218
+ @downloads_dir = downloads_dir
219
+ @butler = butler
220
+ end
221
+
222
+ # @return [String]
223
+ def tier
224
+ "standard"
225
+ end
226
+
227
+ # @return [Boolean]
228
+ def credentials?
229
+ false
230
+ end
231
+
232
+ # @return [String] a directory containing the engine
233
+ # @raise [Error] if butler is missing or the fetch fails
234
+ def fetch(credentials: nil)
235
+ FileUtils.mkdir_p(@downloads_dir)
236
+ dir = Dir.mktmpdir("butler-", @downloads_dir)
237
+ _out, err, status = Open3.capture3(@butler, "fetch", "#{GAME}:#{@platform}", dir)
238
+ return dir if status.success?
239
+
240
+ FileUtils.rm_rf(dir)
241
+ raise Error, "butler fetch failed: #{err.strip}. If you haven't yet, run `butler login`."
242
+ rescue Errno::ENOENT
243
+ FileUtils.rm_rf(dir) if dir
244
+ raise Error, "Standard engines download with itch.io's butler, which isn't installed. " \
245
+ "See https://itch.io/docs/butler/installing.html, then run `butler login`. " \
246
+ "The free Jam license needs neither: `talon engine fetch --tier jam`."
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DrTalon
4
+ module Engine
5
+ # Maintains a marked block in a project's +.gitignore+ that ignores the
6
+ # installed engine, for projects that restore it with +talon engine use+
7
+ # instead of committing it. The block is regenerated on every install so
8
+ # it follows engines that add or drop files.
9
+ module GitIgnore
10
+ BEGIN_MARKER = "# >>> talon: DragonRuby engine, restored by `talon engine use` from .dragonruby-version"
11
+ END_MARKER = "# <<< talon"
12
+ BLOCK = /^#{Regexp.escape(BEGIN_MARKER)}\n.*?^#{Regexp.escape(END_MARKER)}\n?/m
13
+
14
+ module_function
15
+
16
+ # @param files [Array<String>] project-relative engine files
17
+ # @return [Array<String>] gitignore patterns, top-level entries only
18
+ def patterns(files)
19
+ tops = files.map do |relative|
20
+ top, rest = relative.split("/", 2)
21
+ rest ? "/#{top}/" : "/#{top}"
22
+ end
23
+ ["/.talon/", *tops.uniq.sort]
24
+ end
25
+
26
+ # @param path [String] the .gitignore
27
+ # @return [Boolean]
28
+ def block?(path)
29
+ File.file?(path) && File.foreach(path).any? { |line| line.chomp == BEGIN_MARKER }
30
+ end
31
+
32
+ # Replaces the block, or appends it if there isn't one.
33
+ #
34
+ # @param path [String] the .gitignore
35
+ # @param files [Array<String>]
36
+ # @return [void]
37
+ def write(path, files)
38
+ block = [BEGIN_MARKER, *patterns(files), END_MARKER].join("\n") + "\n"
39
+ content = File.file?(path) ? File.read(path) : ""
40
+
41
+ content =
42
+ if content.match?(BLOCK)
43
+ content.sub(BLOCK) { block }
44
+ else
45
+ [content.empty? ? nil : content.chomp, block].compact.join("\n\n")
46
+ end
47
+
48
+ File.write(path, content)
49
+ end
50
+
51
+ # Rewrites the block only if the .gitignore already has one, so projects
52
+ # that commit their engine are left alone.
53
+ #
54
+ # @return [Boolean] whether it was rewritten
55
+ def refresh(path, files)
56
+ return false unless block?(path)
57
+
58
+ write(path, files)
59
+ true
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "time"
6
+ require "zip"
7
+
8
+ module DrTalon
9
+ module Engine
10
+ # Adds a DragonRuby distribution to the {Store}, from either the zip as
11
+ # downloaded or a directory it was already extracted to.
12
+ class Importer
13
+ # @!attribute [r] installation
14
+ # @return [Installation]
15
+ # @!attribute [r] status
16
+ # @return [Symbol] +:installed+, +:replaced+ or +:unchanged+ (the same
17
+ # zip was already imported)
18
+ Result = Data.define(:installation, :status)
19
+
20
+ # @param store [Store]
21
+ # @param tier [String, nil] overrides tier detection
22
+ # @param platform [String, nil] overrides platform detection
23
+ # @param force [Boolean] replace an already cached engine with the same id
24
+ def initialize(store, tier: nil, platform: nil, force: false)
25
+ @store = store
26
+ @tier = tier
27
+ @platform = platform
28
+ @force = force
29
+ end
30
+
31
+ # @param source [String] path to a .zip or an extracted engine directory
32
+ # @return [Result]
33
+ # @raise [Error] if the source is missing or isn't a DragonRuby engine
34
+ # @raise [AlreadyInstalled] if the id is taken and +force+ is off
35
+ def import(source)
36
+ source = File.expand_path(source)
37
+
38
+ @store.stage do |staging|
39
+ root, hints, sha256 =
40
+ if File.directory?(source)
41
+ copy_directory(source, staging)
42
+ elsif File.file?(source)
43
+ extract_zip(source, staging)
44
+ else
45
+ raise Error, "#{source} does not exist."
46
+ end
47
+
48
+ details = Inspector.new(root, hints: hints, tier: @tier, platform: @platform).details
49
+ installation = Installation.new(
50
+ **details,
51
+ path: @store.path_for("#{details[:version]}-#{details[:tier]}-#{details[:platform]}"),
52
+ sha256: sha256,
53
+ source: File.basename(source),
54
+ imported_at: Time.now.utc.iso8601
55
+ )
56
+
57
+ install(installation, root)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def copy_directory(source, staging)
64
+ root = Inspector.find_root(source)
65
+ FileUtils.cp_r(root, staging, preserve: true)
66
+
67
+ hints = [File.basename(source), File.basename(File.dirname(root))]
68
+ [File.join(staging, File.basename(root)), hints, nil]
69
+ end
70
+
71
+ def extract_zip(source, staging)
72
+ Zip::File.open(source, restore_permissions: true) { |zip| zip.extract_all(staging) }
73
+
74
+ [Inspector.find_root(staging), [File.basename(source, ".*")], Digest::SHA256.file(source).hexdigest]
75
+ rescue Zip::Error => e
76
+ raise Error, "Can't read #{source} as a zip: #{e.message}"
77
+ end
78
+
79
+ def install(installation, root)
80
+ status = :installed
81
+
82
+ if (existing = @store.get(installation.id))
83
+ if !@force && existing.sha256 && existing.sha256 == installation.sha256
84
+ return Result.new(installation: existing, status: :unchanged)
85
+ end
86
+
87
+ unless @force
88
+ raise AlreadyInstalled, "#{installation.id} is already cached (imported from #{existing.source}). " \
89
+ "Pass --force to replace it."
90
+ end
91
+
92
+ @store.remove(existing)
93
+ status = :replaced
94
+ end
95
+
96
+ installation.write_metadata(root)
97
+ File.rename(root, installation.path)
98
+
99
+ Result.new(installation: installation, status: status)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DrTalon
4
+ module Engine
5
+ # Reads identifying details out of an unpacked DragonRuby distribution.
6
+ #
7
+ # - version: the first "* X.Y" heading in +CHANGELOG-CURR.txt+
8
+ # (+VERSION.txt+ only has the build date and git sha)
9
+ # - platform: from the root directory name, +dragonruby-<platform>+
10
+ # - tier: from names such as the zip's, +dragonruby-pro-linux-amd64.zip+;
11
+ # the engine files themselves don't say
12
+ class Inspector
13
+ CHANGELOG = "CHANGELOG-CURR.txt"
14
+ VERSION_FILE = "VERSION.txt"
15
+ BINARIES = %w[dragonruby dragonruby.exe].freeze
16
+
17
+ # Finds the engine root at +dir+ or directly inside it, which covers both
18
+ # an extracted zip (+dragonruby-pro-linux-amd64/dragonruby-linux-amd64/+)
19
+ # and the engine directory itself.
20
+ #
21
+ # @param dir [String]
22
+ # @return [String]
23
+ # @raise [InvalidEngine] if there isn't exactly one engine root
24
+ def self.find_root(dir)
25
+ return dir if engine_root?(dir)
26
+
27
+ candidates = Dir.children(dir).map { |child| File.join(dir, child) }.select { |path| engine_root?(path) }
28
+ return candidates.first if candidates.one?
29
+
30
+ if candidates.empty?
31
+ raise InvalidEngine, "#{dir} doesn't look like a DragonRuby engine " \
32
+ "(no directory with #{CHANGELOG} and a dragonruby binary)."
33
+ end
34
+
35
+ raise InvalidEngine, "#{dir} contains more than one DragonRuby engine: " \
36
+ "#{candidates.map { |path| File.basename(path) }.join(", ")}"
37
+ end
38
+
39
+ # @param dir [String]
40
+ # @return [Boolean]
41
+ def self.engine_root?(dir)
42
+ File.directory?(dir) &&
43
+ File.file?(File.join(dir, CHANGELOG)) &&
44
+ BINARIES.any? { |binary| File.file?(File.join(dir, binary)) }
45
+ end
46
+
47
+ # @param root [String] the engine root, see {.find_root}
48
+ # @param hints [Array<String>] names to look for a tier in, such as the
49
+ # zip's basename
50
+ # @param tier [String, nil] overrides tier detection
51
+ # @param platform [String, nil] overrides platform detection
52
+ def initialize(root, hints: [], tier: nil, platform: nil)
53
+ @root = root
54
+ @hints = hints
55
+ @tier = tier
56
+ @platform = platform
57
+ end
58
+
59
+ # @return [Hash{Symbol => String, nil}] +version+, +tier+, +platform+,
60
+ # +build_date+ and +git_sha+
61
+ # @raise [InvalidEngine] if a required detail can't be determined
62
+ def details
63
+ {version: version, tier: tier, platform: platform, **build_info}
64
+ end
65
+
66
+ private
67
+
68
+ def version
69
+ File.foreach(File.join(@root, CHANGELOG)) do |line|
70
+ match = /\A\* (\d+(?:\.\d+)+)\s*\z/.match(line)
71
+ return match[1] if match
72
+ end
73
+
74
+ raise InvalidEngine, "Can't find a version heading (e.g. \"* 7.23\") in #{CHANGELOG}."
75
+ end
76
+
77
+ def tier
78
+ return validate_tier(@tier) if @tier
79
+
80
+ names = [*@hints, File.basename(@root)]
81
+ found = names.filter_map { |name| /(?:\A|[-_])(#{TIERS.join("|")})(?=[-_.]|\z)/.match(name)&.[](1) }.uniq
82
+ return found.first if found.one?
83
+
84
+ raise InvalidEngine, "Can't tell the license tier from #{names.uniq.join(", ")}. " \
85
+ "Pass --tier #{TIERS.join("|")}."
86
+ end
87
+
88
+ def platform
89
+ return @platform if @platform
90
+
91
+ match = /\Adragonruby-([a-z0-9-]+)\z/.match(File.basename(@root))
92
+ return match[1] if match
93
+
94
+ raise InvalidEngine, "Can't tell the platform from #{File.basename(@root)}. " \
95
+ "Pass --platform, e.g. --platform #{Engine.host_platform}."
96
+ end
97
+
98
+ def validate_tier(tier)
99
+ return tier if TIERS.include?(tier)
100
+
101
+ raise InvalidEngine, "Unknown tier \"#{tier}\". Expected one of #{TIERS.join(", ")}."
102
+ end
103
+
104
+ def build_info
105
+ path = File.join(@root, VERSION_FILE)
106
+ lines = File.file?(path) ? File.readlines(path, chomp: true) : []
107
+ fields = lines.filter_map { |line| line.split(": ", 2) if line.include?(": ") }.to_h
108
+
109
+ {build_date: fields["date"], git_sha: fields["git"]}
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "yaml"
5
+
6
+ module DrTalon
7
+ module Engine
8
+ Installation = Data.define(
9
+ :version, :tier, :platform, :path, :build_date, :git_sha, :sha256, :source, :imported_at
10
+ )
11
+
12
+ # An engine unpacked in the {Store}. Its details are recorded in a
13
+ # +.talon-engine.yml+ file at the engine root.
14
+ #
15
+ # @!attribute [r] version
16
+ # @return [String] release number from +CHANGELOG-CURR.txt+, e.g. "7.23"
17
+ # @!attribute [r] tier
18
+ # @return [String] one of {TIERS}
19
+ # @!attribute [r] platform
20
+ # @return [String] e.g. "linux-amd64", "macos", "windows-amd64"
21
+ # @!attribute [r] path
22
+ # @return [String] the engine root directory
23
+ # @!attribute [r] build_date
24
+ # @return [String, nil] +date:+ from +VERSION.txt+
25
+ # @!attribute [r] git_sha
26
+ # @return [String, nil] +git:+ from +VERSION.txt+
27
+ # @!attribute [r] sha256
28
+ # @return [String, nil] digest of the imported zip; nil for directories
29
+ # @!attribute [r] source
30
+ # @return [String, nil] basename of what was imported
31
+ # @!attribute [r] imported_at
32
+ # @return [String, nil] ISO 8601 UTC timestamp
33
+ class Installation
34
+ METADATA_FILE = ".talon-engine.yml"
35
+ RECORDED = %i[version tier platform build_date git_sha sha256 source imported_at].freeze
36
+
37
+ # @param path [String] an engine root containing {METADATA_FILE}
38
+ # @return [Installation]
39
+ # @raise [InvalidEngine] if the metadata is missing or unreadable
40
+ def self.load(path)
41
+ file = File.join(path, METADATA_FILE)
42
+ data = YAML.safe_load_file(file)
43
+ raise InvalidEngine, "#{file} is not a mapping" unless data.is_a?(Hash)
44
+
45
+ new(**RECORDED.to_h { |key| [key, data[key.to_s]] }, path: path)
46
+ rescue Errno::ENOENT, Psych::SyntaxError => e
47
+ raise InvalidEngine, "Can't read engine metadata at #{file}: #{e.message}"
48
+ end
49
+
50
+ # @return [String] e.g. "7.23-pro-linux-amd64"
51
+ def id
52
+ "#{version}-#{tier}-#{platform}"
53
+ end
54
+
55
+ # @return [Gem::Version]
56
+ def gem_version
57
+ Gem::Version.new(version)
58
+ end
59
+
60
+ # @return [String, nil] the build date as YYYY-MM-DD when it parses
61
+ def built_on
62
+ build_date && Time.parse(build_date).strftime("%Y-%m-%d")
63
+ rescue ArgumentError
64
+ build_date
65
+ end
66
+
67
+ # Writes {METADATA_FILE} into +dir+ (defaults to {#path}).
68
+ #
69
+ # @param dir [String]
70
+ # @return [void]
71
+ def write_metadata(dir = path)
72
+ data = RECORDED.to_h { |key| [key.to_s, public_send(key)] }
73
+ File.write(File.join(dir, METADATA_FILE), YAML.dump(data))
74
+ end
75
+ end
76
+ end
77
+ end