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,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
|