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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.ja.md +122 -0
- data/README.md +124 -0
- data/RELEASING.md +54 -0
- data/THIRD_PARTY_NOTICES.md +29 -0
- data/exe/rpremote +7 -0
- data/lib/rpremote/build_command.rb +38 -0
- data/lib/rpremote/builder.rb +123 -0
- data/lib/rpremote/checksum.rb +27 -0
- data/lib/rpremote/cli.rb +267 -0
- data/lib/rpremote/config.rb +137 -0
- data/lib/rpremote/device.rb +56 -0
- data/lib/rpremote/dfu_command.rb +155 -0
- data/lib/rpremote/dfu_compiler.rb +50 -0
- data/lib/rpremote/flash_command.rb +45 -0
- data/lib/rpremote/flasher.rb +131 -0
- data/lib/rpremote/help.rb +60 -0
- data/lib/rpremote/language.rb +15 -0
- data/lib/rpremote/language_source.rb +126 -0
- data/lib/rpremote/mrbgems.rb +288 -0
- data/lib/rpremote/mrbgems_command.rb +48 -0
- data/lib/rpremote/picomodem.rb +293 -0
- data/lib/rpremote/remote_path.rb +27 -0
- data/lib/rpremote/resetter.rb +73 -0
- data/lib/rpremote/runner.rb +48 -0
- data/lib/rpremote/serial.rb +72 -0
- data/lib/rpremote/setup_command.rb +34 -0
- data/lib/rpremote/shell.rb +161 -0
- data/lib/rpremote/target.rb +46 -0
- data/lib/rpremote/terminal.rb +91 -0
- data/lib/rpremote/version.rb +5 -0
- data/lib/rpremote.rb +30 -0
- data/sig/rpremote.rbs +404 -0
- data/tasks/firmware_build.rb +108 -0
- data/tasks/release.rake +12 -0
- data/tasks/release_check.rb +83 -0
- metadata +82 -0
|
@@ -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
|