ruby_llm-skills 0.3.0 → 0.5.0.pre1
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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +58 -3
- data/lib/ruby_llm/skills/agent_extensions.rb +22 -21
- data/lib/ruby_llm/skills/chat_extensions.rb +7 -10
- data/lib/ruby_llm/skills/marketplace/bundle.rb +367 -0
- data/lib/ruby_llm/skills/marketplace/config.rb +41 -0
- data/lib/ruby_llm/skills/marketplace/fetcher.rb +260 -0
- data/lib/ruby_llm/skills/marketplace/github_repo.rb +94 -0
- data/lib/ruby_llm/skills/marketplace/gitlab_repo.rb +72 -0
- data/lib/ruby_llm/skills/marketplace/http.rb +122 -0
- data/lib/ruby_llm/skills/marketplace/locator.rb +115 -0
- data/lib/ruby_llm/skills/marketplace/lockfile.rb +113 -0
- data/lib/ruby_llm/skills/marketplace/manifest.rb +269 -0
- data/lib/ruby_llm/skills/marketplace/registry.rb +384 -0
- data/lib/ruby_llm/skills/marketplace/tarball.rb +160 -0
- data/lib/ruby_llm/skills/marketplace.rb +79 -0
- data/lib/ruby_llm/skills/railtie.rb +3 -0
- data/lib/ruby_llm/skills/skill_tool.rb +14 -14
- data/lib/ruby_llm/skills/source_detection.rb +30 -0
- data/lib/ruby_llm/skills/tasks/marketplaces.rake +95 -0
- data/lib/ruby_llm/skills/tasks.rb +11 -0
- data/lib/ruby_llm/skills/version.rb +1 -1
- data/lib/ruby_llm/skills.rb +23 -45
- metadata +33 -9
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Skills
|
|
7
|
+
module Marketplace
|
|
8
|
+
# What a person types to name a marketplace: `owner/repo`, `owner/repo@ref`,
|
|
9
|
+
# a github.com or gitlab.com repository URL (with an optional `/tree/<ref>`),
|
|
10
|
+
# an https:// URL ending in `.json` (a hosted marketplace file), or a
|
|
11
|
+
# local directory.
|
|
12
|
+
#
|
|
13
|
+
module Locator
|
|
14
|
+
# A marketplace upstream: +kind+ is "github", "gitlab", "url" or "directory".
|
|
15
|
+
Source = Data.define(:kind, :locator, :ref) do
|
|
16
|
+
def to_h
|
|
17
|
+
{"kind" => kind, "locator" => locator, "ref" => ref}.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.from_h(hash)
|
|
21
|
+
hash = hash.to_h.transform_keys(&:to_s)
|
|
22
|
+
new(kind: hash["kind"], locator: hash["locator"], ref: hash["ref"])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Pinned when the ref is a full commit sha.
|
|
26
|
+
def pinned? = ref.to_s.match?(Manifest::SHA_PATTERN)
|
|
27
|
+
|
|
28
|
+
def to_s
|
|
29
|
+
ref ? "#{locator}@#{ref}" : locator
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
HOSTS = {"github.com" => "github", "gitlab.com" => "gitlab"}.freeze
|
|
34
|
+
MAX_LENGTH = 500
|
|
35
|
+
REF_PATTERN = %r{\A\w[\w./-]{0,199}\z}
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @param text [String]
|
|
39
|
+
# @param ref [String, nil] a branch, tag or sha; overrides an `@ref` suffix
|
|
40
|
+
# @return [Source]
|
|
41
|
+
# @raise [ArgumentError]
|
|
42
|
+
def parse(text, ref: nil)
|
|
43
|
+
text = text.to_s.strip
|
|
44
|
+
raise ArgumentError, "marketplace locator is empty" if text.empty?
|
|
45
|
+
raise ArgumentError, "marketplace locator is too long" if text.length > MAX_LENGTH
|
|
46
|
+
|
|
47
|
+
source = if text.match?(%r{\Ahttps?://}i)
|
|
48
|
+
from_url(text)
|
|
49
|
+
elsif text.start_with?("./", "../", "/") || File.directory?(text)
|
|
50
|
+
Source.new(kind: "directory", locator: text, ref: nil)
|
|
51
|
+
else
|
|
52
|
+
from_shorthand(text)
|
|
53
|
+
end
|
|
54
|
+
ref = Manifest.presence(ref)
|
|
55
|
+
return source unless ref
|
|
56
|
+
raise ArgumentError, "a #{source.kind} marketplace has no ref" unless %w[github gitlab].include?(source.kind)
|
|
57
|
+
raise ArgumentError, "invalid ref #{ref.inspect}" unless ref.match?(REF_PATTERN)
|
|
58
|
+
|
|
59
|
+
Source.new(kind: source.kind, locator: source.locator, ref: ref)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def from_shorthand(text)
|
|
65
|
+
repo, ref = text.split("@", 2)
|
|
66
|
+
valid_ref = ref.nil? || ref.match?(REF_PATTERN)
|
|
67
|
+
raise ArgumentError, "invalid marketplace locator #{text.inspect}" unless repo.match?(Manifest::REPO_PATTERN) && valid_ref
|
|
68
|
+
|
|
69
|
+
Source.new(kind: "github", locator: repo, ref: ref)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def from_url(text)
|
|
73
|
+
uri = URI.parse(text)
|
|
74
|
+
raise ArgumentError, "marketplace URLs must be https" unless uri.scheme&.downcase == "https" && Manifest.presence(uri.host)
|
|
75
|
+
raise ArgumentError, "marketplace URLs must not carry credentials" if uri.userinfo
|
|
76
|
+
|
|
77
|
+
kind = HOSTS[uri.host.downcase]
|
|
78
|
+
return Source.new(kind: "url", locator: text, ref: nil) if kind.nil? && uri.path.end_with?(".json")
|
|
79
|
+
raise ArgumentError, "unsupported marketplace host #{uri.host.inspect}" if kind.nil?
|
|
80
|
+
|
|
81
|
+
segments = uri.path.split("/").reject(&:empty?)
|
|
82
|
+
depth = repo_depth(kind, segments)
|
|
83
|
+
raise ArgumentError, "invalid repository URL #{text.inspect}" if depth < 2 || segments.size < depth
|
|
84
|
+
|
|
85
|
+
repo = segments.first(depth).join("/").delete_suffix(".git")
|
|
86
|
+
pattern = (kind == "gitlab") ? Manifest::PROJECT_PATTERN : Manifest::REPO_PATTERN
|
|
87
|
+
raise ArgumentError, "invalid repository URL #{text.inspect}" unless repo.match?(pattern)
|
|
88
|
+
|
|
89
|
+
ref = ref_from_segments(segments, depth)
|
|
90
|
+
Source.new(kind: kind, locator: repo, ref: Manifest.presence(ref))
|
|
91
|
+
rescue URI::InvalidURIError
|
|
92
|
+
raise ArgumentError, "invalid marketplace URL #{text.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `/tree/<ref>` on GitHub, `/-/tree/<ref>` on GitLab.
|
|
96
|
+
def ref_from_segments(segments, depth)
|
|
97
|
+
marker = segments[depth]
|
|
98
|
+
return nil unless %w[tree -].include?(marker)
|
|
99
|
+
|
|
100
|
+
segments.drop((marker == "-") ? depth + 2 : depth + 1).join("/")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Two segments name a GitHub repository; on GitLab everything before
|
|
104
|
+
# the `/-/` (or a legacy `/tree/`) separator, because a project there
|
|
105
|
+
# lives under nested groups.
|
|
106
|
+
def repo_depth(kind, segments)
|
|
107
|
+
return 2 unless kind == "gitlab"
|
|
108
|
+
|
|
109
|
+
segments.index { |segment| %w[- tree].include?(segment) } || segments.size
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Skills
|
|
8
|
+
module Marketplace
|
|
9
|
+
# The record of every added marketplace and installed plugin, with the
|
|
10
|
+
# commit and tree each resolved to, so `install` reproduces the same
|
|
11
|
+
# trees on another machine. JSON, keys sorted, one trailing newline.
|
|
12
|
+
#
|
|
13
|
+
# {
|
|
14
|
+
# "version": 1,
|
|
15
|
+
# "marketplaces": {
|
|
16
|
+
# "compound-writing": {
|
|
17
|
+
# "kind": "github", "locator": "EveryInc/compound-writing", "ref": "main",
|
|
18
|
+
# "commit": "…",
|
|
19
|
+
# "plugins": {
|
|
20
|
+
# "compound-writing": {
|
|
21
|
+
# "version": "2.4.1", "version_kind": "manifest", "commit": "…",
|
|
22
|
+
# "tree_sha256": "…", "source": { "kind": "relative", "path": "" },
|
|
23
|
+
# "skills": ["cw-draft", "…"]
|
|
24
|
+
# }
|
|
25
|
+
# }
|
|
26
|
+
# }
|
|
27
|
+
# }
|
|
28
|
+
# }
|
|
29
|
+
#
|
|
30
|
+
class Lockfile
|
|
31
|
+
VERSION = 1
|
|
32
|
+
|
|
33
|
+
attr_reader :path, :marketplaces
|
|
34
|
+
|
|
35
|
+
# @param path [String] the lockfile; a missing file is an empty lockfile
|
|
36
|
+
# @raise [LockfileError]
|
|
37
|
+
def self.load(path)
|
|
38
|
+
return new(path) unless File.exist?(path)
|
|
39
|
+
|
|
40
|
+
data = JSON.parse(File.read(path))
|
|
41
|
+
raise LockfileError, "#{path} is not a JSON object" unless data.is_a?(Hash)
|
|
42
|
+
raise LockfileError, "#{path} has lockfile version #{data["version"].inspect}; this gem reads version #{VERSION}" unless data["version"] == VERSION
|
|
43
|
+
|
|
44
|
+
marketplaces = data["marketplaces"]
|
|
45
|
+
raise LockfileError, "#{path} has no marketplaces object" unless marketplaces.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
marketplaces.each do |name, entry|
|
|
48
|
+
raise LockfileError, "#{path}: marketplace #{name.inspect} is not an object" unless entry.is_a?(Hash)
|
|
49
|
+
raise LockfileError, "#{path}: marketplace #{name.inspect} has no plugins object" unless entry.fetch("plugins", {}).is_a?(Hash)
|
|
50
|
+
end
|
|
51
|
+
new(path, marketplaces)
|
|
52
|
+
rescue JSON::ParserError => e
|
|
53
|
+
raise LockfileError, "#{path} is not valid JSON (#{e.message[0, 80]})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def initialize(path, marketplaces = {})
|
|
57
|
+
@path = path.to_s
|
|
58
|
+
@marketplaces = marketplaces
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def marketplace(name)
|
|
62
|
+
@marketplaces[name.to_s]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def names
|
|
66
|
+
@marketplaces.keys.sort
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plugins(name)
|
|
70
|
+
marketplace(name)&.fetch("plugins", nil) || {}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def plugin(name, plugin)
|
|
74
|
+
plugins(name)[plugin.to_s]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Records a marketplace, keeping its plugins unless +data+ carries some.
|
|
78
|
+
def set_marketplace(name, data)
|
|
79
|
+
existing = marketplace(name) || {}
|
|
80
|
+
plugins = data.fetch("plugins", existing.fetch("plugins", {}))
|
|
81
|
+
@marketplaces[name.to_s] = data.except("plugins").merge("plugins" => plugins)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def delete_marketplace(name)
|
|
85
|
+
@marketplaces.delete(name.to_s)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def set_plugin(name, plugin, data)
|
|
89
|
+
set_marketplace(name, {}) unless marketplace(name)
|
|
90
|
+
@marketplaces[name.to_s]["plugins"][plugin.to_s] = data
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def delete_plugin(name, plugin)
|
|
94
|
+
plugins(name).delete(plugin.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Marketplaces and plugins sorted by name; each entry keeps its field order.
|
|
98
|
+
def to_h
|
|
99
|
+
marketplaces = @marketplaces.sort.to_h do |name, data|
|
|
100
|
+
[name, data.merge("plugins" => data.fetch("plugins", {}).sort.to_h)]
|
|
101
|
+
end
|
|
102
|
+
{"version" => VERSION, "marketplaces" => marketplaces}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def save
|
|
106
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
107
|
+
File.write(@path, "#{JSON.pretty_generate(to_h)}\n")
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
module Skills
|
|
9
|
+
module Marketplace
|
|
10
|
+
# The marketplace file. Claude Code's `.claude-plugin/marketplace.json`
|
|
11
|
+
# is the native shape; Codex's `.agents/plugins/marketplace.json`
|
|
12
|
+
# (`source: {source: "local", path}`, `interface.displayName`) and
|
|
13
|
+
# Cursor's `.cursor-plugin/marketplace.json` (path-string / `{path}`
|
|
14
|
+
# sources) share `name` / `owner` / `plugins[]{name, source}`.
|
|
15
|
+
#
|
|
16
|
+
# Every `source` variant becomes a PluginSource: `relative` (inside the
|
|
17
|
+
# marketplace repository), `github` / `gitlab` (also from `url` and
|
|
18
|
+
# `git-subdir` on those hosts), `archive` (a .tar.gz), or `unsupported`
|
|
19
|
+
# with the reason people see (`npm`, `command`, zip, other hosts).
|
|
20
|
+
#
|
|
21
|
+
module Manifest
|
|
22
|
+
PATHS = %w[.claude-plugin/marketplace.json .agents/plugins/marketplace.json .cursor-plugin/marketplace.json].freeze
|
|
23
|
+
SHAPES = {
|
|
24
|
+
".claude-plugin/marketplace.json" => "claude",
|
|
25
|
+
".agents/plugins/marketplace.json" => "codex",
|
|
26
|
+
".cursor-plugin/marketplace.json" => "cursor"
|
|
27
|
+
}.freeze
|
|
28
|
+
NAME_PATTERN = /\A[a-z0-9]+(?:[-._][a-z0-9]+)*\z/i
|
|
29
|
+
REPO_PATTERN = %r{\A[\w.-]+/[\w.-]+\z}
|
|
30
|
+
PROJECT_PATTERN = %r{\A[\w.-]+(?:/[\w.-]+)+\z}
|
|
31
|
+
SHA_PATTERN = /\A[0-9a-f]{40}\z/
|
|
32
|
+
# plugin.json fields a marketplace entry may carry (strict: false makes the entry the plugin).
|
|
33
|
+
OVERRIDE_FIELDS = %w[skills commands agents hooks mcpServers lspServers outputStyles].freeze
|
|
34
|
+
MAX_BYTES = 10 * 1024 * 1024
|
|
35
|
+
|
|
36
|
+
# The parsed marketplace file. +renames+ is the file's `{ "old" => "new" }`
|
|
37
|
+
# map of plugins renamed (or, with a nil value, removed) since earlier versions.
|
|
38
|
+
Catalog = Data.define(:name, :display_name, :owner, :description, :version, :plugin_root, :renames, :plugins, :shape, :path, :raw) do
|
|
39
|
+
def plugin(name)
|
|
40
|
+
plugins.find { |entry| entry.name == name.to_s }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
{"name" => name, "display_name" => display_name, "owner" => owner, "description" => description,
|
|
45
|
+
"version" => version, "plugin_root" => plugin_root, "renames" => renames, "shape" => shape, "path" => path}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One `plugins[]` entry.
|
|
50
|
+
Entry = Data.define(:name, :display_name, :description, :version, :category, :tags, :strict, :source, :overrides, :raw) do
|
|
51
|
+
def strict? = strict
|
|
52
|
+
|
|
53
|
+
def supported? = source.supported?
|
|
54
|
+
|
|
55
|
+
def to_h
|
|
56
|
+
{"name" => name, "displayName" => display_name, "description" => description, "version" => version,
|
|
57
|
+
"category" => category, "tags" => tags, "strict" => strict, "source" => source.to_h}.compact
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Where an entry's content comes from.
|
|
62
|
+
PluginSource = Data.define(:kind, :path, :repo, :ref, :sha, :url, :sha256, :reason) do
|
|
63
|
+
def supported? = kind != "unsupported"
|
|
64
|
+
|
|
65
|
+
def to_h
|
|
66
|
+
{"kind" => kind, "path" => path, "repo" => repo, "ref" => ref, "sha" => sha, "url" => url, "sha256" => sha256, "reason" => reason}.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def self.from_h(hash)
|
|
70
|
+
hash = hash.to_h.transform_keys(&:to_s)
|
|
71
|
+
new(kind: hash["kind"], path: hash["path"], repo: hash["repo"], ref: hash["ref"], sha: hash["sha"],
|
|
72
|
+
url: hash["url"], sha256: hash["sha256"], reason: hash["reason"])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.unsupported(reason, raw = nil)
|
|
76
|
+
url = raw.is_a?(Hash) ? Manifest.presence(raw["url"]) : nil
|
|
77
|
+
new(kind: "unsupported", path: nil, repo: nil, ref: nil, sha: nil, url: url, sha256: nil, reason: reason)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
class << self
|
|
82
|
+
# The marketplace file among +files+ (`{ path => bytes }`), in discovery order.
|
|
83
|
+
def discover(files)
|
|
84
|
+
path = PATHS.find { |candidate| files.key?(candidate) }
|
|
85
|
+
raise InvalidManifestError, "no marketplace file (looked for #{PATHS.join(", ")})" if path.nil?
|
|
86
|
+
|
|
87
|
+
parse(files.fetch(path), shape: SHAPES.fetch(path), path: path)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse(json, shape: "claude", path: PATHS.first)
|
|
91
|
+
raise InvalidManifestError, "marketplace file exceeds #{MAX_BYTES} bytes" if json.is_a?(String) && json.bytesize > MAX_BYTES
|
|
92
|
+
|
|
93
|
+
data = json.is_a?(Hash) ? json : JSON.parse(json.to_s.dup.force_encoding(Encoding::UTF_8))
|
|
94
|
+
raise InvalidManifestError, "marketplace file is not a JSON object" unless data.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
name = data["name"].to_s.strip
|
|
97
|
+
raise InvalidManifestError, "marketplace name is required" if name.empty?
|
|
98
|
+
raise InvalidManifestError, "marketplace name #{name.inspect} is not kebab-case" unless name.match?(NAME_PATTERN)
|
|
99
|
+
raise InvalidManifestError, "marketplace has no plugins[]" unless data["plugins"].is_a?(Array)
|
|
100
|
+
|
|
101
|
+
metadata = data["metadata"].is_a?(Hash) ? data["metadata"] : {}
|
|
102
|
+
plugin_root = normalize_relative(metadata["pluginRoot"].to_s, allow_blank: true)
|
|
103
|
+
plugins = data["plugins"].each_with_index.map { |entry, index| entry(entry, index, plugin_root: plugin_root) }
|
|
104
|
+
duplicates = plugins.map(&:name).tally.select { |_, count| count > 1 }.keys
|
|
105
|
+
raise InvalidManifestError, "duplicate plugin names #{duplicates.inspect}" if duplicates.any?
|
|
106
|
+
|
|
107
|
+
display_name = presence(data.dig("interface", "displayName")) || presence(data["displayName"]) || name
|
|
108
|
+
Catalog.new(name: name, display_name: display_name, owner: data["owner"].is_a?(Hash) ? data["owner"] : {},
|
|
109
|
+
description: presence(data["description"] || metadata["description"]),
|
|
110
|
+
version: presence(data["version"] || metadata["version"]),
|
|
111
|
+
plugin_root: plugin_root, renames: renames(data["renames"]), plugins: plugins, shape: shape, path: path, raw: data)
|
|
112
|
+
rescue JSON::ParserError => e
|
|
113
|
+
raise InvalidManifestError, "marketplace file is not valid JSON (#{e.message[0, 80]})"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def entry(raw, index, plugin_root: nil)
|
|
117
|
+
raise InvalidManifestError, "plugins[#{index}] is not an object" unless raw.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
name = raw["name"].to_s.strip
|
|
120
|
+
raise InvalidManifestError, "plugins[#{index}] has no name" if name.empty?
|
|
121
|
+
raise InvalidManifestError, "plugin name #{name.inspect} is not kebab-case" unless name.match?(NAME_PATTERN)
|
|
122
|
+
|
|
123
|
+
Entry.new(name: name, display_name: presence(raw["displayName"]), description: presence(raw["description"]),
|
|
124
|
+
version: presence(raw["version"]), category: presence(raw["category"]), tags: Array(raw["tags"]).map(&:to_s),
|
|
125
|
+
strict: raw["strict"] != false, source: source(raw["source"], plugin_root: plugin_root),
|
|
126
|
+
overrides: raw.slice(*OVERRIDE_FIELDS), raw: raw)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def source(raw, plugin_root: nil)
|
|
130
|
+
case raw
|
|
131
|
+
when String then relative(raw, plugin_root: plugin_root)
|
|
132
|
+
when Hash then hash_source(raw, plugin_root: plugin_root)
|
|
133
|
+
else PluginSource.unsupported("source_missing")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def relative(path, plugin_root: nil)
|
|
138
|
+
cleaned = normalize_relative(path, allow_blank: true)
|
|
139
|
+
return PluginSource.unsupported("unsafe_path") if cleaned.nil?
|
|
140
|
+
|
|
141
|
+
if presence(plugin_root) && !path.to_s.start_with?("./", ".")
|
|
142
|
+
cleaned = [plugin_root, cleaned].reject { |part| part.to_s.empty? }.join("/")
|
|
143
|
+
end
|
|
144
|
+
PluginSource.new(kind: "relative", path: cleaned, repo: nil, ref: nil, sha: nil, url: nil, sha256: nil, reason: nil)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def github(repo, ref: nil, sha: nil, subdir: nil, raw: nil)
|
|
148
|
+
repo_source("github", normalize_repo(repo), REPO_PATTERN, ref: ref, sha: sha, subdir: subdir, raw: raw)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def gitlab(project, ref: nil, sha: nil, subdir: nil, raw: nil)
|
|
152
|
+
repo_source("gitlab", normalize_repo(project), PROJECT_PATTERN, ref: ref, sha: sha, subdir: subdir, raw: raw)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# `https://github.com/o/r[.git]`, `git@github.com:o/r.git`, `owner/repo`
|
|
156
|
+
# and their gitlab.com twins; any other host is unsupported.
|
|
157
|
+
def git_url(url, ref: nil, sha: nil, subdir: nil, raw: nil)
|
|
158
|
+
text = url.to_s.strip
|
|
159
|
+
return github(text, ref: ref, sha: sha, subdir: subdir, raw: raw) if text.match?(REPO_PATTERN) && !text.include?(":")
|
|
160
|
+
|
|
161
|
+
host, path = split_git_url(text)
|
|
162
|
+
case host
|
|
163
|
+
when "github.com" then github(path, ref: ref, sha: sha, subdir: subdir, raw: raw)
|
|
164
|
+
when "gitlab.com" then gitlab(path, ref: ref, sha: sha, subdir: subdir, raw: raw)
|
|
165
|
+
else PluginSource.unsupported(host ? "unsupported_host" : "invalid_url", raw)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def archive(url, sha256: nil, raw: nil)
|
|
170
|
+
reason = archive_problem(url, sha256)
|
|
171
|
+
return PluginSource.unsupported(reason, raw) if reason
|
|
172
|
+
|
|
173
|
+
PluginSource.new(kind: "archive", path: "", repo: nil, ref: nil, sha: nil, url: URI.parse(url.to_s.strip).to_s,
|
|
174
|
+
sha256: presence(sha256)&.downcase, reason: nil)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# A relative path inside the marketplace repo: "./x", "x", "./" and "."
|
|
178
|
+
# are accepted; anything escaping the root is nil.
|
|
179
|
+
def normalize_relative(path, allow_blank:)
|
|
180
|
+
cleaned = clean_relative(path.to_s.strip)
|
|
181
|
+
return cleaned unless cleaned == "."
|
|
182
|
+
|
|
183
|
+
allow_blank ? "" : nil
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# `owner/repo`, with a leading slash, trailing slash or `.git` removed.
|
|
187
|
+
def normalize_repo(value)
|
|
188
|
+
value.to_s.strip.delete_prefix("/").delete_suffix("/").delete_suffix(".git")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def presence(value)
|
|
192
|
+
text = value.to_s.strip
|
|
193
|
+
text.empty? ? nil : text
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# `{ "old" => "new" }`, keeping only well-formed names; a nil value marks a removal.
|
|
197
|
+
def renames(raw)
|
|
198
|
+
return {} unless raw.is_a?(Hash)
|
|
199
|
+
|
|
200
|
+
raw.each_with_object({}) do |(old, new), out|
|
|
201
|
+
valid_new = new.nil? || new.to_s.match?(NAME_PATTERN)
|
|
202
|
+
next unless old.to_s.match?(NAME_PATTERN) && valid_new
|
|
203
|
+
|
|
204
|
+
out[old.to_s] = new&.to_s
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
private
|
|
209
|
+
|
|
210
|
+
def hash_source(raw, plugin_root:)
|
|
211
|
+
kind = raw["source"].to_s
|
|
212
|
+
case kind
|
|
213
|
+
when "" then raw.key?("path") ? relative(raw["path"].to_s, plugin_root: plugin_root) : PluginSource.unsupported("source_missing", raw)
|
|
214
|
+
when "local" then relative(raw["path"].to_s, plugin_root: plugin_root)
|
|
215
|
+
when "github" then github(raw["repo"].to_s, ref: raw["ref"], sha: raw["sha"], raw: raw)
|
|
216
|
+
when "url" then git_url(raw["url"].to_s, ref: raw["ref"], sha: raw["sha"], raw: raw)
|
|
217
|
+
when "git-subdir" then git_url(raw["url"].to_s, ref: raw["ref"], sha: raw["sha"], subdir: raw["path"].to_s, raw: raw)
|
|
218
|
+
when "archive" then archive(raw["url"].to_s, sha256: raw["sha256"], raw: raw)
|
|
219
|
+
else PluginSource.unsupported("#{presence(kind) || "unknown"}_source", raw)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def repo_source(kind, repo, pattern, ref:, sha:, subdir:, raw:)
|
|
224
|
+
return PluginSource.unsupported("invalid_repo", raw) unless repo.match?(pattern)
|
|
225
|
+
|
|
226
|
+
pin = presence(sha)
|
|
227
|
+
return PluginSource.unsupported("invalid_sha", raw) if pin && !pin.match?(SHA_PATTERN)
|
|
228
|
+
|
|
229
|
+
path = presence(subdir) ? normalize_relative(subdir, allow_blank: false) : ""
|
|
230
|
+
return PluginSource.unsupported("unsafe_path", raw) if path.nil?
|
|
231
|
+
|
|
232
|
+
PluginSource.new(kind: kind, path: path, repo: repo, ref: presence(ref), sha: pin, url: nil, sha256: nil, reason: nil)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def split_git_url(text)
|
|
236
|
+
if (ssh = text.match(/\Agit@([\w.-]+):(.+)\z/))
|
|
237
|
+
return [ssh[1].downcase, ssh[2]]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
uri = URI.parse(text)
|
|
241
|
+
return [nil, nil] unless uri.is_a?(URI::HTTPS) && presence(uri.host)
|
|
242
|
+
|
|
243
|
+
[uri.host.downcase, uri.path.to_s]
|
|
244
|
+
rescue URI::InvalidURIError
|
|
245
|
+
[nil, nil]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def archive_problem(url, sha256)
|
|
249
|
+
uri = URI.parse(url.to_s.strip)
|
|
250
|
+
return "invalid_url" unless uri.is_a?(URI::HTTPS) && presence(uri.host)
|
|
251
|
+
return "zip_archive" unless uri.path.to_s.match?(/\.(tar\.gz|tgz)\z/i)
|
|
252
|
+
|
|
253
|
+
"invalid_sha256" if presence(sha256) && !sha256.to_s.match?(/\A[0-9a-f]{64}\z/i)
|
|
254
|
+
rescue URI::InvalidURIError
|
|
255
|
+
"invalid_url"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# nil when the path is absolute, escapes the root or carries unsafe bytes.
|
|
259
|
+
def clean_relative(text)
|
|
260
|
+
return nil if text.match?(%r{\A/|\\|\0})
|
|
261
|
+
|
|
262
|
+
cleaned = text.empty? ? "." : Pathname.new(text).cleanpath.to_s
|
|
263
|
+
cleaned unless cleaned == ".." || cleaned.start_with?("../")
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|