rpremote 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.
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Rpremote
6
+ class FlashCommand
7
+ def self.run(args, defaults:, output: $stdout, flasher: Flasher)
8
+ options = parse_options(args, defaults)
9
+ raise ArgumentError, "flash does not accept arguments; use --firmware FILE" unless args.empty?
10
+
11
+ target = Target.new(**options.slice(:language, :language_version, :board, :cache_dir, :firmware))
12
+ result = flasher.new(timeout: options[:timeout]).flash(
13
+ target.firmware_path(root: Dir.pwd),
14
+ mount: options[:mount],
15
+ port: options[:port]
16
+ )
17
+ output.puts("flashed firmware #{File.basename(target.firmware_path)}: #{result.port}")
18
+ end
19
+
20
+ def self.parse_options(args, defaults)
21
+ options = {
22
+ timeout: defaults.fetch(:timeout, Flasher::DEFAULT_TIMEOUT),
23
+ mount: defaults[:mount],
24
+ port: defaults[:port],
25
+ cache_dir: defaults.fetch(:cache, Target::DEFAULT_CACHE_DIR),
26
+ firmware: defaults[:firmware],
27
+ language: defaults.fetch(:language, Target::DEFAULT_LANGUAGE),
28
+ language_version: defaults.fetch(:language_version, Target::DEFAULT_LANGUAGE_VERSION),
29
+ board: defaults.fetch(:board, Target::DEFAULT_BOARD)
30
+ }
31
+ OptionParser.new do |parser|
32
+ parser.on("--cache DIR") { |value| options[:cache_dir] = value }
33
+ parser.on("--firmware FILE") { |value| options[:firmware] = value }
34
+ parser.on("--language LANGUAGE") { |value| options[:language] = value }
35
+ parser.on("--language-version VERSION") { |value| options[:language_version] = value }
36
+ parser.on("--board BOARD") { |value| options[:board] = value }
37
+ parser.on("--mount DIR") { |value| options[:mount] = value }
38
+ parser.on("--port PORT") { |value| options[:port] = value }
39
+ parser.on("--timeout SECONDS", Float) { |value| options[:timeout] = value }
40
+ end.parse!(args)
41
+ options
42
+ end
43
+ private_class_method :parse_options
44
+ end
45
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rpremote
6
+ class Flasher
7
+ DEFAULT_VOLUMES_ROOT = "/Volumes"
8
+ DEFAULT_TIMEOUT = 10.0
9
+ INFO_FILE = "INFO_UF2.TXT"
10
+ UF2_BLOCK_SIZE = 512
11
+ UF2_MAGIC_START0 = 0x0A324655
12
+ UF2_MAGIC_START1 = 0x9E5D5157
13
+ UF2_MAGIC_END = 0x0AB16F30
14
+
15
+ Result = Data.define(:mount, :destination, :port)
16
+
17
+ class Error < Rpremote::Error; end
18
+ class MountNotFoundError < Error; end
19
+ class InvalidTargetError < Error; end
20
+ class TimeoutError < Error; end
21
+
22
+ attr_reader :volumes_root, :timeout
23
+
24
+ def initialize(
25
+ volumes_root: DEFAULT_VOLUMES_ROOT,
26
+ timeout: DEFAULT_TIMEOUT,
27
+ mount_probe: nil,
28
+ port_probe: nil,
29
+ sleeper: nil
30
+ )
31
+ @volumes_root = volumes_root
32
+ @timeout = Float(timeout)
33
+ @mount_probe = mount_probe || ->(path) { Dir.exist?(path) }
34
+ @port_probe = port_probe || -> { Device.main_port }
35
+ @sleeper = sleeper || ->(seconds) { sleep(seconds) }
36
+ raise ArgumentError, "timeout must be positive" unless @timeout.positive?
37
+ end
38
+
39
+ def flash(uf2_path, mount: nil, port: nil)
40
+ validate_firmware!(uf2_path)
41
+ target = find_mount(mount)
42
+ destination = File.join(target, File.basename(uf2_path))
43
+ copy_firmware(uf2_path, destination)
44
+ wait_until("BOOTSEL drive to disappear") { !mount_probe.call(target) }
45
+ detected_port = wait_until("R2P2 serial port to appear") { detect_port(port) }
46
+ Result.new(mount: target, destination: destination, port: detected_port)
47
+ end
48
+
49
+ def find_mount(explicit_mount = nil)
50
+ return validate_mount!(File.expand_path(explicit_mount)) if explicit_mount
51
+
52
+ matches = Dir.glob(File.join(volumes_root, "*")).select do |path|
53
+ Dir.exist?(path) && valid_target?(path)
54
+ end
55
+ case matches.length
56
+ when 0
57
+ raise MountNotFoundError,
58
+ "Pico 2 BOOTSEL drive not found; reconnect it while holding BOOTSEL or use --mount DIR"
59
+ when 1
60
+ matches.first
61
+ else
62
+ raise MountNotFoundError, "multiple Pico 2 BOOTSEL drives found; use --mount DIR"
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :mount_probe, :port_probe, :sleeper
69
+
70
+ def validate_firmware!(path)
71
+ raise InvalidTargetError, "UF2 file not found: #{path}" unless File.file?(path)
72
+ raise InvalidTargetError, "firmware file must have a .uf2 extension" unless File.extname(path).casecmp?(".uf2")
73
+ raise InvalidTargetError, "UF2 file is empty: #{path}" unless File.size?(path)
74
+ raise InvalidTargetError, "firmware is not a valid UF2 file: #{path}" unless valid_uf2?(path)
75
+ end
76
+
77
+ def validate_mount!(path)
78
+ raise MountNotFoundError, "BOOTSEL mount not found: #{path}" unless Dir.exist?(path)
79
+ raise InvalidTargetError, "mount is not an RP2350 BOOTSEL drive: #{path}" unless valid_target?(path)
80
+
81
+ path
82
+ end
83
+
84
+ def valid_target?(path)
85
+ info_path = File.join(path, INFO_FILE)
86
+ return false unless File.file?(info_path)
87
+
88
+ File.basename(path).casecmp?("RP2350") || File.read(info_path).match?(/RP2350|Pico 2/i)
89
+ rescue SystemCallError
90
+ false
91
+ end
92
+
93
+ def copy_firmware(source, destination)
94
+ FileUtils.copy_file(source, destination)
95
+ rescue SystemCallError => e
96
+ raise Error, "failed to copy UF2 firmware: #{e.message}"
97
+ end
98
+
99
+ def wait_until(description)
100
+ deadline = monotonic_time + timeout
101
+ loop do
102
+ result = yield
103
+ return result if result
104
+ raise TimeoutError, "timed out waiting for #{description}" if monotonic_time >= deadline
105
+
106
+ sleeper.call(0.1)
107
+ end
108
+ end
109
+
110
+ def valid_uf2?(path)
111
+ size = File.size(path)
112
+ return false if size < UF2_BLOCK_SIZE || (size % UF2_BLOCK_SIZE).positive?
113
+
114
+ start_magic = File.binread(path, 8).unpack("V2")
115
+ end_magic = File.binread(path, 4, UF2_BLOCK_SIZE - 4).unpack1("V")
116
+ start_magic == [UF2_MAGIC_START0, UF2_MAGIC_START1] && end_magic == UF2_MAGIC_END
117
+ end
118
+
119
+ def detect_port(explicit_port)
120
+ return explicit_port if explicit_port && File.exist?(explicit_port)
121
+
122
+ port_probe.call
123
+ rescue Device::NotFoundError, Device::MultipleDevicesError
124
+ nil
125
+ end
126
+
127
+ def monotonic_time
128
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpremote
4
+ module Help
5
+ TEXT = <<~HELP
6
+ rpremote - R2P2 remote control for Raspberry Pi Pico 2
7
+
8
+ Usage:
9
+ rpremote setup [--language LANGUAGE] [--language-version VERSION] [--force] [--cache DIR]
10
+ rpremote build [--language LANGUAGE] [--language-version VERSION] [--board BOARD]
11
+ [--firmware FILE] [--cache DIR]
12
+ [--mrbgems FILE|--no-mrbgems]
13
+ rpremote build clean
14
+ rpremote dfu app FILE [--type ruby|rite] [--port PORT] [--baud RATE] [--timeout SEC]
15
+ rpremote dfu compile FILE [--output FILE] [--language picoruby]
16
+ [--language-version VERSION] [--cache DIR]
17
+ rpremote dfu status [--port PORT] [--baud RATE] [--timeout SEC]
18
+ rpremote mrbgems check|list|lock|update [--file FILE] [--lockfile FILE]
19
+ rpremote flash [--firmware FILE] [--language LANGUAGE] [--language-version VERSION]
20
+ [--board BOARD] [--cache DIR] [--mount DIR] [--port PORT] [--timeout SEC]
21
+ rpremote ports
22
+ rpremote run FILE [--port PORT] [--timeout SEC] [--language LANGUAGE]
23
+ rpremote monitor [--port PORT]
24
+ rpremote repl [--port PORT]
25
+ rpremote exec CODE [--port PORT] [--timeout SEC] [--language LANGUAGE]
26
+ rpremote reset [--port PORT]
27
+ rpremote fs cp FILE :/REMOTE/PATH [--port PORT]
28
+ rpremote fs cp :/REMOTE/PATH FILE [--port PORT]
29
+ rpremote fs cat :/REMOTE/PATH [--port PORT]
30
+ rpremote fs ls :/REMOTE/PATH [--port PORT]
31
+ rpremote fs rm :/REMOTE/PATH [--port PORT]
32
+ rpremote fs mkdir :/REMOTE/PATH [--port PORT]
33
+
34
+ `rpremote build clean` removes only the project's generated `build/` directory.
35
+
36
+ Configuration:
37
+ config/setting.json default project options
38
+
39
+ Options:
40
+ --force download the PicoRuby source again during setup
41
+ --language-version VERSION
42
+ use R2P2/PicoRuby 4.0.3 or 3.4.2 (default: 4.0.3)
43
+ --cache DIR use another project cache directory
44
+ --mrbgems FILE use an explicit Mrbgems definition during build
45
+ --no-mrbgems build without the automatically detected Mrbgems
46
+ --board BOARD
47
+ select pico2 or pico2_w (default: pico2)
48
+ --firmware FILE build to, or flash from, this UF2 path
49
+ --language LANGUAGE
50
+ select the remote language (default: picoruby)
51
+ --config FILE use another configuration file
52
+ --mount DIR use an explicit RP2350 BOOTSEL drive
53
+ --port PORT use an explicit R2P2 CDC 0 device
54
+ --baud RATE serial baud rate (default: 115200)
55
+ --timeout SEC timeout in seconds (default: 10)
56
+ -h, --help show this help
57
+ -V, --version show the version
58
+ HELP
59
+ end
60
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rpremote
4
+ module Language
5
+ SUPPORTED = %w[picoruby].freeze
6
+
7
+ module_function
8
+
9
+ def validate!(language)
10
+ return language if SUPPORTED.include?(language)
11
+
12
+ raise ArgumentError, "unsupported language: #{language}"
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "net/http"
5
+ require "timeout"
6
+ require "uri"
7
+
8
+ module Rpremote
9
+ class LanguageSource
10
+ MAX_REDIRECTS = 5
11
+
12
+ class Error < Rpremote::Error; end
13
+ class DownloadError < Error; end
14
+ class ExtractError < Error; end
15
+
16
+ attr_reader :language, :version, :cache_dir
17
+
18
+ def initialize(language: Target::DEFAULT_LANGUAGE, version: Target::DEFAULT_LANGUAGE_VERSION,
19
+ cache_dir: Target::DEFAULT_CACHE_DIR,
20
+ fetcher: nil, extractor: nil, preparer: nil)
21
+ Language.validate!(language)
22
+
23
+ @language = language
24
+ @version = version
25
+ @cache_dir = File.expand_path(cache_dir.gsub("{version}", version))
26
+ @fetcher = fetcher || method(:download)
27
+ @extractor = extractor || method(:extract)
28
+ @preparer = preparer || method(:prepare_repository)
29
+ end
30
+
31
+ def setup(force: false)
32
+ FileUtils.mkdir_p(cache_dir)
33
+ fetch_archive if force || !File.size?(archive_path)
34
+ unless File.directory?(source_dir) && !force
35
+ FileUtils.rm_rf(source_dir) if force
36
+ extractor.call(archive_path, cache_dir)
37
+ end
38
+ raise ExtractError, "PicoRuby source was not extracted: #{source_dir}" unless File.directory?(source_dir)
39
+
40
+ preparer.call(source_dir)
41
+ source_dir
42
+ end
43
+
44
+ def archive_url
45
+ "https://github.com/picoruby/picoruby/archive/refs/tags/#{version}.zip"
46
+ end
47
+
48
+ def archive_path
49
+ File.join(cache_dir, "picoruby-#{version}.zip")
50
+ end
51
+
52
+ def source_dir
53
+ File.join(cache_dir, "picoruby-#{version}")
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :fetcher, :extractor, :preparer
59
+
60
+ def fetch_archive
61
+ temporary = "#{archive_path}.part"
62
+ File.binwrite(temporary, fetcher.call(archive_url))
63
+ raise DownloadError, "downloaded PicoRuby archive is empty" unless File.size?(temporary)
64
+
65
+ File.rename(temporary, archive_path)
66
+ rescue SystemCallError => e
67
+ raise DownloadError, "cannot store PicoRuby source: #{e.message}"
68
+ ensure
69
+ FileUtils.rm_f(temporary) if temporary
70
+ end
71
+
72
+ def extract(archive, destination)
73
+ success = system("/usr/bin/ditto", "-x", "-k", archive, destination)
74
+ raise ExtractError, "cannot extract PicoRuby archive: #{archive}" unless success
75
+ end
76
+
77
+ def prepare_repository(source)
78
+ mruby_core = File.join(source, "mrbgems/picoruby-mruby/lib/mruby/lib/mruby/core_ext.rb")
79
+ pico_sdk = File.join(source, "mrbgems/picoruby-r2p2/lib/pico-sdk/CMakeLists.txt")
80
+ return if File.file?(mruby_core) && File.file?(pico_sdk)
81
+
82
+ commands = []
83
+ unless File.directory?(File.join(source, ".git"))
84
+ commands << %w[git init]
85
+ commands << ["git", "remote", "add", "origin", "https://github.com/picoruby/picoruby.git"]
86
+ end
87
+ commands.push(
88
+ ["git", "fetch", "--depth", "1", "origin", "refs/tags/#{version}"],
89
+ ["git", "checkout", "--force", "FETCH_HEAD"],
90
+ ["git", "submodule", "update", "--init", "--recursive", "--depth", "1"]
91
+ )
92
+ commands.each do |command|
93
+ next if system(*command, chdir: source)
94
+
95
+ raise ExtractError, "cannot prepare PicoRuby submodules: #{command.join(" ")}"
96
+ end
97
+ end
98
+
99
+ def download(url, redirects = MAX_REDIRECTS)
100
+ raise DownloadError, "too many redirects while downloading PicoRuby" if redirects.negative?
101
+
102
+ uri = URI(url)
103
+ raise DownloadError, "PicoRuby download requires HTTPS" unless uri.is_a?(URI::HTTPS)
104
+
105
+ request = Net::HTTP::Get.new(uri)
106
+ request["User-Agent"] = "rpremote/#{Rpremote::VERSION}"
107
+ response = Net::HTTP.start(
108
+ uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60
109
+ ) { |http| http.request(request) }
110
+
111
+ case response
112
+ when Net::HTTPSuccess
113
+ response.body
114
+ when Net::HTTPRedirection
115
+ location = response["location"]
116
+ raise DownloadError, "PicoRuby download redirect has no location" unless location
117
+
118
+ download(URI.join(url, location).to_s, redirects - 1)
119
+ else
120
+ raise DownloadError, "PicoRuby download failed: HTTP #{response.code} #{response.message}"
121
+ end
122
+ rescue SocketError, SystemCallError, Timeout::Error => e
123
+ raise DownloadError, "PicoRuby download failed: #{e.message}"
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+
8
+ module Rpremote
9
+ class Mrbgems
10
+ DEFAULT_PATH = "Mrbgems"
11
+ DEFAULT_LOCK_PATH = "Mrbgems.lock"
12
+ LOCK_VERSION = 1
13
+ GITHUB_PATTERN = %r{\A[\w.-]+/[\w.-]+\z}
14
+ COMMIT_PATTERN = /\A[0-9a-f]{40,64}\z/i
15
+ VMS = %i[mruby mrubyc].freeze
16
+
17
+ Dependency = Data.define(:type, :source, :branch, :commit, :path)
18
+ Overlay = Data.define(:path, :fingerprint)
19
+
20
+ class Error < Rpremote::Error; end
21
+ class DefinitionError < Error; end
22
+ class LockError < Error; end
23
+
24
+ attr_reader :path, :lock_path
25
+
26
+ def initialize(path: DEFAULT_PATH, lock_path: nil, cwd: Dir.pwd, resolver: nil)
27
+ @path = File.expand_path(path, cwd)
28
+ @lock_path = File.expand_path(lock_path || DEFAULT_LOCK_PATH, File.dirname(@path))
29
+ @resolver = resolver || method(:resolve_github)
30
+ end
31
+
32
+ def exist?
33
+ File.file?(path)
34
+ end
35
+
36
+ def dependencies
37
+ @dependencies ||= load_definition
38
+ end
39
+
40
+ def vm
41
+ dependencies
42
+ @definition.vm_name
43
+ end
44
+
45
+ def check
46
+ dependencies.each { |dependency| validate_local!(dependency) if dependency.type == :path }
47
+ dependencies
48
+ end
49
+
50
+ def lock(update: false)
51
+ check
52
+ previous = update ? nil : read_lock(required: false)
53
+ entries = dependencies.map { |dependency| lock_entry(dependency, previous) }
54
+ contents = { "version" => LOCK_VERSION, "vm" => vm&.to_s, "gems" => entries }.compact
55
+ write_json(lock_path, contents)
56
+ contents
57
+ end
58
+
59
+ def read_lock(required: true)
60
+ contents = JSON.parse(File.read(lock_path))
61
+ validate_lock!(contents)
62
+ contents
63
+ rescue Errno::ENOENT
64
+ raise LockError, "mrbgems lock file does not exist: #{lock_path}" if required
65
+
66
+ nil
67
+ rescue JSON::ParserError => e
68
+ raise LockError, "invalid mrbgems lock file #{lock_path}: #{e.message}"
69
+ end
70
+
71
+ def generate_overlay(base_config:, target:, directory:, update: false)
72
+ lock_data = lock(update: update)
73
+ fingerprint = build_fingerprint(base_config, lock_data)
74
+ output_dir = File.join(File.expand_path(directory), fingerprint)
75
+ output_path = File.join(output_dir, "build_config.rb")
76
+ FileUtils.mkdir_p(output_dir)
77
+ write_file(output_path, overlay_source(base_config, target, lock_data.fetch("gems")))
78
+ Overlay.new(path: output_path, fingerprint: fingerprint)
79
+ end
80
+
81
+ private
82
+
83
+ attr_reader :resolver
84
+
85
+ def load_definition
86
+ dsl = Definition.new(path)
87
+ dsl.instance_eval(File.read(path), path, 1)
88
+ @definition = dsl
89
+ dsl.dependencies.freeze
90
+ rescue Errno::ENOENT
91
+ raise DefinitionError, "mrbgems definition does not exist: #{path}"
92
+ rescue SyntaxError => e
93
+ raise DefinitionError, "invalid mrbgems definition #{path}: #{e.message}"
94
+ rescue DefinitionError
95
+ raise
96
+ rescue StandardError => e
97
+ raise DefinitionError, "cannot load mrbgems definition #{path}: #{e.message}"
98
+ end
99
+
100
+ def validate_local!(dependency)
101
+ return if File.file?(File.join(dependency.path, "mrbgem.rake"))
102
+
103
+ raise DefinitionError, "local mrbgem has no mrbgem.rake: #{dependency.path}"
104
+ end
105
+
106
+ def lock_entry(dependency, previous)
107
+ if dependency.type == :github
108
+ commit = dependency.commit || previous_commit(previous, dependency) || resolver.call(
109
+ dependency.source, dependency.branch
110
+ )
111
+ { "type" => "github", "source" => dependency.source,
112
+ "branch" => dependency.branch, "commit" => validate_commit!(commit) }
113
+ else
114
+ { "type" => "path", "source" => dependency.source,
115
+ "sha256" => digest_directory(dependency.path) }
116
+ end
117
+ end
118
+
119
+ def previous_commit(lock_data, dependency)
120
+ return unless lock_data
121
+
122
+ entry = lock_data.fetch("gems").find do |gem|
123
+ gem["type"] == "github" && gem["source"] == dependency.source &&
124
+ gem["branch"] == dependency.branch
125
+ end
126
+ entry&.fetch("commit", nil)
127
+ end
128
+
129
+ def validate_commit!(commit)
130
+ value = commit.to_s.downcase
131
+ raise LockError, "invalid Git commit: #{commit.inspect}" unless COMMIT_PATTERN.match?(value)
132
+
133
+ value
134
+ end
135
+
136
+ def resolve_github(source, branch)
137
+ url = "https://github.com/#{source}.git"
138
+ stdout, stderr, status = Open3.capture3(
139
+ "git", "ls-remote", "--exit-code", url, "refs/heads/#{branch}"
140
+ )
141
+ unless status.success?
142
+ detail = stderr.strip
143
+ detail = "branch not found" if detail.empty?
144
+ raise LockError, "cannot resolve #{source} #{branch}: #{detail}"
145
+ end
146
+
147
+ validate_commit!(stdout.split.first)
148
+ rescue Errno::ENOENT
149
+ raise LockError, "git is required to resolve GitHub mrbgems"
150
+ end
151
+
152
+ def digest_directory(directory)
153
+ digest = Digest::SHA256.new
154
+ files = Dir.glob(File.join(directory, "**", "*"), File::FNM_DOTMATCH)
155
+ .select { |file| File.file?(file) }
156
+ .reject { |file| ignored_local_file?(file, directory) }
157
+ .sort
158
+ files.each do |file|
159
+ relative = file.delete_prefix("#{directory}/")
160
+ digest.update(relative).update("\0").update(File.binread(file)).update("\0")
161
+ end
162
+ digest.hexdigest
163
+ end
164
+
165
+ def ignored_local_file?(file, directory)
166
+ relative = file.delete_prefix("#{directory}/")
167
+ relative.split(File::SEPARATOR).intersect?(%w[.git build tmp])
168
+ end
169
+
170
+ def validate_lock!(contents)
171
+ unless contents.is_a?(Hash) && contents["version"] == LOCK_VERSION && contents["gems"].is_a?(Array)
172
+ raise LockError, "unsupported mrbgems lock format: #{lock_path}"
173
+ end
174
+ unless contents["vm"].nil? || VMS.map(&:to_s).include?(contents["vm"])
175
+ raise LockError, "invalid mrbgems VM in lock file: #{contents["vm"].inspect}"
176
+ end
177
+
178
+ contents["gems"].each do |entry|
179
+ type = entry["type"]
180
+ valid = type == "github" ? valid_github_lock?(entry) : valid_path_lock?(entry)
181
+ raise LockError, "invalid mrbgems lock entry: #{entry.inspect}" unless valid
182
+ end
183
+ end
184
+
185
+ def valid_github_lock?(entry)
186
+ GITHUB_PATTERN.match?(entry["source"].to_s) && !entry["branch"].to_s.empty? &&
187
+ COMMIT_PATTERN.match?(entry["commit"].to_s)
188
+ end
189
+
190
+ def valid_path_lock?(entry)
191
+ entry["type"] == "path" && !entry["source"].to_s.empty? &&
192
+ /\A[0-9a-f]{64}\z/.match?(entry["sha256"].to_s)
193
+ end
194
+
195
+ def build_fingerprint(base_config, lock_data)
196
+ Digest::SHA256.hexdigest(
197
+ [File.binread(path), File.binread(base_config), JSON.generate(lock_data)].join("\0")
198
+ ).slice(0, 12)
199
+ end
200
+
201
+ def overlay_source(base_config, target, locked_entries)
202
+ lines = [
203
+ "# Generated by rpremote. Do not edit.",
204
+ "load #{File.expand_path(base_config).inspect}",
205
+ "conf = MRuby.targets.fetch(#{target.inspect})"
206
+ ]
207
+ dependencies.zip(locked_entries).each do |dependency, locked|
208
+ lines << if dependency.type == :github
209
+ "conf.gem github: #{dependency.source.inspect}, " \
210
+ "branch: #{dependency.branch.inspect}, checksum_hash: #{locked.fetch("commit").inspect}"
211
+ else
212
+ "conf.gem #{dependency.path.inspect}"
213
+ end
214
+ end
215
+ "#{lines.join("\n")}\n"
216
+ end
217
+
218
+ def write_json(filename, contents)
219
+ write_file(filename, "#{JSON.pretty_generate(contents)}\n")
220
+ end
221
+
222
+ def write_file(filename, contents)
223
+ return if File.file?(filename) && File.binread(filename) == contents
224
+
225
+ FileUtils.mkdir_p(File.dirname(filename))
226
+ temporary = "#{filename}.tmp-#{Process.pid}"
227
+ File.binwrite(temporary, contents)
228
+ File.rename(temporary, filename)
229
+ ensure
230
+ FileUtils.rm_f(temporary) if temporary
231
+ end
232
+
233
+ class Definition
234
+ attr_reader :dependencies, :vm_name
235
+
236
+ def initialize(filename)
237
+ @directory = File.dirname(filename)
238
+ @dependencies = []
239
+ end
240
+
241
+ def vm(name)
242
+ value = name.to_sym
243
+ raise DefinitionError, "unsupported mrbgems VM: #{name.inspect}" unless VMS.include?(value)
244
+ raise DefinitionError, "mrbgems VM specified more than once" if vm_name
245
+
246
+ @vm_name = value
247
+ end
248
+
249
+ def gem(github: nil, path: nil, branch: "main", commit: nil)
250
+ sources = [github, path].compact
251
+ raise DefinitionError, "gem requires exactly one of github or path" unless sources.length == 1
252
+
253
+ dependency = if github
254
+ github_dependency(github, branch, commit)
255
+ else
256
+ path_dependency(path, branch, commit)
257
+ end
258
+ key = [dependency.type, dependency.source]
259
+ raise DefinitionError, "duplicate mrbgem: #{dependency.source}" if dependencies.any? do |item|
260
+ [item.type, item.source] == key
261
+ end
262
+
263
+ dependencies << dependency
264
+ end
265
+
266
+ private
267
+
268
+ attr_reader :directory
269
+
270
+ def github_dependency(source, branch, commit)
271
+ raise DefinitionError, "invalid GitHub mrbgem: #{source.inspect}" unless GITHUB_PATTERN.match?(source.to_s)
272
+ raise DefinitionError, "GitHub mrbgem branch must not be empty" if branch.to_s.empty?
273
+ raise DefinitionError, "invalid Git commit: #{commit.inspect}" if commit && !COMMIT_PATTERN.match?(commit.to_s)
274
+
275
+ Dependency.new(type: :github, source: source, branch: branch,
276
+ commit: commit&.downcase, path: nil)
277
+ end
278
+
279
+ def path_dependency(source, branch, commit)
280
+ raise DefinitionError, "local mrbgem path must not be empty" if source.to_s.empty?
281
+ raise DefinitionError, "local mrbgem does not accept branch or commit" if branch != "main" || commit
282
+
283
+ Dependency.new(type: :path, source: source, branch: nil, commit: nil,
284
+ path: File.expand_path(source, directory))
285
+ end
286
+ end
287
+ end
288
+ end