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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module DrTalon
6
+ class CLI < Thor
7
+ # +talon generate ...+: Talon framework code, Rails-generator style.
8
+ class Generate < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ {
14
+ "scene" => "A Talon::Scene: one screen or level, entered with game.change_scene",
15
+ "component" => "A Talon::Component: behaviour attached to game objects with go.add",
16
+ "prefab" => "A prefab: a reusable game object setup, passed to spawn"
17
+ }.each do |kind, description|
18
+ desc "#{kind} NAME", description
19
+ method_option :skip_test, type: :boolean, default: false, desc: "Don't create a test"
20
+ method_option :force, type: :boolean, default: false, desc: "Overwrite existing files"
21
+ define_method(kind) do |name|
22
+ Generators::TalonCode.new([kind, name], options, shell: shell).invoke_all
23
+ rescue DrTalon::Error => e
24
+ raise Thor::Error, e.message
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,343 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "cli/engine"
5
+ require_relative "cli/generate"
6
+ require_relative "generators/new_project"
7
+ require_relative "generators/talon_code"
8
+
9
+ module DrTalon
10
+ # Entry point for the +talon+ executable.
11
+ class CLI < Thor
12
+ package_name "talon"
13
+
14
+ map %w[-v --version] => :version
15
+
16
+ # Exit with a non-zero status on errors and unknown commands.
17
+ def self.exit_on_failure?
18
+ true
19
+ end
20
+
21
+ desc "version", "Print the talon version"
22
+ def version
23
+ say "talon #{VERSION}"
24
+ end
25
+
26
+ desc "new PATH", "Create a DragonRuby game (or a library with --lib) with a pinned engine"
27
+ long_desc <<~DESC
28
+ Creates PATH with a game in DragonRuby's layout (mygame/, started from the
29
+ engine's own sample game) or, with --lib, a library whose root is the game
30
+ directory. Pins and installs a cached engine (the newest unless --engine),
31
+ fills in game_metadata.txt, adds editor tooling, then runs git init and
32
+ bundle install.
33
+
34
+ You're asked whether to commit the engine unless you pass --commit-engine
35
+ or --no-commit-engine. If not committed, it's gitignored and anyone cloning
36
+ the project restores it with `talon engine use`.
37
+ DESC
38
+ method_options.merge!(Generators::NewProject.class_options)
39
+ def new(path)
40
+ Generators::NewProject.new([path], options, shell: shell).invoke_all
41
+ rescue DrTalon::Error => e
42
+ raise Thor::Error, e.message
43
+ end
44
+
45
+ # `run` is reserved by Thor, so the command method has another name.
46
+ map "run" => :run_game
47
+ stop_on_unknown_option! :run_game
48
+
49
+ desc "run [ARGS...]", "Run the game (extra arguments go to dragonruby, e.g. --record)"
50
+ def run_game(*args)
51
+ exec_in_project { |runner| runner.run_command(args) }
52
+ end
53
+
54
+ desc "test [FILES...]", "Run DragonRuby tests (*_tests.rb in test/ and tests/ by default)"
55
+ long_desc <<~DESC
56
+ Runs each test file with `dragonruby --test` and reports passes and
57
+ failures. DragonRuby always exits 0, so talon reads its output and exits
58
+ non-zero when a test fails, a file doesn't load, a file has no tests, or a
59
+ run times out.
60
+
61
+ FILES may be files or directories inside the game directory. By default
62
+ every *_tests.rb under test/ and tests/ runs. Tests run without a window
63
+ unless you pass --no-headless.
64
+ DESC
65
+ method_option :headless, type: :boolean, default: true, desc: "Run without a window or audio (SDL dummy drivers)"
66
+ method_option :timeout, type: :numeric, default: 120, desc: "Seconds before a test file's run is stopped"
67
+ method_option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Print DragonRuby's full output"
68
+ def test(*paths)
69
+ project = with_errors { Project.locate }
70
+ runner = Runner.new(project)
71
+ files = with_errors { runner.test_files(paths) }
72
+ if files.empty?
73
+ raise Thor::Error, "No #{Runner::TEST_PATTERN} files in #{Runner::TEST_DIRS.join(" or ")} under #{project.game_dir}."
74
+ end
75
+
76
+ reports = files.map do |file|
77
+ report = with_errors { runner.run_test(file, timeout: options[:timeout], headless: options[:headless]) }
78
+ print_test_report(file, report)
79
+ report
80
+ end
81
+
82
+ print_test_summary(reports)
83
+ exit(1) unless reports.all?(&:passed?)
84
+ rescue Interrupt
85
+ say "\nInterrupted.", :yellow
86
+ exit(130)
87
+ end
88
+
89
+ desc "build [ARGS...]", "Package the game for every platform into builds/ (dragonruby-publish --only-package)"
90
+ stop_on_unknown_option! :build
91
+ def build(*args)
92
+ exec_in_project do |runner, project|
93
+ unset = Doctor.unset_metadata(project)
94
+ if unset&.any?
95
+ say "Warning: game_metadata.txt doesn't set #{unset.join(", ")}; dragonruby-publish needs them.", :yellow
96
+ end
97
+ runner.build_command(args)
98
+ end
99
+ end
100
+
101
+ desc "serve [DIR]", "Serve an HTML5 build locally with dragonruby-httpd (default: the newest in builds/)"
102
+ method_option :port, type: :numeric, default: 8080, desc: "Port to listen on"
103
+ def serve(dir = nil)
104
+ exec_in_project do |runner|
105
+ dir ||= runner.html5_build || raise(Error, "No HTML5 build in builds/. Run `talon build` first.")
106
+ say "Serving #{dir} at http://localhost:#{options[:port]}/"
107
+ runner.serve_command(dir, options[:port])
108
+ end
109
+ end
110
+
111
+ desc "clean", "Delete builds/, logs/, tmp/ and errors/ (and their mygame/ counterparts)"
112
+ def clean
113
+ project = with_errors { Project.locate }
114
+ removed = Runner.new(project).clean
115
+ if removed.empty?
116
+ say "Nothing to clean."
117
+ else
118
+ removed.each { |dir| say_status :remove, dir, :red }
119
+ end
120
+ end
121
+
122
+ desc "install", "Install the pinned engine (if missing) and the dependencies in dependencies.rb"
123
+ long_desc <<~DESC
124
+ Run after cloning a project. Installs the engine pinned in .dragonruby-version
125
+ if it isn't installed yet, then vendors every dependency in the game
126
+ directory's dependencies.rb at its locked version. Dependencies without a
127
+ lock yet are resolved and locked.
128
+
129
+ Vendored files edited since talon installed them stop the install unless
130
+ you pass --force.
131
+ DESC
132
+ method_option :force, type: :boolean, default: false, desc: "Replace or delete locally modified vendored files"
133
+ def install
134
+ project = with_errors { Project.locate }
135
+ with_errors do
136
+ engine = install_pinned_engine(project)
137
+ say "Installed engine #{engine.id}" if engine
138
+ end
139
+ run_dependency_install(project)
140
+ end
141
+
142
+ desc "update [NAMES...]", "Re-resolve dependencies (all, or the named ones) and install the new versions"
143
+ method_option :force, type: :boolean, default: false, desc: "Replace locally modified vendored files"
144
+ def update(*names)
145
+ run_dependency_install(with_errors { Project.locate }, update: names.empty? || names)
146
+ end
147
+
148
+ desc "add SOURCE", "Add a dependency to dependencies.rb and install it"
149
+ long_desc <<~DESC
150
+ SOURCE is one of:
151
+
152
+ github:OWNER/REPO a GitHub repository (downloaded as a tarball)
153
+ git:URL any git repository
154
+ url:URL a single file
155
+ path:DIR a local file or directory, copied on every install
156
+
157
+ Examples:
158
+
159
+ talon add github:asimmons91/talon --path lib
160
+ talon add github:someone/tweens --ref v1.2.0 --as tweens
161
+ talon add url:https://example.com/easing.rb
162
+
163
+ The files are vendored into vendor/NAME in the game directory.
164
+ DESC
165
+ method_option :path, type: :string, desc: "File or directory inside the repository to vendor (default: all of it)"
166
+ method_option :ref, type: :string, desc: "Branch, tag or full commit SHA (default: the default branch)"
167
+ method_option :as, type: :string, desc: "Name, and directory under vendor/ (default: from the source)"
168
+ def add(source)
169
+ project = with_errors { Project.locate }
170
+ manifest = with_errors { Deps::Manifest.for(project) }
171
+ original = File.file?(manifest.path) ? File.read(manifest.path) : nil
172
+ kind, value = with_errors { Deps.parse_source(source) }
173
+ line = Deps.declaration(kind, value, path: options[:path], ref: options[:ref], as: options[:as])
174
+
175
+ begin
176
+ with_errors { manifest.add(line) }
177
+ say_status :add, "#{relative_to_root(project, manifest.path)}: #{line}"
178
+ run_dependency_install(project)
179
+ rescue Thor::Error, SystemExit
180
+ original ? File.write(manifest.path, original) : FileUtils.rm_f(manifest.path)
181
+ raise
182
+ end
183
+
184
+ dependency = Deps::Manifest.for(project).dependencies.last
185
+ hint = Deps.require_hint(project, dependency)
186
+ say "\nLoad it from your game with:\n #{hint}" if hint
187
+ end
188
+
189
+ desc "remove NAME", "Remove a dependency from dependencies.rb and delete its vendored files"
190
+ method_option :force, type: :boolean, default: false, desc: "Delete the files even if they were modified"
191
+ def remove(name)
192
+ project = with_errors { Project.locate }
193
+ manifest = with_errors { Deps::Manifest.for(project) }
194
+ dependency = manifest.find(name) || raise(Thor::Error, "No dependency named #{name} in #{Deps::Manifest::FILE}.")
195
+
196
+ with_errors { manifest.remove(dependency) }
197
+ say_status :remove, "#{relative_to_root(project, manifest.path)}: #{dependency.name}", :red
198
+ run_dependency_install(project)
199
+ end
200
+
201
+ desc "outdated", "List dependencies whose branch, tag or URL now points at something newer"
202
+ def outdated
203
+ project = with_errors { Project.locate }
204
+ rows = with_errors { Deps::Installer.new(project).outdated }
205
+
206
+ if rows.empty?
207
+ say "All dependencies are up to date."
208
+ else
209
+ table = rows.map do |row|
210
+ [row[:dependency].name, short(row[:locked]) || "(not locked)", short(row[:latest]), row[:dependency].ref || "default branch"]
211
+ end
212
+ print_table([%w[NAME LOCKED LATEST REF], *table])
213
+ say "\nRun `talon update NAME` to install the latest."
214
+ end
215
+ end
216
+
217
+ desc "doctor", "Check this project's engine, game and metadata"
218
+ def doctor
219
+ project = Project.locate
220
+ store = DrTalon::Engine::Store.new(Paths.new.engines_dir)
221
+ checks = Doctor.new(project, store).checks
222
+
223
+ symbols = {ok: "✓", warning: "!", error: "✗"}
224
+ checks.each { |check| say "#{symbols.fetch(check.status)} #{check.message}" }
225
+
226
+ errors = checks.count { |check| check.status == :error }
227
+ raise Thor::Error, "#{errors} problem#{"s" unless errors == 1} found" if errors.positive?
228
+ rescue DrTalon::Error => e
229
+ raise Thor::Error, e.message
230
+ end
231
+
232
+ desc "engine SUBCOMMAND", "Manage cached DragonRuby engines"
233
+ subcommand "engine", Engine
234
+
235
+ map "g" => :generate
236
+ desc "generate KIND NAME", "Generate Talon framework code: scene, component or prefab (alias: g)"
237
+ subcommand "generate", Generate
238
+
239
+ no_commands do
240
+ def with_errors
241
+ yield
242
+ rescue DrTalon::Error => e
243
+ raise Thor::Error, e.message
244
+ end
245
+
246
+ # Yields the project's runner (and project) for a command line, then
247
+ # replaces this process with it so signals and the exit status are the
248
+ # engine's own.
249
+ def exec_in_project
250
+ project = with_errors { Project.locate }
251
+ argv = with_errors { yield Runner.new(project), project }
252
+ Runner.exec_process(argv, chdir: project.root)
253
+ end
254
+
255
+ def install_pinned_engine(project)
256
+ pin = project.pin or return nil
257
+ installation = DrTalon::Engine::Store.new(Paths.new.engines_dir).find_native(pin)
258
+ materializer = DrTalon::Engine::Materializer.new(project)
259
+ return nil if materializer.manifest&.engine == installation.id
260
+
261
+ materializer.install(installation)
262
+ DrTalon::Engine::GitIgnore.refresh(project.path(".gitignore"), materializer.manifest.files)
263
+ installation
264
+ end
265
+
266
+ def run_dependency_install(project, update: nil)
267
+ manifest = with_errors { Deps::Manifest.for(project) }
268
+ unless manifest.exist?
269
+ say "No #{Deps::Manifest::FILE} in #{relative_to_root(project, project.game_dir)}/; add a dependency with `talon add`."
270
+ return
271
+ end
272
+ if manifest.foodchain_locks
273
+ say "Found Foodchain's ETag locks; talon re-resolves those dependencies and locks them to commits.", :yellow
274
+ end
275
+
276
+ changes = with_errors { Deps::Installer.new(project, force: options[:force]).install(update: update) }
277
+ changes.each { |change| say_change(change) }
278
+ say "No dependencies in #{Deps::Manifest::FILE}." if changes.empty?
279
+ end
280
+
281
+ def say_change(change)
282
+ source = change.dependency && "#{change.dependency.key} @ #{short(change.commit) || "local"}"
283
+ case change.action
284
+ when :installed then say_status :install, "#{change.name} (#{source})"
285
+ when :updated then say_status :update, "#{change.name} #{short(change.previous) || "-"} → #{short(change.commit) || "local"}"
286
+ when :unchanged then say_status :using, "#{change.name} (#{source})", :blue
287
+ when :restored then say_status :restore, "#{change.name} (#{source}); local changes replaced", :yellow
288
+ when :removed then say_status :remove, change.name, :red
289
+ when :kept then say_status :kept, "#{change.name}: no longer declared, but modified locally; delete it yourself", :yellow
290
+ end
291
+ end
292
+
293
+ # 7 characters of a commit, or the first 8 hex digits of a sha256.
294
+ def short(commit)
295
+ return nil unless commit
296
+
297
+ commit.start_with?("sha256:") ? commit[0, 15] : commit[0, 7]
298
+ end
299
+
300
+ def relative_to_root(project, path)
301
+ path.delete_prefix("#{project.root}/")
302
+ end
303
+
304
+ def print_test_report(file, report)
305
+ counts = ["#{report.passed} passed"]
306
+ counts << "#{report.failed} failed" if report.failed.positive?
307
+ counts << "#{report.inconclusive} inconclusive" if report.inconclusive.positive?
308
+
309
+ case report.status
310
+ when :passed then say "✓ #{file} (#{counts.join(", ")})", :green
311
+ when :failed then say "✗ #{file} (#{counts.join(", ")})", :red
312
+ when :error then say "✗ #{file} didn't load or raised outside a test", :red
313
+ when :no_tests then say "✗ #{file} has no tests (methods named test_*)", :red
314
+ when :timeout then say "✗ #{file} timed out after #{options[:timeout]}s", :red
315
+ end
316
+
317
+ if options[:verbose]
318
+ say report.output
319
+ elsif !report.details.empty?
320
+ say report.details.gsub(/^/, " ")
321
+ end
322
+ end
323
+
324
+ def print_test_summary(reports)
325
+ passed = reports.sum(&:passed)
326
+ failed = reports.sum(&:failed)
327
+ bad_files = reports.count { |report| !report.passed? }
328
+ summary = "#{plural(reports.size, "file")}, #{plural(passed, "test")} passed"
329
+
330
+ say ""
331
+ if bad_files.zero?
332
+ say summary, :green
333
+ else
334
+ say "#{summary}, #{failed} failed, #{plural(bad_files, "file")} with problems", :red
335
+ end
336
+ end
337
+
338
+ def plural(count, word)
339
+ "#{count} #{word}#{"s" unless count == 1}"
340
+ end
341
+ end
342
+ end
343
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module DrTalon
6
+ Config = Data.define(:default_template, :dev_id, :dev_title)
7
+
8
+ # User settings from +config.yml+ (see {Paths#config_file}):
9
+ #
10
+ # default_template: bare # template for `talon new`
11
+ # dev_id: my_itchio_username # devid in game_metadata.txt
12
+ # dev_title: My Name # devtitle in game_metadata.txt
13
+ class Config
14
+ DEFAULTS = {default_template: "bare", dev_id: nil, dev_title: nil}.freeze
15
+
16
+ # @param path [String]
17
+ # @return [Config] defaults for anything the file doesn't set
18
+ # @raise [Error] if the file isn't a YAML mapping
19
+ def self.load(path = Paths.new.config_file)
20
+ data = File.file?(path) ? YAML.safe_load_file(path) : {}
21
+ data ||= {}
22
+ raise Error, "#{path} should contain a YAML mapping." unless data.is_a?(Hash)
23
+
24
+ new(**DEFAULTS.to_h { |key, default| [key, data.fetch(key.to_s, default)&.to_s] })
25
+ rescue Psych::SyntaxError => e
26
+ raise Error, "Can't read #{path}: #{e.message}"
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module DrTalon
7
+ Credentials = Data.define(:username, :password, :source)
8
+
9
+ # A dragonruby.org login for downloading Pro/Indie engines. Looked up in
10
+ # +DRAGONRUBY_USERNAME+/+DRAGONRUBY_PASSWORD+, then {Paths#credentials_file}
11
+ # (which must be readable only by its owner).
12
+ #
13
+ # @!attribute [r] source
14
+ # @return [Symbol] +:env+, +:file+ or +:prompt+
15
+ class Credentials
16
+ ENV_USERNAME = "DRAGONRUBY_USERNAME"
17
+ ENV_PASSWORD = "DRAGONRUBY_PASSWORD"
18
+
19
+ # The credentials file can be read by other users.
20
+ class InsecureFile < Error; end
21
+
22
+ # @param env [Hash]
23
+ # @param path [String]
24
+ # @return [Credentials, nil]
25
+ # @raise [InsecureFile, Error]
26
+ def self.lookup(env: ENV, path: Paths.new.credentials_file)
27
+ from_env(env) || load(path)
28
+ end
29
+
30
+ # @return [Credentials, nil] only when both variables are set
31
+ def self.from_env(env = ENV)
32
+ username, password = env.values_at(ENV_USERNAME, ENV_PASSWORD)
33
+ return nil if username.to_s.empty? || password.to_s.empty?
34
+
35
+ new(username: username, password: password, source: :env)
36
+ end
37
+
38
+ # @param path [String]
39
+ # @return [Credentials, nil]
40
+ # @raise [InsecureFile] if group or others can read the file
41
+ # @raise [Error] if it isn't a mapping with username and password
42
+ def self.load(path)
43
+ return nil unless File.file?(path)
44
+
45
+ mode = File.stat(path).mode & 0o777
46
+ if !Gem.win_platform? && mode.anybits?(0o077)
47
+ raise InsecureFile, "#{path} is readable by other users (mode #{format("%o", mode)}). " \
48
+ "Run `chmod 600 #{path}` or `talon engine login` again."
49
+ end
50
+
51
+ data = YAML.safe_load_file(path)
52
+ unless data.is_a?(Hash) && !data["username"].to_s.empty? && !data["password"].to_s.empty?
53
+ raise Error, "#{path} should contain username: and password:."
54
+ end
55
+
56
+ new(username: data["username"].to_s, password: data["password"].to_s, source: :file)
57
+ rescue Psych::SyntaxError => e
58
+ raise Error, "Can't read #{path}: #{e.message}"
59
+ end
60
+
61
+ # @param path [String]
62
+ # @return [Boolean] whether a file was deleted
63
+ def self.delete(path)
64
+ return false unless File.exist?(path)
65
+
66
+ File.unlink(path)
67
+ true
68
+ end
69
+
70
+ # Writes the file readable only by its owner (mode 0600).
71
+ #
72
+ # @param path [String]
73
+ # @return [void]
74
+ def save(path)
75
+ FileUtils.mkdir_p(File.dirname(path))
76
+ File.unlink(path) if File.exist?(path)
77
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
78
+ file.write(YAML.dump({"username" => username, "password" => password}))
79
+ end
80
+ File.chmod(0o600, path)
81
+ end
82
+
83
+ # @return [HTTP::Auth]
84
+ def to_auth
85
+ HTTP::Auth.new(username: username, password: password)
86
+ end
87
+
88
+ # Never print the password.
89
+ def inspect
90
+ "#<#{self.class.name} username=#{username.inspect} source=#{source.inspect}>"
91
+ end
92
+ alias_method :to_s, :inspect
93
+ end
94
+ end