depot-linux 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 (44) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +10 -0
  3. data/README.md +56 -0
  4. data/Rakefile +10 -0
  5. data/bin/depot +8 -0
  6. data/bin/depot-gui +14 -0
  7. data/bin/setup-rubyqt6 +72 -0
  8. data/fixtures/assets/download.png +0 -0
  9. data/fixtures/flatpakrefs/org.qbittorrent.qBittorrent.flatpakref +10 -0
  10. data/fixtures/rpms/Modrinth App-0.13.14-1.x86_64.rpm +0 -0
  11. data/lib/depot/app_customizer.rb +152 -0
  12. data/lib/depot/assets.rb +11 -0
  13. data/lib/depot/backends/app_image.rb +210 -0
  14. data/lib/depot/backends/archive.rb +263 -0
  15. data/lib/depot/backends/deb.rb +265 -0
  16. data/lib/depot/backends/flatpak_ref.rb +123 -0
  17. data/lib/depot/backends/rpm.rb +280 -0
  18. data/lib/depot/backends/support.rb +39 -0
  19. data/lib/depot/cli.rb +344 -0
  20. data/lib/depot/desktop_entry.rb +37 -0
  21. data/lib/depot/doctor.rb +59 -0
  22. data/lib/depot/gui/app.rb +23 -0
  23. data/lib/depot/gui/drop_panel.rb +80 -0
  24. data/lib/depot/gui/main_window.rb +1196 -0
  25. data/lib/depot/inspection.rb +52 -0
  26. data/lib/depot/inspector.rb +387 -0
  27. data/lib/depot/installer.rb +54 -0
  28. data/lib/depot/manifest_store.rb +53 -0
  29. data/lib/depot/packages/archive.rb +188 -0
  30. data/lib/depot/packages/deb.rb +262 -0
  31. data/lib/depot/packages/flatpak_ref.rb +90 -0
  32. data/lib/depot/packages/rpm.rb +301 -0
  33. data/lib/depot/paths.rb +57 -0
  34. data/lib/depot/result.rb +13 -0
  35. data/lib/depot/sandbox.rb +285 -0
  36. data/lib/depot/settings.rb +43 -0
  37. data/lib/depot/source_resolver.rb +36 -0
  38. data/lib/depot/uninstaller.rb +90 -0
  39. data/lib/depot/update_downloader.rb +136 -0
  40. data/lib/depot/updater.rb +230 -0
  41. data/lib/depot/util.rb +33 -0
  42. data/lib/depot/version.rb +5 -0
  43. data/lib/depot.rb +21 -0
  44. metadata +139 -0
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+ require_relative "../desktop_entry"
6
+ require_relative "../paths"
7
+ require_relative "../result"
8
+ require_relative "../packages/rpm"
9
+ require_relative "../util"
10
+ require_relative "support"
11
+
12
+ module Depot
13
+ module Backends
14
+ class Rpm
15
+ include Support
16
+
17
+ def initialize(store:)
18
+ @store = store
19
+ end
20
+
21
+ def install(inspection, settings: {})
22
+ return Result.err("RPM backend cannot install #{inspection.format}") unless inspection.rpm?
23
+
24
+ Paths.ensure_base_dirs
25
+ package = RpmPackage.new(inspection.input)
26
+ return Result.err("Invalid RPM package.") unless package.valid?
27
+
28
+ app_id = Util.unique_id(Util.slug("#{package.display_name}-#{package.version_label}"), @store.ids)
29
+ app_dir = File.join(Paths.apps_dir, app_id)
30
+ root_dir = File.join(app_dir, "root")
31
+ FileUtils.mkdir_p(app_dir)
32
+
33
+ package.extract_to(root_dir)
34
+
35
+ desktop_source = package.primary_desktop_entry
36
+ desktop_contents = desktop_source && read_extracted_entry(root_dir, desktop_source)
37
+ display_name = desktop_name(desktop_contents) || package.display_name
38
+ preferred_icon = desktop_contents&.[]( /^Icon=(.+)$/, 1)&.strip
39
+ icon_name = install_icons(package, root_dir, app_id, preferred_icon)
40
+ executable = executable_for(package, root_dir, desktop_contents)
41
+
42
+ desktop_path = nil
43
+ if settings.fetch("desktop_integration", true) && executable
44
+ desktop_path = File.join(Paths.desktop_entries_dir, "depot-#{app_id}.desktop")
45
+ contents = desktop_contents ? rewrite_desktop(desktop_contents, root_dir, app_id, display_name, icon_name) : generated_desktop(app_id, display_name, executable, icon_name)
46
+ File.write(desktop_path, contents)
47
+ end
48
+
49
+ warnings = inspection.warnings + portable_warnings(package, executable, desktop_source)
50
+ manifest = {
51
+ "schema_version" => 1,
52
+ "app_id" => app_id,
53
+ "display_name" => display_name,
54
+ "default_display_name" => display_name,
55
+ "backend" => "rpm-portable",
56
+ "install_source" => File.expand_path(inspection.input),
57
+ "source_sha256" => inspection.sha256,
58
+ "source_size" => inspection.size,
59
+ "installed_executable" => executable,
60
+ "desktop_entry" => desktop_path,
61
+ "icons" => installed_icon_paths(app_id, preferred_icon),
62
+ "default_icon_name" => icon_name,
63
+ "created_files" => [desktop_path].compact + installed_icon_paths(app_id, preferred_icon),
64
+ "created_dirs" => [app_dir],
65
+ "installed_at" => Time.now.utc.iso8601,
66
+ "package" => package.package_fields.merge("Requires" => package.requires),
67
+ "desktop_source" => desktop_source,
68
+ "portable_root" => root_dir,
69
+ "permissions" => {
70
+ "executable" => executable ? File.executable?(executable) : false,
71
+ "requires_sudo" => false,
72
+ "writes_outside_depot" => false,
73
+ "notes" => ["RPM payload extracted user-locally. RPM scriptlets were not executed."]
74
+ },
75
+ "sandbox" => {
76
+ "enabled" => false,
77
+ "preference" => settings.fetch("sandbox_preference", "ask")
78
+ },
79
+ "update" => {
80
+ "mechanism" => "manual",
81
+ "source" => File.expand_path(inspection.input)
82
+ },
83
+ "warnings" => warnings
84
+ }
85
+
86
+ manifest_path = @store.write(manifest)
87
+ refresh_desktop_caches if settings.fetch("desktop_integration", true)
88
+ Result.ok(manifest.merge("manifest_path" => manifest_path), warnings:)
89
+ rescue RpmPackage::FormatError, SystemCallError => e
90
+ Result.err("RPM install failed: #{e.message}")
91
+ end
92
+
93
+ private
94
+
95
+ def read_extracted_entry(root_dir, entry)
96
+ path = File.join(root_dir, entry)
97
+ return nil unless File.file?(path)
98
+
99
+ File.read(path)
100
+ end
101
+
102
+ def desktop_name(contents)
103
+ return nil unless contents
104
+
105
+ line = contents.lines.find { |candidate| candidate.start_with?("Name=") }
106
+ line&.split("=", 2)&.last&.strip
107
+ rescue SystemCallError
108
+ nil
109
+ end
110
+
111
+ def executable_for(package, root_dir, desktop_contents)
112
+ exec_line = desktop_contents&.lines&.find { |line| line.start_with?("Exec=") }
113
+ command = exec_line&.split("=", 2)&.last.to_s.strip
114
+ rewritten = rewrite_exec_value(command, root_dir)
115
+ first = first_exec_token(rewritten)
116
+ return first if first && File.exist?(first)
117
+
118
+ package.executable_candidates.map { |entry| File.join(root_dir, entry) }.find { |path| File.file?(path) && File.executable?(path) }
119
+ rescue SystemCallError
120
+ nil
121
+ end
122
+
123
+ def generated_desktop(app_id, display_name, executable, icon_name)
124
+ DesktopEntry.new(app_id:, name: display_name, exec_path: executable, icon_name:).contents
125
+ end
126
+
127
+ def rewrite_desktop(contents, root_dir, app_id, display_name, icon_name)
128
+ seen_depot_id = false
129
+ current_group = nil
130
+ lines = []
131
+ contents.lines.each do |line|
132
+ if line.start_with?("[")
133
+ lines << "X-Depot-AppID=#{app_id}\n" if current_group == "[Desktop Entry]" && !seen_depot_id
134
+ current_group = line.strip
135
+ end
136
+
137
+ lines << if line.start_with?("Name=") && current_group == "[Desktop Entry]"
138
+ "Name=#{display_name}\n"
139
+ elsif line.match?(/\AExec=/)
140
+ "Exec=#{rewrite_exec_value(line.split("=", 2).last.strip, root_dir)}\n"
141
+ elsif line.match?(/\ATryExec=/)
142
+ "TryExec=#{rewrite_exec_value(line.split("=", 2).last.strip, root_dir)}\n"
143
+ elsif line.start_with?("Icon=")
144
+ icon_name ? "Icon=#{icon_name}\n" : line
145
+ elsif line.start_with?("X-Depot-AppID=")
146
+ seen_depot_id = true
147
+ "X-Depot-AppID=#{app_id}\n"
148
+ else
149
+ line
150
+ end
151
+ end
152
+ lines << "X-Depot-AppID=#{app_id}\n" if current_group == "[Desktop Entry]" && !seen_depot_id
153
+ lines.join
154
+ end
155
+
156
+ def rewrite_exec_value(value, root_dir)
157
+ token, rest = split_exec(value)
158
+ return value if token.to_s.empty?
159
+
160
+ mapped = if token.start_with?("/")
161
+ File.join(root_dir, token.delete_prefix("/"))
162
+ elsif token.include?("/")
163
+ File.join(root_dir, token)
164
+ else
165
+ candidate = find_executable(root_dir, token)
166
+ candidate || token
167
+ end
168
+ "#{Util.desktop_exec_quote(mapped)}#{rest}"
169
+ end
170
+
171
+ def split_exec(value)
172
+ if value.start_with?('"')
173
+ closing = value.index('"', 1)
174
+ return [value[1...closing], value[(closing + 1)..].to_s] if closing
175
+ end
176
+ token, rest = value.split(/\s+/, 2)
177
+ [token, rest ? " #{rest}" : ""]
178
+ end
179
+
180
+ def first_exec_token(value)
181
+ token, = split_exec(value)
182
+ token
183
+ end
184
+
185
+ def find_executable(root_dir, token)
186
+ Dir.glob(File.join(root_dir, "**", token)).find { |path| File.file?(path) && File.executable?(path) }
187
+ end
188
+
189
+ def install_icons(package, root_dir, app_id, preferred)
190
+ icons = package.icon_entries
191
+ icons = fallback_icon_entries(package, preferred) if icons.empty?
192
+ icons.sort_by { |entry| icon_score(entry, preferred) }.first(8).each do |entry|
193
+ ext = File.extname(entry).downcase
194
+ next unless [".png", ".svg", ".xpm"].include?(ext)
195
+
196
+ source = File.join(root_dir, entry)
197
+ next unless File.file?(source)
198
+
199
+ target_dir = File.join(Paths.icon_root, icon_theme_size(entry, source), "apps")
200
+ FileUtils.mkdir_p(target_dir)
201
+ icon_install_names(app_id, preferred).each do |name|
202
+ FileUtils.cp(source, File.join(target_dir, "#{name}#{ext}"))
203
+ end
204
+ end
205
+ icon_paths(app_id).any? ? app_id : nil
206
+ end
207
+
208
+ def fallback_icon_entries(package, preferred)
209
+ preferred_base = File.basename(preferred.to_s, ".*")
210
+ candidates = package.image_entries
211
+ scored = candidates.select do |entry|
212
+ base = File.basename(entry, ".*")
213
+ entry.match?(/(^|\/)(icon|logo|app|code)[^\/]*\.(png|svg|xpm)\z/i) ||
214
+ (!preferred_base.empty? && (base == preferred_base || entry.downcase.include?(preferred_base.downcase)))
215
+ end
216
+ scored.empty? ? candidates : scored
217
+ end
218
+
219
+ def icon_score(entry, preferred)
220
+ base = File.basename(entry, ".*")
221
+ [
222
+ preferred.to_s.empty? || base != preferred ? 1 : 0,
223
+ entry.include?("/256x256/") ? 0 : 1,
224
+ entry.include?("/512x512/") ? 0 : 1,
225
+ entry.match?(/(^|\/)(icon|logo|app|code)[^\/]*\.(png|svg|xpm)\z/i) ? 0 : 1,
226
+ -entry.length
227
+ ]
228
+ end
229
+
230
+ def icon_theme_size(entry, source)
231
+ match = entry.match(RpmPackage::HICOLOR_ICON_PATH)
232
+ return match[1] if match
233
+ return "scalable" if File.extname(entry).downcase == ".svg"
234
+ return png_size(source) if File.extname(entry).downcase == ".png"
235
+
236
+ "256x256"
237
+ end
238
+
239
+ def png_size(path)
240
+ File.open(path, "rb") do |file|
241
+ header = file.read(24)
242
+ return "256x256" unless header&.start_with?("\x89PNG\r\n\x1A\n".b)
243
+
244
+ width, height = header.byteslice(16, 8).unpack("NN")
245
+ return "256x256" if width.to_i <= 0 || height.to_i <= 0
246
+
247
+ "#{width}x#{height}"
248
+ end
249
+ rescue SystemCallError
250
+ "256x256"
251
+ end
252
+
253
+ def icon_paths(app_id)
254
+ Dir.glob(File.join(Paths.icon_root, "*", "apps", "#{app_id}.{png,svg,xpm}"), File::FNM_EXTGLOB)
255
+ end
256
+
257
+ def installed_icon_paths(app_id, preferred)
258
+ icon_install_names(app_id, preferred).flat_map do |name|
259
+ Dir.glob(File.join(Paths.icon_root, "*", "apps", "#{name}.{png,svg,xpm}"), File::FNM_EXTGLOB)
260
+ end.uniq
261
+ end
262
+
263
+ def icon_install_names(app_id, preferred)
264
+ names = [app_id]
265
+ preferred = preferred.to_s
266
+ names << preferred if preferred.match?(/\A[a-zA-Z0-9_.-]+\z/)
267
+ names.uniq
268
+ end
269
+
270
+ def portable_warnings(package, executable, desktop_source)
271
+ warnings = ["Depot installed this RPM in portable extraction mode and did not use rpm, dnf, zypper, sudo, or root scriptlets."]
272
+ warnings << "RPM scriptlets were found and were not executed: #{package.scriptlets.join(", ")}." unless package.scriptlets.empty?
273
+ warnings << "No desktop launcher was found; Depot generated one." unless desktop_source
274
+ warnings << "No executable could be confidently selected." unless executable
275
+ warnings
276
+ end
277
+
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "shellwords"
5
+ require_relative "../paths"
6
+
7
+ module Depot
8
+ module Backends
9
+ module Support
10
+ private
11
+
12
+ def command_available?(command)
13
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? { |dir| File.executable?(File.join(dir, command)) }
14
+ end
15
+
16
+ def refresh_desktop_caches
17
+ if command_available?("gtk-update-icon-cache") && Dir.exist?(Paths.icon_root)
18
+ system("gtk-update-icon-cache", "-f", "-t", Paths.icon_root, out: File::NULL, err: File::NULL)
19
+ end
20
+ if command_available?("update-desktop-database") && Dir.exist?(Paths.desktop_entries_dir)
21
+ system("update-desktop-database", Paths.desktop_entries_dir, out: File::NULL, err: File::NULL)
22
+ end
23
+ end
24
+
25
+ def write_shell_launcher(path, command, *args)
26
+ FileUtils.mkdir_p(File.dirname(path))
27
+ escaped = ([command] + args).map { |part| Shellwords.escape(part.to_s) }.join(" ")
28
+ File.write(path, "#!/bin/sh\nexec #{escaped} \"$@\"\n")
29
+ File.chmod(0o755, path)
30
+ path
31
+ end
32
+
33
+ def desktop_name_from(contents)
34
+ line = contents.to_s.lines.find { |candidate| candidate.start_with?("Name=") }
35
+ line&.split("=", 2)&.last&.strip
36
+ end
37
+ end
38
+ end
39
+ end
data/lib/depot/cli.rb ADDED
@@ -0,0 +1,344 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../depot"
6
+
7
+ module Depot
8
+ class CLI
9
+ def initialize(stdout: $stdout, stderr: $stderr)
10
+ @stdout = stdout
11
+ @stderr = stderr
12
+ end
13
+
14
+ def run(argv)
15
+ command = argv.shift
16
+ case command
17
+ when "inspect"
18
+ inspect_command(argv)
19
+ when "install"
20
+ install_command(argv)
21
+ when "list"
22
+ list_command(argv)
23
+ when "info"
24
+ info_command(argv)
25
+ when "uninstall", "remove"
26
+ uninstall_command(argv)
27
+ when "update"
28
+ update_command(argv)
29
+ when "update-source"
30
+ update_source_command(argv)
31
+ when "sandbox"
32
+ sandbox_command(argv)
33
+ when "doctor"
34
+ doctor_command(argv)
35
+ when "settings"
36
+ settings_command(argv)
37
+ when "-h", "--help", nil
38
+ help
39
+ 0
40
+ when "-v", "--version"
41
+ @stdout.puts "Depot #{Depot::VERSION}"
42
+ 0
43
+ else
44
+ @stderr.puts "Unknown command: #{command}"
45
+ help(@stderr)
46
+ 1
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def inspect_command(argv)
53
+ json = argv.delete("--json")
54
+ input = argv.shift
55
+ return usage_error("Usage: depot inspect [--json] PATH_OR_URL") unless input
56
+
57
+ result = Inspector.inspect(input)
58
+ return print_error(result) unless result.ok?
59
+
60
+ if json
61
+ @stdout.puts JSON.pretty_generate(result.value.to_h)
62
+ else
63
+ inspection = result.value
64
+ @stdout.puts "Input: #{inspection.input}"
65
+ @stdout.puts "Format: #{inspection.format} (#{inspection.confidence})"
66
+ @stdout.puts "Name: #{inspection.display_name}"
67
+ @stdout.puts "Size: #{inspection.size || "unknown"}"
68
+ @stdout.puts "SHA-256: #{inspection.sha256 || "unknown"}"
69
+ @stdout.puts "Executable: #{inspection.executable ? "yes" : "no"}"
70
+ print_deb_metadata(inspection) if inspection.deb?
71
+ print_archive_metadata(inspection) if inspection.archive?
72
+ print_rpm_metadata(inspection) if inspection.rpm?
73
+ print_flatpakref_metadata(inspection) if inspection.flatpakref?
74
+ print_list("Warnings", inspection.warnings)
75
+ print_list("Risks", inspection.risks)
76
+ end
77
+ 0
78
+ end
79
+
80
+ def install_command(argv)
81
+ json = argv.delete("--json")
82
+ no_desktop = argv.delete("--no-desktop")
83
+ input = argv.shift
84
+ return usage_error("Usage: depot install [--json] [--no-desktop] PATH") unless input
85
+
86
+ settings = {}
87
+ settings["desktop_integration"] = false if no_desktop
88
+ result = Installer.install(input, settings:)
89
+ return print_error(result) unless result.ok?
90
+
91
+ manifest = result.value
92
+ if json
93
+ @stdout.puts JSON.pretty_generate(manifest)
94
+ else
95
+ @stdout.puts "Installed #{manifest.fetch("display_name")} as #{manifest.fetch("app_id")}"
96
+ @stdout.puts "Executable: #{manifest.fetch("installed_executable")}"
97
+ @stdout.puts "Desktop entry: #{manifest["desktop_entry"] || "disabled"}"
98
+ @stdout.puts "Manifest: #{manifest.fetch("manifest_path")}"
99
+ print_list("Warnings", result.warnings)
100
+ end
101
+ 0
102
+ end
103
+
104
+ def list_command(_argv)
105
+ apps = ManifestStore.new.all
106
+ if apps.empty?
107
+ @stdout.puts "No Depot apps installed."
108
+ else
109
+ apps.each do |manifest|
110
+ @stdout.puts "#{manifest.fetch("app_id")}\t#{manifest.fetch("display_name")}\t#{manifest.fetch("backend")}"
111
+ end
112
+ end
113
+ 0
114
+ end
115
+
116
+ def info_command(argv)
117
+ json = argv.delete("--json")
118
+ app_id = argv.shift
119
+ return usage_error("Usage: depot info [--json] APP_ID") unless app_id
120
+
121
+ manifest = ManifestStore.new.find(app_id)
122
+ return usage_error("No installed app found for #{app_id}") unless manifest
123
+
124
+ if json
125
+ @stdout.puts JSON.pretty_generate(manifest)
126
+ else
127
+ @stdout.puts "App: #{manifest.fetch("display_name")} (#{manifest.fetch("app_id")})"
128
+ @stdout.puts "Backend: #{manifest.fetch("backend")}"
129
+ @stdout.puts "Source: #{manifest.fetch("install_source")}"
130
+ @stdout.puts "Update source: #{manifest.dig("update", "source") || "none"}"
131
+ @stdout.puts "Sandbox: #{Sandbox.summary(manifest)}"
132
+ @stdout.puts "Executable: #{manifest.fetch("installed_executable")}"
133
+ @stdout.puts "Desktop entry: #{manifest["desktop_entry"] || "none"}"
134
+ @stdout.puts "Installed: #{manifest.fetch("installed_at")}"
135
+ print_list("Created files", manifest.fetch("created_files", []))
136
+ print_list("Warnings", manifest.fetch("warnings", []))
137
+ end
138
+ 0
139
+ end
140
+
141
+ def uninstall_command(argv)
142
+ app_id = argv.shift
143
+ return usage_error("Usage: depot uninstall APP_ID") unless app_id
144
+
145
+ result = Uninstaller.uninstall(app_id)
146
+ return print_error(result) unless result.ok?
147
+
148
+ @stdout.puts "Uninstalled #{app_id}"
149
+ print_list("Deleted files", result.value.fetch("deleted_files", []))
150
+ 0
151
+ end
152
+
153
+ def update_command(argv)
154
+ json = argv.delete("--json")
155
+ all = argv.delete("--all")
156
+ app_id = argv.shift
157
+ return usage_error("Usage: depot update [--json] APP_ID | --all") unless all || app_id
158
+
159
+ result = all ? Updater.update_all : Updater.update(app_id)
160
+ return print_error(result) unless result.ok?
161
+
162
+ if json
163
+ @stdout.puts JSON.pretty_generate(result.value)
164
+ elsif all
165
+ @stdout.puts "Updated all available apps."
166
+ else
167
+ manifest = result.value
168
+ @stdout.puts "Updated #{manifest.fetch("display_name")} (#{manifest.fetch("app_id")})"
169
+ end
170
+ 0
171
+ end
172
+
173
+ def update_source_command(argv)
174
+ app_id = argv.shift
175
+ url = argv.shift
176
+ return usage_error("Usage: depot update-source APP_ID HTTPS_URL") unless app_id && url
177
+
178
+ result = Updater.set_source(app_id, url)
179
+ return print_error(result) unless result.ok?
180
+
181
+ @stdout.puts "Set update source for #{app_id}."
182
+ 0
183
+ end
184
+
185
+ def sandbox_command(argv)
186
+ app_id = argv.shift
187
+ mode = argv.shift
188
+ return usage_error("Usage: depot sandbox APP_ID [inherit|enabled|disabled]") unless app_id
189
+
190
+ manifest = ManifestStore.new.find(app_id)
191
+ return usage_error("No installed app found for #{app_id}") unless manifest
192
+
193
+ if mode.nil?
194
+ @stdout.puts Sandbox.summary(manifest)
195
+ return 0
196
+ end
197
+
198
+ result = Sandbox.set(app_id, { "mode" => mode })
199
+ return print_error(result) unless result.ok?
200
+
201
+ @stdout.puts "Set sandbox for #{app_id} to #{result.value.fetch("sandbox").fetch("mode")}."
202
+ 0
203
+ end
204
+
205
+ def settings_command(argv)
206
+ settings = Settings.new
207
+ values = settings.load
208
+ if argv.empty?
209
+ @stdout.puts JSON.pretty_generate(values)
210
+ return 0
211
+ end
212
+
213
+ if argv.first == "set"
214
+ key = argv[1]
215
+ value = argv[2]
216
+ return usage_error("Usage: depot settings set KEY VALUE") unless key && value
217
+
218
+ parsed = case value
219
+ when "true" then true
220
+ when "false" then false
221
+ else value
222
+ end
223
+ saved = settings.save(values.merge(key => parsed))
224
+ @stdout.puts JSON.pretty_generate(saved)
225
+ return 0
226
+ end
227
+
228
+ usage_error("Usage: depot settings [set KEY VALUE]")
229
+ end
230
+
231
+ def doctor_command(argv)
232
+ json = argv.delete("--json")
233
+ report = Doctor.new.report
234
+ if json
235
+ @stdout.puts JSON.pretty_generate(report)
236
+ return 0
237
+ end
238
+
239
+ @stdout.puts "Depot doctor"
240
+ @stdout.puts "Tools:"
241
+ report.fetch("tools").each do |tool, present|
242
+ @stdout.puts " - #{tool}: #{present ? "found" : "missing"}"
243
+ end
244
+ @stdout.puts "Paths:"
245
+ report.fetch("paths").each do |path, present|
246
+ @stdout.puts " - #{path}: #{present ? "ok" : "missing"}"
247
+ end
248
+ manifests = report.fetch("manifests")
249
+ if manifests.empty?
250
+ @stdout.puts "Manifests: none installed"
251
+ else
252
+ @stdout.puts "Manifests:"
253
+ manifests.each do |manifest|
254
+ status = manifest.fetch("ok") ? "ok" : manifest.fetch("issues").join(", ")
255
+ @stdout.puts " - #{manifest.fetch("app_id")}: #{status}"
256
+ end
257
+ end
258
+ 0
259
+ end
260
+
261
+ def print_error(result)
262
+ @stderr.puts result.error
263
+ print_list("Warnings", result.warnings, io: @stderr)
264
+ 1
265
+ end
266
+
267
+ def usage_error(message)
268
+ @stderr.puts message
269
+ 1
270
+ end
271
+
272
+ def print_list(title, items, io: @stdout)
273
+ return if items.nil? || items.empty?
274
+
275
+ io.puts "#{title}:"
276
+ items.each { |item| io.puts " - #{item}" }
277
+ end
278
+
279
+ def print_deb_metadata(inspection)
280
+ metadata = inspection.metadata
281
+ @stdout.puts "Package: #{metadata["package"] || "unknown"}"
282
+ @stdout.puts "Version: #{metadata["version"] || "unknown"}"
283
+ @stdout.puts "Architecture: #{metadata["architecture"] || "unknown"}"
284
+ @stdout.puts "Debian control archive: #{metadata["control_archive"] || "unknown"}"
285
+ @stdout.puts "Debian data archive: #{metadata["data_archive"] || "unknown"}"
286
+ print_list("Maintainer scripts", metadata.fetch("maintainer_scripts", []))
287
+ print_list("Desktop entries", metadata.fetch("desktop_entries", []))
288
+ print_list("Executable candidates", metadata.fetch("executable_candidates", []))
289
+ end
290
+
291
+ def print_archive_metadata(inspection)
292
+ metadata = inspection.metadata
293
+ @stdout.puts "Archive format: #{metadata["archive_format"] || inspection.format}"
294
+ @stdout.puts "Archive root: #{metadata["archive_root"] || "mixed"}"
295
+ print_list("Desktop entries", metadata.fetch("desktop_entries", []))
296
+ print_list("Executable candidates", metadata.fetch("executable_candidates", []))
297
+ print_list("Installer-like scripts", metadata.fetch("script_entries", []))
298
+ print_list("Source/build markers", metadata.fetch("source_markers", []))
299
+ end
300
+
301
+ def print_rpm_metadata(inspection)
302
+ metadata = inspection.metadata
303
+ version = [metadata["version"], metadata["release"]].compact.join("-")
304
+ @stdout.puts "Package: #{metadata["package"] || "unknown"}"
305
+ @stdout.puts "Version: #{version.empty? ? "unknown" : version}"
306
+ @stdout.puts "Architecture: #{metadata["architecture"] || "unknown"}"
307
+ @stdout.puts "RPM payload: #{metadata["payload_format"] || "unknown"} / #{metadata["payload_compressor"] || "unknown"}"
308
+ print_list("RPM requirements", metadata.fetch("requires", []))
309
+ print_list("RPM scriptlets", metadata.fetch("scriptlets", []))
310
+ print_list("Desktop entries", metadata.fetch("desktop_entries", []))
311
+ print_list("Executable candidates", metadata.fetch("executable_candidates", []))
312
+ end
313
+
314
+ def print_flatpakref_metadata(inspection)
315
+ metadata = inspection.metadata
316
+ @stdout.puts "Flatpak ID: #{metadata["name"] || "unknown"}"
317
+ @stdout.puts "Title: #{metadata["title"] || inspection.display_name}"
318
+ @stdout.puts "Branch: #{metadata["branch"] || "master"}"
319
+ @stdout.puts "Remote URL: #{metadata["url"] || "unknown"}"
320
+ @stdout.puts "Suggested remote: #{metadata["suggest_remote_name"] || "none"}"
321
+ @stdout.puts "Runtime ref: #{metadata["is_runtime"] ? "yes" : "no"}"
322
+ @stdout.puts "Embedded GPG key: #{metadata["gpg_key_present"] ? "yes" : "no"}"
323
+ end
324
+
325
+ def help(io = @stdout)
326
+ io.puts <<~HELP
327
+ Depot #{Depot::VERSION}
328
+
329
+ Usage:
330
+ depot inspect [--json] PATH_OR_URL
331
+ depot install [--json] [--no-desktop] PATH
332
+ depot list
333
+ depot info [--json] APP_ID
334
+ depot uninstall APP_ID
335
+ depot update [--json] APP_ID
336
+ depot update [--json] --all
337
+ depot update-source APP_ID HTTPS_URL
338
+ depot sandbox APP_ID [inherit|enabled|disabled]
339
+ depot doctor [--json]
340
+ depot settings [set KEY VALUE]
341
+ HELP
342
+ end
343
+ end
344
+ end