rakit 0.1.4 → 0.1.6

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.
data/lib/rakit/file.rb ADDED
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "generated/rakit.file_pb"
5
+
6
+ module Rakit
7
+ # Protobuf-first file operations (list directory, copy file). See specs/005-file-ops/contracts/ruby-api.md.
8
+ module File
9
+ class << self
10
+ # @param request [Rakit::File::ListRequest]
11
+ # @return [Rakit::File::ListResult]
12
+ def list(request)
13
+ dir_path = normalize_path(request.directory.to_s)
14
+ if dir_path.nil?
15
+ return error_list_result("Empty or invalid directory path", 1)
16
+ end
17
+ unless ::File.exist?(dir_path)
18
+ return error_list_result("Directory does not exist: #{dir_path}", 1)
19
+ end
20
+ unless ::File.directory?(dir_path)
21
+ return error_list_result("Not a directory: #{dir_path}", 1)
22
+ end
23
+
24
+ include_hidden = request.include_hidden
25
+ follow_symlinks = request.config&.follow_symlinks == true
26
+ entries = request.recursive ? list_entries_recursive(dir_path, include_hidden, follow_symlinks) : list_entries_one_dir(dir_path, include_hidden, follow_symlinks)
27
+
28
+ ListResult.new(
29
+ success: true,
30
+ message: "",
31
+ entries: entries,
32
+ exit_code: 0,
33
+ stderr: ""
34
+ )
35
+ rescue => e
36
+ error_list_result("#{e.message}", 1)
37
+ end
38
+
39
+ # @param request [Rakit::File::CopyRequest]
40
+ # @return [Rakit::File::CopyResult]
41
+ def copy(request)
42
+ config = request.config || FileConfig.new
43
+ src = normalize_path(request.source.to_s)
44
+ dest_raw = normalize_path(request.destination.to_s)
45
+
46
+ if src.nil? || dest_raw.nil?
47
+ return error_copy_result(request.source.to_s, request.destination.to_s, "Empty or invalid path", 1)
48
+ end
49
+ unless ::File.exist?(src)
50
+ return error_copy_result(src, dest_raw, "Source does not exist: #{src}", 1)
51
+ end
52
+ if ::File.directory?(src)
53
+ return error_copy_result(src, dest_raw, "Source is a directory (MVP: file only)", 1)
54
+ end
55
+
56
+ dest_path = resolve_copy_destination(src, dest_raw)
57
+ parent = ::File.dirname(dest_path)
58
+
59
+ unless ::File.directory?(parent)
60
+ if config.create_directories
61
+ FileUtils.mkdir_p(parent)
62
+ else
63
+ return error_copy_result(src, dest_path, "Parent directory does not exist: #{parent}", 1)
64
+ end
65
+ end
66
+
67
+ if ::File.file?(dest_path) && !config.overwrite
68
+ return error_copy_result(src, dest_path, "Destination file exists (use overwrite to replace)", 1)
69
+ end
70
+
71
+ bytes = perform_copy(src, dest_path, config)
72
+ CopyResult.new(
73
+ success: true,
74
+ message: "",
75
+ source: src,
76
+ destination: dest_path,
77
+ exit_code: 0,
78
+ stderr: "",
79
+ bytes_copied: bytes || 0
80
+ )
81
+ rescue => e
82
+ error_copy_result(request.source.to_s, request.destination.to_s, e.message, 1)
83
+ end
84
+
85
+ private
86
+
87
+ # T004: Path normalization. Returns expanded absolute path or nil if nil/empty.
88
+ def normalize_path(path, base_dir = nil)
89
+ return nil if path.nil?
90
+ s = path.to_s.strip
91
+ return nil if s.empty?
92
+ base = base_dir || Dir.pwd
93
+ ::File.expand_path(s, base)
94
+ end
95
+
96
+ def error_list_result(message, exit_code)
97
+ ListResult.new(success: false, message: message, entries: [], exit_code: exit_code, stderr: message)
98
+ end
99
+
100
+ def error_copy_result(source, destination, message, exit_code)
101
+ CopyResult.new(success: false, message: message, source: source.to_s, destination: destination.to_s, exit_code: exit_code, stderr: message)
102
+ end
103
+
104
+ # T005: List entries for one directory; sort by name; filter hidden; best-effort metadata.
105
+ def list_entries_one_dir(dir_path, include_hidden, follow_symlinks)
106
+ names = Dir.children(dir_path)
107
+ names.reject! { |n| n.start_with?(".") } unless include_hidden
108
+ names.sort!
109
+ names.map { |name| file_entry_for(::File.join(dir_path, name), name, follow_symlinks) }
110
+ end
111
+
112
+ def file_entry_for(abs_path, name, follow_symlinks)
113
+ stat = follow_symlinks ? (::File.stat(abs_path) rescue nil) : (::File.lstat(abs_path) rescue nil)
114
+ is_symlink = ::File.symlink?(abs_path)
115
+ is_dir = stat ? stat.directory? : false
116
+ size_bytes = (stat && stat.file?) ? (stat.size rescue 0) : 0
117
+ modified_unix_ms = stat ? (stat.mtime.to_f * 1000).to_i : 0
118
+ FileEntry.new(
119
+ path: abs_path,
120
+ name: name,
121
+ is_directory: is_dir,
122
+ is_symlink: is_symlink,
123
+ size_bytes: size_bytes,
124
+ modified_unix_ms: modified_unix_ms
125
+ )
126
+ rescue
127
+ FileEntry.new(path: abs_path, name: name, is_directory: false, is_symlink: false, size_bytes: 0, modified_unix_ms: 0)
128
+ end
129
+
130
+ # T006: Depth-first recursive list; children at each level sorted by name.
131
+ def list_entries_recursive(dir_path, include_hidden, follow_symlinks)
132
+ entries = list_entries_one_dir(dir_path, include_hidden, follow_symlinks)
133
+ result = []
134
+ entries.each do |entry|
135
+ result << entry
136
+ if entry.is_directory
137
+ result.concat(list_entries_recursive(entry.path, include_hidden, follow_symlinks))
138
+ end
139
+ end
140
+ result
141
+ end
142
+
143
+ # Resolve destination: if existing directory, copy into it with basename(source); else file path.
144
+ def resolve_copy_destination(source, destination)
145
+ return ::File.join(destination, ::File.basename(source)) if ::File.exist?(destination) && ::File.directory?(destination)
146
+ destination
147
+ end
148
+
149
+ # T007: Atomic copy where possible (temp then rename); respect follow_symlinks for source.
150
+ def perform_copy(source, dest_path, config)
151
+ if !config.follow_symlinks && ::File.symlink?(source)
152
+ # Copy symlink itself: create new symlink with same target
153
+ target = ::File.readlink(source)
154
+ ::File.delete(dest_path) if ::File.exist?(dest_path)
155
+ ::File.symlink(target, dest_path)
156
+ return 0 # best-effort bytes
157
+ end
158
+
159
+ content = ::File.binread(source)
160
+ dest_dir = ::File.dirname(dest_path)
161
+ temp_path = ::File.join(dest_dir, ".rakit_copy_#{Process.pid}_#{object_id}_#{::File.basename(dest_path)}")
162
+ bytes = nil
163
+ begin
164
+ ::File.write(temp_path, content)
165
+ bytes = content.bytesize
166
+ ::File.rename(temp_path, dest_path)
167
+ rescue Errno::EXDEV, Errno::EPERM
168
+ # Cross-filesystem or rename not allowed: fall back to overwrite
169
+ ::File.write(dest_path, content)
170
+ bytes = content.bytesize
171
+ ::File.delete(temp_path) if ::File.exist?(temp_path)
172
+ ensure
173
+ ::File.delete(temp_path) if ::File.exist?(temp_path)
174
+ end
175
+ bytes
176
+ rescue => e
177
+ raise "Failed to copy: #{e.message}"
178
+ end
179
+ end
180
+ end
181
+ end
data/lib/rakit/gem.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "json"
5
+ require "open3"
4
6
  require "rubygems/package"
