-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathfoodchain.rb
388 lines (340 loc) · 13.1 KB
/
foodchain.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# Handles the mechanics of performing an HTTP GET request. Calls the block iff
# the request was successful; error handling is hardcoded for this application.
class Downloader
# Creates a new Downloader, responsible for making a single HTTP request.
#
# @param url [String] The URL to fetch.
# @param headers [Array] An array of HTTP header strings to include.
# @yield [Hash] The result of the HTTP request.
def initialize(url, headers, &block)
unless block_given?
raise ArgumentError, "Downloader cannot be constructed without a block"
end
@url = url
@headers = headers
@handler = block
@result = $gtk.http_get(url, headers)
@result.merge!(url: url, request_headers: headers)
end
# Calls the handler the result once the request has been completed.
def tick
@handler.call(@result) if @result[:complete]
end
# @returns [Boolean] Has this request completed?
def complete?
@result[:complete]
end
end
# Represents an dependency; look to subclasses for specific implementations.
# @abstract
class Dependency
# Intitializes a new Dependency with an empty download queue.
def initialize
@download_queue = []
end
# Identifies this dependency in the lock table.
# @abstract
def key
raise NotImplementedError, "Dependency subclasses must implement #key."
end
# Configures the initial download for this dependency.
# @abstract
def boot
raise NotImplementedError, "Dependency subclasses must implement #boot."
end
# Causes each download in the queue to tick, discarding any finished requests.
def tick
@download_queue.each(&:tick).reject!(&:complete?)
end
# Creates and enqueues a new download. Dispatches to the passed block on
# successful requests.
# @see Downloader#initialize
def download(url, headers, &block)
@download_queue << Downloader.new(url, headers) do |result|
case (code = result[:http_response_code])
when 200
yield result
when 304
$gtk.log_info "#{key} is up-to-date.", "Foodchain"
else
$gtk.log_error "GET #{url} returned status code #{code}", "Foodchain"
end
end
end
# Records a new lock version for this dependency.
#
# We're leveraging the `Etag` cache key as our lock version by convention,
# since it's an identifier of what the server believes identifies this version
# of the content, and can be used with conditional HTTP requests.
#
# @param result [Hash] The response from a successful HTTP request.
def update_lock_version(result)
etag = result.dig(:response_headers, "Etag")
if $state.locks[key] && $state.locks[key] != etag
$state.outdated << key
else
$state.locks[key] = etag
$state.update_lock_versions = true
end
end
# @return [Boolean] Has this dependency been resolved?
def complete?
@download_queue.empty?
end
end
# Represents a direct dependency on a URL.
class Dependency::URL < Dependency
# Creates a new `Dependency` on a specific URL.
#
# @param url [String] The resource to be downloaded.
# @param destination [String] The game-local file path for the download.
def initialize(url, destination:)
super()
@url = url
@destination = destination
end
# {include:Dependency#key}
def key
@url
end
# {include:Dependency#boot}
def boot
download(@url, ["If-None-Match: #{$state.locks[key]}"]) do |result|
update_lock_version(result)
$gtk.write_file(@destination, result[:response_data])
end
end
end
# Represents a dependency on a resource hosted by GitHub.
class Dependency::GitHub < Dependency
ACCEPT_HEADER = "Accept: application/vnd.github.raw+json"
# Creates a new `Dependency` on a path within a specific GitHub repository.
#
# @param owner [#to_s] The repository's owner.
# @param repo [#to_s] The repository's name.
# @param path [#to_s] The repository-relative path do be downloaded. This may
# be either a single file or a directory (which will be downloaded
# recursively into `destination`).
# @param ref [#to_s, nil] The git "ref" to use. Can identify a branch, a tag,
# or a specific commit SHA. If omitted, GitHub will use the HEAD of the
# default branch.
# @param destination [String, nil] The game-local file path for the download.
# If `path` refers to a file, then `destination` will be treated as the
# path in which to save that file's contents; if `path` refers to a
# directory, then the files within that directory will be saved into a
# local directory named `destination`.
def initialize(owner, repo, path, ref: nil, destination: nil)
super()
@owner = owner
@repo = repo
@path = path
@ref = ref || ""
@url = "https://api.github.com/repos/#{owner}/#{repo}/contents/#{path}"
@destination = destination || "vendor/#{owner}/#{repo}/#{path}"
end
# {include:Dependency#key}
def key
"github:#{@owner}/#{@repo}/#{@path}"
end
# {include:Dependency#boot}
def boot
url = (@ref.empty? ? @url : "#{@url}?ref=#{@ref}")
headers = [ ACCEPT_HEADER, "If-None-Match: #{$state.locks[key]}" ]
download(url, headers) do |result|
update_lock_version(result)
process(result, @destination)
end
end
# Handles a successful API response from Github.
#
# In practice, there are three types of responses we can expect to recieve
# from the API endpoint we're querying:
# * Responses for *regular file* requests will have a Content-Type that
# matches our `Accept` header, and will have a body that contains the file's
# contents.
# * Responses for *directory* requests will have a Content-Type of
# `application/json`, and will contain a JSON array of hashes describing the
# directory's contents.
# * Responses for *other* object types (including symlinks and submodules)
# will have a Content-Type of `application/json`, and will contain a JSON
# hash describing that object.
#
# We make no effort to process anything but files and directories.
#
# @param result [Hash] The result of the HTTP query.
# @param destination [String] The game-relative file path for this result.
def process(result, destination)
headers, body = result.values_at(:response_headers, :response_data)
# If we got an "application/json" response, GitHub isn't sending us the raw
# file contents. This happens when requesting a directory or other special
# type of object.
if headers["Content-Type"].start_with?("application/json")
response = $gtk.parse_json(body)
# For directory responses, GitHub sends an array of objects; symlinks and
# submodules come back as hashes.
if response.is_a?(Array)
response.each do |obj|
type, url, path = obj.values_at("type", "url", "path")
next unless %w[ file dir ].include?(type)
# @TODO We currently don't store lock versions for recursive queries.
# This is based on the assumption that GitHub would return a new
# Etag value if the directory or any of its descendants were
# changed. We should validate this assumption, since it's
# possible that GitHub may only change based on the response
# content (which is inherently shallow).
download(url, [ACCEPT_HEADER]) do |result|
result.merge!(url: url)
destination = "#{@destination}/#{path.delete_prefix(@path)}"
process(result, destination)
end
end
else
message = "Could not process the response from #{result[:url]}"
$gtk.log_error message, "Foodchain"
end
else
$gtk.write_file(destination, body)
end
end
end
# Records a dependency on a specific URL.
# @param url [String] The resource to be downloaded.
# @param destination [String] The game-local file path for the download.
def url(url, destination:)
$state.deps << Dependency::URL.new(url, destination: destination)
end
# Creates a new `Dependency` on a path within a specific GitHub repository.
#
# @param owner [#to_s] The repository's owner.
# @param repo [#to_s] The repository's name.
# @param path [#to_s] The repository-relative path do be downloaded. This may
# be either a single file or a directory (which will be downloaded
# recursively into `destination`).
# @param ref [#to_s, nil] The git "ref" to use. Can identify a branch, a tag,
# or a specific commit SHA. If omitted, GitHub will use the HEAD of the
# default branch.
# @param destination [String, nil] The game-local file path for the download.
# If `path` refers to a file, then `destination` will be treated as the
# path in which to save that file's contents; if `path` refers to a
# directory, then the files within that directory will be saved into a
# local directory named `destination`.
# Defaults to "vendor/$owner/$repo/$path".
def github(owner, repo, file, ref: nil, destination: nil)
$state.deps << Dependency::GitHub.new(
owner,
repo,
file,
ref: ref,
destination: destination,
)
end
HELP_FLAGS = %i[ help noop no-op dryrun dry-run ]
UPDATE_FLAGS = %i[ update upgrade overwrite ]
# Implements the basic fetch loop.
#
# @NOTE This hooks into the `GTK::Runtime` at a fairly low level, specifically
# because `--eval` will run this *after* loading `app/main.rb` — if the
# game has itself patched `GTK::Runtime` in this way, a simple global
# `tick` method is never going to be executed.
def $gtk.tick_core
@is_inside_tick = true
unless $gtk.cli_arguments.key?(:eval)
$gtk.log_error "\n\n" + <<~TEXT, "Foodchain"
Foodchain is not intended to be required in your game's code.
Please move your dependencies into a separate file, and run:
#{$gtk.argv} --eval <your-dependency-file.rb>
TEXT
return $gtk.request_quit
end
if ($gtk.cli_arguments.keys & HELP_FLAGS).any?
$gtk.log_debug "\n\n" + <<~HELP, "Foodchain"
Dependency management by Foodchain
https://github.com/pvande/foodchain
Installs the dependencies described in #{$gtk.cli_arguments[:eval]}.
Options:
--update [dependency-key ...]
Updates the identified dependencies with their current versions. If no
dependencies are identified, all non-current dependencies are updated.
--help
Displays this help message.
HELP
return $gtk.request_quit
end
if Kernel.global_tick_count.zero?
contents = $gtk.read_file($state.depfile)
config, _, locks = contents.partition("__END__\n")
config.rstrip!
locks = locks.lines.map!(&:chomp)
locks.reject!(&:empty?)
locks.reject! { |x| x.start_with?("#") }
locks = locks.to_h { |line| line.split("\t") }
$state.config = config
$state.locks = locks.slice(*$state.deps.map(&:key).sort)
$state.update_lock_versions = (locks.size != $state.locks.size)
$state.outdated = []
action = "Installing"
upgrades = nil
if ($gtk.cli_arguments.keys & UPDATE_FLAGS).any?
action = "Upgrading"
upgrades = $gtk.cli_arguments.values_at(*UPDATE_FLAGS)
upgrades.map! { |opt| Array(opt) }.flatten!
if upgrades.empty?
$state.locks.clear
else
unknown = upgrades - $state.deps.map(&:key)
if unknown.any?
$gtk.log_error "\n\n" + <<~TEXT, "Foodchain"
The following dependencies are unknown:
#{unknown.map! { |x| " * " + x }.join("\n")}
Please pass the dependency keys from #{$gtk.cli_arguments[:eval]},
or pass no argument to upgrade all dependencies.
TEXT
return $gtk.request_quit
end
$state.locks = $state.locks.except(*upgrades)
end
end
$gtk.log_info "#{action} dependencies…", "Foodchain"
$state.deps.each(&:boot)
end
$state.deps.each(&:tick).reject!(&:complete?)
return unless $state.deps.empty?
if $state.update_lock_versions
$gtk.log_info "Updating locks…", "Foodchain"
contents = [
$state.config,
"",
"__END__",
"",
"# The lines below pin the versions of your installed dependencies.",
"# Removing or changing these lines may result in those dependencies",
"# being overwritten on your next installation.",
"",
$state.locks.to_a.map { |pair| pair.join("\t") }.sort,
"",
]
$gtk.write_file($state.depfile, contents.flatten.join("\n"))
end
if $state.outdated.any?
if $state.outdated.one?
$gtk.log_debug "\n\n" + <<~HELP, "Foodchain"
An update is available for #{$state.outdated.first}
Please run this again with the `--update` option to update.
HELP
else
$gtk.log_debug "\n\n" + <<~HELP, "Foodchain"
The following dependencies have updates available:
#{$state.outdated.map! { |x| " * " + x }.join("\n")}
Please run this again with the `--update` option to update all these
depenencies, or run with `--update [dependency-key]` to update single
dependencies one at a time.
HELP
end
end
$gtk.log_info "All done!", "Foodchain"
$gtk.request_quit
ensure
@is_inside_tick = false
end
$state.depfile = caller.first.rpartition(":").first
$state.deps = []