ukiryu 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/.github/workflows/test.yml +143 -0
- data/.gitignore +8 -0
- data/.gitmodules +3 -0
- data/Gemfile +17 -0
- data/README.adoc +295 -0
- data/Rakefile +8 -0
- data/lib/ukiryu/cli.rb +348 -0
- data/lib/ukiryu/errors.rb +30 -0
- data/lib/ukiryu/executor.rb +716 -0
- data/lib/ukiryu/platform.rb +93 -0
- data/lib/ukiryu/registry.rb +246 -0
- data/lib/ukiryu/schema_validator.rb +103 -0
- data/lib/ukiryu/shell/base.rb +79 -0
- data/lib/ukiryu/shell/bash.rb +60 -0
- data/lib/ukiryu/shell/cmd.rb +75 -0
- data/lib/ukiryu/shell/fish.rb +16 -0
- data/lib/ukiryu/shell/powershell.rb +60 -0
- data/lib/ukiryu/shell/sh.rb +16 -0
- data/lib/ukiryu/shell/zsh.rb +16 -0
- data/lib/ukiryu/shell.rb +164 -0
- data/lib/ukiryu/tool.rb +439 -0
- data/lib/ukiryu/type.rb +254 -0
- data/lib/ukiryu/version.rb +5 -0
- data/lib/ukiryu.rb +54 -0
- data/ukiryu.gemspec +33 -0
- metadata +72 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ukiryu
|
|
4
|
+
# Platform detection module
|
|
5
|
+
#
|
|
6
|
+
# Provides explicit platform detection with clear error messages.
|
|
7
|
+
# No automatic fallbacks - if platform cannot be determined, raises an error.
|
|
8
|
+
module Platform
|
|
9
|
+
class << self
|
|
10
|
+
# Detect the current platform
|
|
11
|
+
#
|
|
12
|
+
# @return [Symbol] :windows, :macos, or :linux
|
|
13
|
+
# @raise [UnsupportedPlatformError] if platform cannot be determined
|
|
14
|
+
def detect
|
|
15
|
+
if windows?
|
|
16
|
+
:windows
|
|
17
|
+
elsif macos?
|
|
18
|
+
:macos
|
|
19
|
+
elsif linux?
|
|
20
|
+
:linux
|
|
21
|
+
else
|
|
22
|
+
# Try to determine from RbConfig
|
|
23
|
+
host_os = RbConfig::CONFIG["host_os"]
|
|
24
|
+
case host_os
|
|
25
|
+
when /mswin|mingw|windows/i
|
|
26
|
+
:windows
|
|
27
|
+
when /darwin|mac os/i
|
|
28
|
+
:macos
|
|
29
|
+
when /linux/i
|
|
30
|
+
:linux
|
|
31
|
+
else
|
|
32
|
+
raise UnsupportedPlatformError, <<~ERROR
|
|
33
|
+
Unable to detect platform. Host OS: #{host_os}
|
|
34
|
+
|
|
35
|
+
Supported platforms: Windows, macOS, Linux
|
|
36
|
+
|
|
37
|
+
Please configure platform explicitly:
|
|
38
|
+
Ukiryu.configure do |config|
|
|
39
|
+
config.platform = :linux # or :macos, :windows
|
|
40
|
+
end
|
|
41
|
+
ERROR
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if running on Windows
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def windows?
|
|
50
|
+
Gem.win_platform? || RbConfig::CONFIG["host_os"] =~ /mswin|mingw|windows/i
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if running on macOS
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def macos?
|
|
57
|
+
RbConfig::CONFIG["host_os"] =~ /darwin|mac os/i
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if running on Linux
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def linux?
|
|
64
|
+
RbConfig::CONFIG["host_os"] =~ /linux/i
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Check if running on a Unix-like system (macOS or Linux)
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def unix?
|
|
71
|
+
macos? || linux?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get the PATH environment variable as an array
|
|
75
|
+
# Handles different PATH separators on Windows (;) vs Unix (:)
|
|
76
|
+
#
|
|
77
|
+
# @return [Array<String>] array of directory paths
|
|
78
|
+
def executable_search_paths
|
|
79
|
+
@executable_search_paths ||= begin
|
|
80
|
+
path_sep = windows? ? ";" : ":"
|
|
81
|
+
(ENV["PATH"] || "").split(path_sep)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Reset cached paths (primarily for testing)
|
|
86
|
+
#
|
|
87
|
+
# @api private
|
|
88
|
+
def reset_cache
|
|
89
|
+
@executable_search_paths = nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "find"
|
|
5
|
+
|
|
6
|
+
module Ukiryu
|
|
7
|
+
# YAML profile registry loader
|
|
8
|
+
#
|
|
9
|
+
# Loads tool definitions from YAML profiles in a registry directory.
|
|
10
|
+
class Registry
|
|
11
|
+
class << self
|
|
12
|
+
# Load all tool profiles from a registry directory
|
|
13
|
+
#
|
|
14
|
+
# @param path [String] the registry directory path
|
|
15
|
+
# @param options [Hash] loading options
|
|
16
|
+
# @option options [Boolean] :recursive search recursively (default: true)
|
|
17
|
+
# @option options [Boolean] :validate validate against schema (default: false)
|
|
18
|
+
# @return [Hash] loaded tools keyed by name
|
|
19
|
+
def load_from(path, options = {})
|
|
20
|
+
raise ProfileLoadError, "Registry path not found: #{path}" unless Dir.exist?(path)
|
|
21
|
+
|
|
22
|
+
tools = {}
|
|
23
|
+
recursive = options.fetch(:recursive, true)
|
|
24
|
+
|
|
25
|
+
pattern = recursive ? "**/*.yaml" : "*.yaml"
|
|
26
|
+
files = Dir.glob(File.join(path, "tools", pattern))
|
|
27
|
+
|
|
28
|
+
files.each do |file|
|
|
29
|
+
begin
|
|
30
|
+
profile = load_profile(file)
|
|
31
|
+
name = profile[:name]
|
|
32
|
+
tools[name] ||= []
|
|
33
|
+
tools[name] << profile
|
|
34
|
+
rescue => e
|
|
35
|
+
warn "Warning: Failed to load profile #{file}: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
tools
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Load a single profile file
|
|
43
|
+
#
|
|
44
|
+
# @param file [String] the path to the YAML file
|
|
45
|
+
# @return [Hash] the loaded profile
|
|
46
|
+
def load_profile(file)
|
|
47
|
+
content = File.read(file)
|
|
48
|
+
profile = YAML.safe_load(content, permitted_classes: [], permitted_symbols: [], aliases: true)
|
|
49
|
+
|
|
50
|
+
# Convert string keys to symbols for consistency
|
|
51
|
+
profile = symbolize_keys(profile)
|
|
52
|
+
|
|
53
|
+
# Resolve inheritance if present
|
|
54
|
+
resolve_inheritance(profile, file)
|
|
55
|
+
|
|
56
|
+
profile
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Load a specific tool by name
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] the tool name
|
|
62
|
+
# @param options [Hash] loading options
|
|
63
|
+
# @option options [String] :version specific version to load
|
|
64
|
+
# @option options [String] :registry_path path to registry
|
|
65
|
+
# @return [Hash, nil] the tool profile or nil if not found
|
|
66
|
+
def load_tool(name, options = {})
|
|
67
|
+
registry_path = options[:registry_path] || @default_registry_path
|
|
68
|
+
|
|
69
|
+
raise ProfileLoadError, "Registry path not configured" unless registry_path
|
|
70
|
+
|
|
71
|
+
# Try version-specific directory first
|
|
72
|
+
version = options[:version]
|
|
73
|
+
if version
|
|
74
|
+
file = File.join(registry_path, "tools", name, "#{version}.yaml")
|
|
75
|
+
return load_profile(file) if File.exist?(file)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Search in all matching files
|
|
79
|
+
pattern = File.join(registry_path, "tools", name, "*.yaml")
|
|
80
|
+
files = Dir.glob(pattern).sort
|
|
81
|
+
|
|
82
|
+
if files.empty?
|
|
83
|
+
# Try the old format (single file per tool)
|
|
84
|
+
file = File.join(registry_path, "tools", "#{name}.yaml")
|
|
85
|
+
return load_profile(file) if File.exist?(file)
|
|
86
|
+
return nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return the latest version if version not specified
|
|
90
|
+
if version.nil?
|
|
91
|
+
# Sort by version and return the newest
|
|
92
|
+
sorted_files = files.sort_by { |f| Gem::Version.new(File.basename(f, ".yaml")) }
|
|
93
|
+
load_profile(sorted_files.last)
|
|
94
|
+
else
|
|
95
|
+
# Find specific version
|
|
96
|
+
version_file = files.find { |f| File.basename(f, ".yaml") == version }
|
|
97
|
+
version_file ? load_profile(version_file) : nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Set the default registry path
|
|
102
|
+
#
|
|
103
|
+
# @param path [String] the default registry path
|
|
104
|
+
def default_registry_path=(path)
|
|
105
|
+
@default_registry_path = path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the default registry path
|
|
109
|
+
#
|
|
110
|
+
# @return [String, nil] the default registry path
|
|
111
|
+
def default_registry_path
|
|
112
|
+
@default_registry_path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get all available tool names
|
|
116
|
+
#
|
|
117
|
+
# @return [Array<String>] list of tool names
|
|
118
|
+
def tools
|
|
119
|
+
registry_path = @default_registry_path
|
|
120
|
+
return [] unless registry_path
|
|
121
|
+
|
|
122
|
+
tools_dir = File.join(registry_path, "tools")
|
|
123
|
+
return [] unless Dir.exist?(tools_dir)
|
|
124
|
+
|
|
125
|
+
# List all directories in tools/
|
|
126
|
+
Dir.glob(File.join(tools_dir, "*")).select do |path|
|
|
127
|
+
File.directory?(path)
|
|
128
|
+
end.map do |path|
|
|
129
|
+
File.basename(path)
|
|
130
|
+
end.sort
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Find the newest compatible version of a tool
|
|
134
|
+
#
|
|
135
|
+
# @param tool_name [String] the tool name
|
|
136
|
+
# @param options [Hash] search options
|
|
137
|
+
# @option options [String] :platform platform (default: auto-detect)
|
|
138
|
+
# @option options [String] :shell shell (default: auto-detect)
|
|
139
|
+
# @option options [String] :version_constraint version constraint
|
|
140
|
+
# @return [Hash, nil] the best matching profile or nil
|
|
141
|
+
def find_compatible_profile(tool_name, options = {})
|
|
142
|
+
profiles = load_tool_profiles(tool_name)
|
|
143
|
+
return nil if profiles.nil? || profiles.empty?
|
|
144
|
+
|
|
145
|
+
platform = options[:platform] || Platform.detect
|
|
146
|
+
shell = options[:shell] || Shell.detect
|
|
147
|
+
version = options[:version]
|
|
148
|
+
|
|
149
|
+
# Filter by platform and shell
|
|
150
|
+
candidates = profiles.select do |profile|
|
|
151
|
+
profile_platforms = profile[:platforms] || profile[:platform]
|
|
152
|
+
profile_shells = profile[:shells] || profile[:shell]
|
|
153
|
+
|
|
154
|
+
platform_match = profile_platforms.include?(platform) if profile_platforms
|
|
155
|
+
shell_match = profile_shells.include?(shell) if profile_shells
|
|
156
|
+
|
|
157
|
+
(platform_match || profile_platforms.nil?) && (shell_match || profile_shells.nil?)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Further filter by version if specified
|
|
161
|
+
if version && !candidates.empty?
|
|
162
|
+
constraint = Gem::Requirement.new(version)
|
|
163
|
+
candidates.select! do |profile|
|
|
164
|
+
profile_version = profile[:version]
|
|
165
|
+
next true unless profile_version
|
|
166
|
+
|
|
167
|
+
constraint.satisfied_by?(Gem::Version.new(profile_version))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Return the first matching profile (prefer newer versions)
|
|
172
|
+
candidates.first
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Load all profiles for a specific tool
|
|
178
|
+
def load_tool_profiles(name)
|
|
179
|
+
registry_path = @default_registry_path
|
|
180
|
+
return nil unless registry_path
|
|
181
|
+
|
|
182
|
+
pattern = File.join(registry_path, "tools", name, "*.yaml")
|
|
183
|
+
files = Dir.glob(pattern)
|
|
184
|
+
|
|
185
|
+
return nil if files.empty?
|
|
186
|
+
|
|
187
|
+
files.flat_map do |file|
|
|
188
|
+
begin
|
|
189
|
+
profile = load_profile(file)
|
|
190
|
+
profile[:_file_path] = file
|
|
191
|
+
profile
|
|
192
|
+
rescue => e
|
|
193
|
+
warn "Warning: Failed to load profile #{file}: #{e.message}"
|
|
194
|
+
[]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Resolve profile inheritance
|
|
200
|
+
#
|
|
201
|
+
# @param profile [Hash] the profile to resolve
|
|
202
|
+
# @param file_path [String] the path to the profile file
|
|
203
|
+
def resolve_inheritance(profile, file_path)
|
|
204
|
+
return unless profile[:profiles]
|
|
205
|
+
|
|
206
|
+
base_dir = File.dirname(file_path)
|
|
207
|
+
|
|
208
|
+
profile[:profiles].each do |p|
|
|
209
|
+
next unless p[:inherits]
|
|
210
|
+
|
|
211
|
+
parent_profile = find_parent_profile(p[:inherits], profile[:profiles], base_dir)
|
|
212
|
+
if parent_profile
|
|
213
|
+
# Merge parent into child (child takes precedence)
|
|
214
|
+
p.merge!(parent_profile) { |_, child_val, _| child_val }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Find a parent profile within the same file
|
|
220
|
+
def find_parent_profile(name, profiles, _base_dir)
|
|
221
|
+
profiles.find { |p| p[:name] == name }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Recursively convert string keys to symbols
|
|
225
|
+
#
|
|
226
|
+
# @param hash [Hash] the hash to convert
|
|
227
|
+
# @return [Hash] the hash with symbolized keys
|
|
228
|
+
def symbolize_keys(hash)
|
|
229
|
+
return hash unless hash.is_a?(Hash)
|
|
230
|
+
|
|
231
|
+
hash.transform_keys do |key|
|
|
232
|
+
key.is_a?(String) ? key.to_sym : key
|
|
233
|
+
end.transform_values do |value|
|
|
234
|
+
case value
|
|
235
|
+
when Hash
|
|
236
|
+
symbolize_keys(value)
|
|
237
|
+
when Array
|
|
238
|
+
value.map { |v| v.is_a?(Hash) ? symbolize_keys(v) : v }
|
|
239
|
+
else
|
|
240
|
+
value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
begin
|
|
5
|
+
require "json-schema"
|
|
6
|
+
rescue LoadError
|
|
7
|
+
# json-schema is optional - only needed for schema validation
|
|
8
|
+
end
|
|
9
|
+
require "yaml"
|
|
10
|
+
|
|
11
|
+
module Ukiryu
|
|
12
|
+
# Schema validator for YAML tool profiles
|
|
13
|
+
#
|
|
14
|
+
# Validates tool profile YAML files against JSON Schema definitions.
|
|
15
|
+
class SchemaValidator
|
|
16
|
+
class << self
|
|
17
|
+
# Validate a tool profile against the schema
|
|
18
|
+
#
|
|
19
|
+
# @param profile [Hash] the loaded profile hash
|
|
20
|
+
# @param options [Hash] validation options
|
|
21
|
+
# @option options [String] :schema_path path to schema file
|
|
22
|
+
# @option options [Boolean] :strict whether to use strict validation
|
|
23
|
+
# @return [Array<String>] list of validation errors (empty if valid)
|
|
24
|
+
def validate_profile(profile, options = {})
|
|
25
|
+
# Check if json-schema gem is available
|
|
26
|
+
unless defined?(JSON::Validator)
|
|
27
|
+
return ["json-schema gem not installed. Add 'json-schema' to Gemfile for schema validation."]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
errors = []
|
|
31
|
+
|
|
32
|
+
# Load the schema
|
|
33
|
+
schema = load_schema(options[:schema_path])
|
|
34
|
+
return ["Failed to load schema"] unless schema
|
|
35
|
+
|
|
36
|
+
# Validate against JSON schema
|
|
37
|
+
begin
|
|
38
|
+
# JSON Schema library expects the data to be a hash
|
|
39
|
+
validation_errors = JSON::Validator.fully_validate(schema, profile, strict: options[:strict] || false)
|
|
40
|
+
|
|
41
|
+
# Convert errors to readable format
|
|
42
|
+
validation_errors.each do |error|
|
|
43
|
+
errors << format_schema_error(error)
|
|
44
|
+
end
|
|
45
|
+
rescue JSON::Schema::ValidationError => e
|
|
46
|
+
errors << "Schema validation error: #{e.message}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
errors
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Load and parse the JSON schema
|
|
53
|
+
#
|
|
54
|
+
# @param path [String, nil] path to schema file (optional)
|
|
55
|
+
# @return [Hash] the parsed schema
|
|
56
|
+
def load_schema(path = nil)
|
|
57
|
+
schema_path = path || default_schema_path
|
|
58
|
+
return nil unless schema_path && File.exist?(schema_path)
|
|
59
|
+
|
|
60
|
+
schema_content = File.read(schema_path)
|
|
61
|
+
parsed = YAML.safe_load(schema_content)
|
|
62
|
+
|
|
63
|
+
# Convert YAML schema to JSON schema format
|
|
64
|
+
# YAML schema uses $schema, definitions, etc.
|
|
65
|
+
convert_yaml_schema_to_json(parsed)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Get the default schema path
|
|
69
|
+
#
|
|
70
|
+
# @return [String, nil] the default schema path
|
|
71
|
+
def default_schema_path
|
|
72
|
+
# Schema is in the sibling 'schema' directory at the same level as the gem
|
|
73
|
+
# From lib/ukiryu/, we go up to gem root, then to sibling schema/
|
|
74
|
+
gem_root = File.expand_path("../..", __dir__) # ukiryu gem root
|
|
75
|
+
schema_dir = File.expand_path("../schema", gem_root) # src/ukiryu/schema/
|
|
76
|
+
schema_file = File.join(schema_dir, "tool-profile.schema.yaml")
|
|
77
|
+
return schema_file if File.exist?(schema_file)
|
|
78
|
+
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Convert YAML schema format to JSON schema format
|
|
85
|
+
#
|
|
86
|
+
# @param yaml_schema [Hash] the parsed YAML schema
|
|
87
|
+
# @return [Hash] the converted JSON schema
|
|
88
|
+
def convert_yaml_schema_to_json(yaml_schema)
|
|
89
|
+
# The YAML schema format is very similar to JSON schema
|
|
90
|
+
# Just need to ensure it has the right structure
|
|
91
|
+
yaml_schema
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Format a schema error for readability
|
|
95
|
+
#
|
|
96
|
+
# @param error [String] the raw error message
|
|
97
|
+
# @return [String] the formatted error
|
|
98
|
+
def format_schema_error(error)
|
|
99
|
+
error
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ukiryu
|
|
4
|
+
module Shell
|
|
5
|
+
# Base class for shell implementations
|
|
6
|
+
#
|
|
7
|
+
# Each shell implementation must provide:
|
|
8
|
+
# - name: Symbol identifying the shell
|
|
9
|
+
# - escape(string): Escape a string for this shell
|
|
10
|
+
# - quote(string): Quote an argument for this shell
|
|
11
|
+
# - format_path(path): Format a file path for this shell
|
|
12
|
+
# - env_var(name): Format an environment variable reference
|
|
13
|
+
# - join(executable, *args): Join executable and arguments into a command line
|
|
14
|
+
class Base
|
|
15
|
+
# Identify the shell
|
|
16
|
+
#
|
|
17
|
+
# @return [Symbol] the shell name
|
|
18
|
+
def name
|
|
19
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Escape a string for this shell
|
|
23
|
+
#
|
|
24
|
+
# @param string [String] the string to escape
|
|
25
|
+
# @return [String] the escaped string
|
|
26
|
+
def escape(string)
|
|
27
|
+
raise NotImplementedError, "#{self.class} must implement #escape"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Quote an argument for this shell
|
|
31
|
+
#
|
|
32
|
+
# @param string [String] the string to quote
|
|
33
|
+
# @return [String] the quoted string
|
|
34
|
+
def quote(string)
|
|
35
|
+
raise NotImplementedError, "#{self.class} must implement #quote"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Format a file path for this shell
|
|
39
|
+
#
|
|
40
|
+
# @param path [String] the file path
|
|
41
|
+
# @return [String] the formatted path
|
|
42
|
+
def format_path(path)
|
|
43
|
+
path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Format an environment variable reference
|
|
47
|
+
#
|
|
48
|
+
# @param name [String] the variable name
|
|
49
|
+
# @return [String] the formatted reference
|
|
50
|
+
def env_var(name)
|
|
51
|
+
raise NotImplementedError, "#{self.class} must implement #env_var"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Join executable and arguments into a command line
|
|
55
|
+
#
|
|
56
|
+
# @param executable [String] the executable path
|
|
57
|
+
# @param args [Array<String>] the arguments
|
|
58
|
+
# @return [String] the complete command line
|
|
59
|
+
def join(executable, *args)
|
|
60
|
+
raise NotImplementedError, "#{self.class} must implement #join"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Format environment variables for command execution
|
|
64
|
+
#
|
|
65
|
+
# @param env_vars [Hash] environment variables to set
|
|
66
|
+
# @return [Hash] formatted environment variables
|
|
67
|
+
def format_environment(env_vars)
|
|
68
|
+
env_vars
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get headless environment variables (e.g., DISPLAY="" for Unix)
|
|
72
|
+
#
|
|
73
|
+
# @return [Hash] environment variables for headless operation
|
|
74
|
+
def headless_environment
|
|
75
|
+
{}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Ukiryu
|
|
6
|
+
module Shell
|
|
7
|
+
# Bash shell implementation
|
|
8
|
+
#
|
|
9
|
+
# Bash uses single quotes for literal strings and backslash for escaping.
|
|
10
|
+
# Environment variables are referenced with $VAR syntax.
|
|
11
|
+
class Bash < Base
|
|
12
|
+
def name
|
|
13
|
+
:bash
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Escape a string for Bash
|
|
17
|
+
# Single quotes are literal (no escaping inside), so we end the quote,
|
|
18
|
+
# add an escaped quote, and restart the quote.
|
|
19
|
+
#
|
|
20
|
+
# @param string [String] the string to escape
|
|
21
|
+
# @return [String] the escaped string
|
|
22
|
+
def escape(string)
|
|
23
|
+
string.to_s.gsub("'") { "'\\''" }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Quote an argument for Bash
|
|
27
|
+
# Uses single quotes for literal strings
|
|
28
|
+
#
|
|
29
|
+
# @param string [String] the string to quote
|
|
30
|
+
# @return [String] the quoted string
|
|
31
|
+
def quote(string)
|
|
32
|
+
"'#{escape(string)}'"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Format an environment variable reference
|
|
36
|
+
#
|
|
37
|
+
# @param name [String] the variable name
|
|
38
|
+
# @return [String] the formatted reference ($VAR)
|
|
39
|
+
def env_var(name)
|
|
40
|
+
"$#{name}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Join executable and arguments into a command line
|
|
44
|
+
#
|
|
45
|
+
# @param executable [String] the executable path
|
|
46
|
+
# @param args [Array<String>] the arguments
|
|
47
|
+
# @return [String] the complete command line
|
|
48
|
+
def join(executable, *args)
|
|
49
|
+
[executable, *args.map { |a| quote(a) }].join(" ")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get headless environment (disable DISPLAY on Unix)
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash] environment variables for headless operation
|
|
55
|
+
def headless_environment
|
|
56
|
+
{ "DISPLAY" => "" }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Ukiryu
|
|
6
|
+
module Shell
|
|
7
|
+
# Windows cmd.exe shell implementation
|
|
8
|
+
#
|
|
9
|
+
# cmd.exe uses caret (^) as the escape character and double quotes
|
|
10
|
+
# for strings containing spaces. Environment variables use %VAR% syntax.
|
|
11
|
+
class Cmd < Base
|
|
12
|
+
def name
|
|
13
|
+
:cmd
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Escape a string for cmd.exe
|
|
17
|
+
# Caret is the escape character for special characters: % ^ < > & |
|
|
18
|
+
#
|
|
19
|
+
# @param string [String] the string to escape
|
|
20
|
+
# @return [String] the escaped string
|
|
21
|
+
def escape(string)
|
|
22
|
+
string.to_s.gsub(/[%^<>&|]/) { "^$&" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Quote an argument for cmd.exe
|
|
26
|
+
# Uses double quotes for strings with spaces
|
|
27
|
+
#
|
|
28
|
+
# @param string [String] the string to quote
|
|
29
|
+
# @return [String] the quoted string
|
|
30
|
+
def quote(string)
|
|
31
|
+
if string.to_s =~ /[ \t]/
|
|
32
|
+
# Contains whitespace, use double quotes
|
|
33
|
+
# Note: cmd.exe doesn't escape quotes inside double quotes the same way
|
|
34
|
+
"\"#{string}\""
|
|
35
|
+
else
|
|
36
|
+
# No whitespace, escape special characters
|
|
37
|
+
escape(string)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Format a file path for cmd.exe
|
|
42
|
+
# Convert forward slashes to backslashes
|
|
43
|
+
#
|
|
44
|
+
# @param path [String] the file path
|
|
45
|
+
# @return [String] the formatted path
|
|
46
|
+
def format_path(path)
|
|
47
|
+
path.to_s.gsub("/", "\\")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Format an environment variable reference
|
|
51
|
+
#
|
|
52
|
+
# @param name [String] the variable name
|
|
53
|
+
# @return [String] the formatted reference (%VAR%)
|
|
54
|
+
def env_var(name)
|
|
55
|
+
"%#{name}%"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Join executable and arguments into a command line
|
|
59
|
+
#
|
|
60
|
+
# @param executable [String] the executable path
|
|
61
|
+
# @param args [Array<String>] the arguments
|
|
62
|
+
# @return [String] the complete command line
|
|
63
|
+
def join(executable, *args)
|
|
64
|
+
[executable, *args.map { |a| quote(a) }].join(" ")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# cmd.exe doesn't need DISPLAY variable
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash] empty hash (no headless environment needed)
|
|
70
|
+
def headless_environment
|
|
71
|
+
{}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|