5
7
 
6
8
  module Rakit
@@ -11,29 +13,29 @@ module Rakit
11
13
  FileUtils.mkdir_p(out_dir)
12
14
  gem_file = ::Gem::Package.build(spec)
13
15
  FileUtils.mv(gem_file, out_dir)
14
- File.join(out_dir, gem_file)
16
+ ::File.join(out_dir, gem_file)
15
17
  end
16
18
 
17
19
  # Publish the gem to rubygems.org. Loads the gemspec from gemspec_path and
18
20
  # expects the .gem file in dirname(gemspec_path)/artifacts/. Run package first.
19
21
  def self.publish(gemspec_path)
20
- path = File.expand_path(gemspec_path)
22
+ path = ::File.expand_path(gemspec_path)
21
23
  spec = ::Gem::Specification.load(path)
22
- out_dir = File.join(File.dirname(path), "artifacts")
23
- gem_path = File.join(out_dir, "#{spec.full_name}.gem")
24
+ out_dir = ::File.join(::File.dirname(path), "artifacts")
25
+ gem_path = ::File.join(out_dir, "#{spec.full_name}.gem")
24
26
  push(gem_path)
25
27
  end
26
28
 
27
29
  # Bump the last digit of the version in the gemspec file (e.g. "0.1.0" -> "0.1.1").
28
30
  # Writes the file in place. Returns the new version string.
29
31
  def self.bump(gemspec_path)
30
- content = File.read(gemspec_path)
32
+ content = ::File.read(gemspec_path)
31
33
  content.sub!(/^(\s*s\.version\s*=\s*["'])([\d.]+)(["'])/) do
32
34
  segs = Regexp.last_match(2).split(".")
33
35
  segs[-1] = (segs[-1].to_i + 1).to_s
34
36
  "#{Regexp.last_match(1)}#{segs.join(".")}#{Regexp.last_match(3)}"
35
37
  end or raise "No s.version line found in #{gemspec_path}"
36
- File.write(gemspec_path, content)
38
+ ::File.write(gemspec_path, content)
37
39
  content[/s\.version\s*=\s*["']([^"']+)["']/, 1]
38
40
  end
39
41
 
@@ -41,12 +43,11 @@ module Rakit
41
43
  # published, warns and returns without pushing. Raises if the file is missing
42
44
  # or if gem push fails.
43
45
  def self.push(gem_path)
44
- raise "Gem not found: #{gem_path}. Run rake package first." unless File.file?(gem_path)
46
+ raise "Gem not found: #{gem_path}. Run rake package first." unless ::File.file?(gem_path)
45
47
 
46
- base = File.basename(gem_path, ".gem")
47
- parts = base.split("-")
48
- version = parts.pop
49
- name = parts.join("-")
48
+ base = ::File.basename(gem_path, ".gem")
49
+ name, version = parse_gem_basename(base)
50
+ raise "Could not parse name/version from #{base}.gem" unless name && version
50
51
 
51
52
  if version_published?(name, version)
52
53
  warn "publish: Version #{version} of #{name} is already published on rubygems.org. Skipping push. Bump the version in the gemspec to publish again."
@@ -57,14 +58,86 @@ module Rakit
57
58
  raise "gem push failed" unless success
58
59
  end
59
60
 
61
+ # Parse "name-version" basename (no .gem) into [name, version].
62
+ # Version is the last hyphen-separated segment that looks like a version (e.g. 0.1.5).
63
+ def self.parse_gem_basename(base)
64
+ # Match name (may contain hyphens) and version (digits and dots, optional pre-release suffix).
65
+ m = base.match(/\A(.+)-(\d+(?:\.\d+)*(?:\.\w+)?)\z/)
66
+ m ? [m[1], m[2]] : nil
67
+ end
68
+
60
69
  def self.version_published?(name, version)
70
+ begin
71
+ return true if version_published_gem_list?(name, version)
72
+ rescue StandardError
73
+ # try API fallbacks
74
+ end
75
+ begin
76
+ return true if version_published_v2?(name, version)
77
+ rescue StandardError
78
+ # try v1 fallback
79
+ end
80
+ begin
81
+ return true if version_published_v1?(name, version)
82
+ rescue StandardError
83
+ nil
84
+ end
85
+ false
86
+ end
87
+
88
+ # Run `gem list NAME --remote` and check if version appears in the output.
89
+ def self.version_published_gem_list?(name, version)
90
+ out, err, status = Open3.capture3("gem", "list", name, "--remote")
91
+ return false unless status.success?
92
+ # Output format: "name (1.0.0, 0.9.0)" or "name (1.0.0)"
93
+ combined = "#{out}#{err}"
94
+ combined.each_line do |line|
95
+ next unless line.include?(name)
96
+ if line =~ /\s*#{Regexp.escape(name)}\s*\((.*)\)/
97
+ versions = Regexp.last_match(1).split(",").map(&:strip)
98
+ return true if versions.include?(version)
99
+ end
100
+ end
101
+ false
102
+ end
103
+
104
+ # GET /api/v2/rubygems/{name}/versions/{version}.json (follows redirects)
105
+ def self.version_published_v2?(name, version)
61
106
  require "net/http"
62
107
  require "uri"
63
108
  uri = URI("https://rubygems.org/api/v2/rubygems/#{URI::DEFAULT_PARSER.escape(name)}/versions/#{URI::DEFAULT_PARSER.escape(version)}.json")
64
- response = Net::HTTP.get_response(uri)
109
+ response = http_get_following_redirects(uri)
65
110
  response.is_a?(Net::HTTPSuccess)
66
- rescue StandardError
67
- false
111
+ end
112
+
113
+ # GET /api/v1/versions/{name}.json and check if version is in the list
114
+ def self.version_published_v1?(name, version)
115
+ require "net/http"
116
+ require "json"
117
+ require "uri"
118
+ uri = URI("https://rubygems.org/api/v1/versions/#{URI::DEFAULT_PARSER.escape(name)}.json")
119
+ response = http_get_following_redirects(uri)
120
+ return false unless response.is_a?(Net::HTTPSuccess)
121
+ list = JSON.parse(response.body)
122
+ list.is_a?(Array) && list.any? { |h| h["number"] == version }
123
+ end
124
+
125
+ def self.http_get_following_redirects(uri, limit: 5)
126
+ raise ArgumentError, "redirect limit exceeded" if limit <= 0
127
+ require "net/http"
128
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 10) do |http|
129
+ request = Net::HTTP::Get.new(uri)
130
+ request["User-Agent"] = "rakit (https://rubygems.org/gems/rakit)"
131
+ http.request(request)
132
+ end
133
+ case response
134
+ when Net::HTTPRedirection
135
+ location = response["location"]
136
+ next_uri = location.match?(/\Ahttps?:\/\//) ? URI(location) : URI.join(uri, location)
137
+ http_get_following_redirects(next_uri, limit: limit - 1)
138
+ else
139
+ response
140
+ end
68
141
  end
69
142
  end
70
143
  end
data/lib/rakit/git.rb CHANGED
@@ -4,12 +4,18 @@ module Rakit
4
4
  module Git
5
5
  # Sync the current directory with the remote (git pull, then git push).
6
6
  # Runs from Dir.pwd. Raises if not a git repo or if pull/push fails.
7
+ # If the current branch has no remote tracking branch, pull and push are skipped (no error).
7
8
  def self.sync(dir = nil)
8
9
  require_relative "shell"
9
- target = dir ? File.expand_path(dir) : Dir.pwd
10
- raise "Not a git repository: #{target}" unless File.directory?(File.join(target, ".git"))
10
+ target = dir ? ::File.expand_path(dir) : Dir.pwd
11
+ raise "Not a git repository: #{target}" unless ::File.directory?(::File.join(target, ".git"))
11
12
 
12
13
  Dir.chdir(target) do
14
+ check = Rakit::Shell.run("git rev-parse --abbrev-ref @{u}")
15
+ if check.exit_status != 0
16
+ # No upstream configured for current branch; skip sync
17
+ return
18
+ end
13
19
  result = Rakit::Shell.run("git pull")
14
20
  raise "git pull failed" unless result.exit_status == 0
15
21
  result = Rakit::Shell.run("git push")
@@ -22,8 +28,8 @@ module Rakit
22
28
  # Raises if not a git repo or if add fails.
23
29
  def self.integrate(commit_message = nil, dir = nil)
24
30
  require_relative "shell"
25
- target = dir ? File.expand_path(dir) : Dir.pwd
26
- raise "Not a git repository: #{target}" unless File.directory?(File.join(target, ".git"))
31
+ target = dir ? ::File.expand_path(dir) : Dir.pwd
32
+ raise "Not a git repository: #{target}" unless ::File.directory?(::File.join(target, ".git"))
27
33
 
28
34
  message = commit_message || "Integrate"
29
35
  Dir.chdir(target) do
data/lib/rakit/hugo.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Rakit
6
+ # Build Hugo static sites from source. Used by publish_docs and tests.
7
+ # Contract: specs/008-hugo-docs-site/contracts/ruby-api.md
8
+ module Hugo
9
+ class << self
10
+ attr_accessor :hugo_path
11
+
12
+ def hugo_path
13
+ @hugo_path ||= "hugo"
14
+ end
15
+
16
+ # @param site_dir [String] path to Hugo source (must exist and be a directory)
17
+ # @param out_dir [String] path for build output
18
+ # @return [true] on success
19
+ # @return [false] on failure (Hugo not found, build failed, or invalid site_dir)
20
+ def build(site_dir:, out_dir:)
21
+ site_dir = ::File.expand_path(site_dir)
22
+ out_dir = ::File.expand_path(out_dir)
23
+ return false unless ::File.directory?(site_dir)
24
+ return false if ::File.file?(site_dir)
25
+
26
+ FileUtils.mkdir_p(out_dir)
27
+ success = system(hugo_path, "-s", site_dir, "-d", out_dir, out: $stdout, err: $stderr)
28
+ return false unless success
29
+ return false unless ::File.directory?(out_dir) && (Dir.entries(out_dir) - %w[. ..]).any?
30
+
31
+ true
32
+ end
33
+
34
+ # @param site_dir [String] path to check
35
+ # @return [Boolean] true if config.toml or config.yaml exists under site_dir
36
+ def valid_site?(site_dir)
37
+ dir = ::File.expand_path(site_dir)
38
+ return false unless ::File.directory?(dir)
39
+ ::File.file?(::File.join(dir, "config.toml")) || ::File.file?(::File.join(dir, "config.yaml"))
40
+ end
41
+ end
42
+ end
43
+ end
@@ -5,19 +5,21 @@ require "fileutils"
5
5
  module Rakit
6
6
  module Protobuf
7
7
  # Generate Ruby from .proto files.
8
- # proto_dir: directory containing .proto files (and -I root)
9
- # ruby_out: directory for generated *_pb.rb files
8
+ # proto_dir: directory containing .proto files (and -I root); may be relative to base_dir
9
+ # ruby_out: directory for generated *_pb.rb files; may be relative to base_dir
10
+ # base_dir: directory used to expand relative proto_dir and ruby_out (default: Dir.pwd)
10
11
  # protoc: executable name/path (default: "protoc")
11
12
  # Returns ruby_out if generation ran, nil if no proto files found.
12
- def self.generate(proto_dir:, ruby_out:, protoc: "protoc")
13
+ def self.generate(proto_dir:, ruby_out:, base_dir: Dir.pwd, protoc: "protoc")
14
+ expanded_proto_dir = File.expand_path(proto_dir, base_dir)
15
+ expanded_ruby_out = File.expand_path(ruby_out, base_dir)
13
16
  # output, in grey, " generating code from .proto files..."
14
17
  puts "\e[30m generating code from .proto files...\e[0m"
15
- expanded_proto_dir = File.expand_path(proto_dir)
16
18
  proto_files = Dir[File.join(expanded_proto_dir, "**", "*.proto")]
17
19
  return nil if proto_files.empty?
18
20
 
19
- FileUtils.mkdir_p(ruby_out)
20
- args = ["-I", expanded_proto_dir, "--ruby_out=#{ruby_out}", *proto_files]
21
+ FileUtils.mkdir_p(expanded_ruby_out)
22
+ args = ["-I", expanded_proto_dir, "--ruby_out=#{expanded_ruby_out}", *proto_files]
21
23
  system(protoc, *args) or raise "protoc failed"
22
24
 
23
25
  # output a green checkmark and the command that was run
@@ -25,14 +27,14 @@ module Rakit
25
27
  # output that the files were generated
26
28
  #puts " Generated #{proto_files.size} files in #{ruby_out}"
27
29
  # output the files that were generated (all files in the ruby_out directory), once per line
28
- ruby_out_files = Dir[File.join(ruby_out, "**", "*_pb.rb")]
30
+ ruby_out_files = Dir[File.join(expanded_ruby_out, "**", "*_pb.rb")]
29
31
  ruby_out_files.each do |file|
30
32
  # output, in grey, " #{File.basename(file)}"
31
33
  puts "\e[30m #{File.basename(file)}\e[0m"
32
34
  end
33
35
  # output the number of files that were generated
34
- #puts " Generated #{ruby_out_files.size} files in #{ruby_out}"
35
- ruby_out
36
+ #puts " Generated #{ruby_out_files.size} files in #{expanded_ruby_out}"
37
+ expanded_ruby_out
36
38
  end
37
39
  end
38
40
  end
data/lib/rakit/shell.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "timeout"
5
- require "generated/shell_pb"
5
+ require "generated/rakit.shell_pb"
6
6
 
7
7
  module Rakit
8
8
  module Shell