rakit 0.1.5 → 0.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cf9325de6845aa1235b153b42503a92c8604f447a6e6e402c891412a1157905
4
- data.tar.gz: ce566e52e4889d22eb42dff316fa989b414d34f4d0b14d9595b509e5913c702f
3
+ metadata.gz: 8f146d5f635716871e7960854ae13c5f64dcf353d50b7b57a808f81258580972
4
+ data.tar.gz: 80f4539d5cb9e02c506789a3ebb4862a280eb750aea7b88449dbcf54222cf6f4
5
5
  SHA512:
6
- metadata.gz: c8da52aef46f781cdf227fc0494047bdbf64869ce7dea91797ca8a79f61d41fd3a490fdb332b10413b0b2be292c2f00e0b8c2703b944eeb57779a3a517327d94
7
- data.tar.gz: c9c856269882482e5e9f838e5f1f7ca89ca2ea951296cc0b24f46b642b641277bab3f833e38f670f00ad1b878cb14a678986c15b80db6a3029c0e6e1033a309d
6
+ metadata.gz: fe9c41e4505e458900c66bfa5fed2041fed850f568fdcb44cf0843468278a5320317d84d6b97c1b76bc303f683f79ca1b9994a69224574599c8ec5c216b00925
7
+ data.tar.gz: fc4fd21d2dbefede571552edc32d3f8e62e323ed008e09d7831dd282ad216aca8ba7221af3515f56b2d76897898e40f295d075dbbfb058bb2749d32aeba54507
data/exe/rakit CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # CLI for rakit gem. Subcommands: file (list|copy), word-count (--json-keys), static-web-server (start|stop|running|publish).
4
+ # CLI for rakit gem. Subcommands: markdown (pdf|amalgamate), file (list|copy), word-count (--json-keys), static-web-server (start|stop|running|publish).
5
+ # CLI for rakit gem. Subcommands: file (list|copy), word-count (--json-keys), static-web-server (start|stop|running|publish), word-cloud (status|install|generate).
5
6
 
6
7
  require "rakit"
7
8
 
@@ -11,20 +12,25 @@ Rakit::StaticWebServer.root = ENV["RAKIT_WWW_ROOT"] if ENV["RAKIT_WWW_ROOT"]
11
12
  def usage_stderr(msg = nil)
12
13
  $stderr.puts msg if msg
13
14
  $stderr.puts "Usage: rakit <subcommand> [options] [args]"
15
+ $stderr.puts " markdown pdf <input.md> [output.pdf]"
16
+ $stderr.puts " markdown amalgamate <source_dir> <target.md> [--create-directories]"
14
17
  $stderr.puts " file list <directory> [--recursive] [--include-hidden] [--format console|json|proto-json]"
15
18
  $stderr.puts " file copy <source> <destination> [--overwrite] [--create-directories] [--format ...]"
16
19
  $stderr.puts " word-count <file>|--stdin --json-keys [--format console|json|proto-json] [options]"
17
20
  $stderr.puts " static-web-server <start|stop|running|publish> [options] [args]"
18
- $stderr.puts " start [--port PORT] Start server (idempotent)"
19
- $stderr.puts " stop Stop server"
20
- $stderr.puts " running Exit 0 if running, non-zero otherwise"
21
- $stderr.puts " publish <site_name> <source_dir> Publish static site"
21
+ $stderr.puts " word-cloud <status|install|generate> [options] [args]"
22
22
  end
23
23
 
24
24
  def main(argv = ARGV)
25
25
  return usage_stderr("Expected subcommand.") if argv.empty?
26
26
 
27
27
  sub = argv[0]
28
+ if sub == "markdown"
29
+ argv.shift
30
+ require "rakit/cli/markdown"
31
+ return Rakit::CLI::Markdown.run(argv)
32
+ end
33
+
28
34
  if sub == "file"
29
35
  argv.shift
30
36
  require "rakit/cli/file"
@@ -37,6 +43,12 @@ def main(argv = ARGV)
37
43
  return Rakit::CLI::WordCount.run(argv)
38
44
  end
39
45
 
46
+ if sub == "word-cloud"
47
+ argv.shift
48
+ require "rakit/cli/word_cloud"
49
+ return Rakit::CLI::WordCloud.run(argv)
50
+ end
51
+
40
52
  if sub != "static-web-server"
41
53
  usage_stderr("Unknown subcommand: #{sub}")
42
54
  return 1
@@ -2,7 +2,8 @@
2
2
  # Generated by the protocol buffer compiler. DO NOT EDIT!
3
3
  # source: data.proto
4
4
 
5
- require "google/protobuf"
5
+ require 'google/protobuf'
6
+
6
7
 
7
8
  descriptor_data = "\n\ndata.proto\x12\nrakit.data\"h\n\x05Index\x12/\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x1e.rakit.data.Index.EntriesEntry\x1a.\n\x0c\x45ntriesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*^\n\x0c\x45xportFormat\x12\x19\n\x15PROTOBUF_BINARY_FILES\x10\x00\x12\x17\n\x13PROTOBUF_JSON_FILES\x10\x01\x12\x1a\n\x16PROTOBUF_BINARY_ZIPPED\x10\x02\x42\x0e\xea\x02\x0bRakit::Datab\x06proto3"
8
9
 
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # source: word_cloud.proto
4
+
5
+ require 'google/protobuf'
6
+
7
+
8
+ descriptor_data = "\n\x10word_cloud.proto\x12\x0frakit.wordcloud\"W\n\x0fWordCloudConfig\x12\x13\n\x0bwcloud_path\x18\x01 \x01(\t\x12\x14\n\x0c\x61uto_install\x18\x02 \x01(\x08\x12\x19\n\x11working_directory\x18\x03 \x01(\t\"S\n\nTextSource\x12\x13\n\ttext_file\x18\x01 \x01(\tH\x00\x12\x15\n\x0binline_text\x18\x02 \x01(\tH\x00\x12\x0f\n\x05stdin\x18\x03 \x01(\x08H\x00\x42\x08\n\x06source\"\xa6\x02\n\x0fGenerateRequest\x12\x30\n\x06\x63onfig\x18\x01 \x01(\x0b\x32 .rakit.wordcloud.WordCloudConfig\x12)\n\x04text\x18\x02 \x01(\x0b\x32\x1b.rakit.wordcloud.TextSource\x12\x14\n\x0coutput_image\x18\x03 \x01(\t\x12\r\n\x05width\x18\x04 \x01(\x05\x12\x0e\n\x06height\x18\x05 \x01(\x05\x12\x10\n\x08rng_seed\x18\x06 \x01(\x05\x12\x12\n\nmask_image\x18\x07 \x01(\t\x12\x11\n\tfont_file\x18\x08 \x01(\t\x12\x1a\n\x12\x65xclude_words_file\x18\t \x01(\t\x12\x11\n\tmax_words\x18\n \x01(\x05\x12\x19\n\x11publish_site_name\x18\x0b \x01(\t\"\xcf\x01\n\x0eGenerateResult\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\x0f\n\x07message\x18\x02 \x01(\t\x12\x14\n\x0coutput_image\x18\x03 \x01(\t\x12\x11\n\texit_code\x18\x04 \x01(\x05\x12\x0e\n\x06stdout\x18\x05 \x01(\t\x12\x0e\n\x06stderr\x18\x06 \x01(\t\x12\x1c\n\x14wcloud_was_installed\x18\x07 \x01(\x08\x12\x1c\n\x14wcloud_resolved_path\x18\x08 \x01(\t\x12\x16\n\x0ewcloud_version\x18\t \x01(\t\"`\n\nToolStatus\x12\x14\n\x0cwcloud_found\x18\x01 \x01(\x08\x12\x13\n\x0bwcloud_path\x18\x02 \x01(\t\x12\x13\n\x0b\x63\x61rgo_found\x18\x03 \x01(\x08\x12\x12\n\ncargo_path\x18\x04 \x01(\tB\x13\xea\x02\x10Rakit::Generatedb\x06proto3"
9
+
10
+ pool = ::Google::Protobuf::DescriptorPool.generated_pool
11
+ pool.add_serialized_file(descriptor_data)
12
+
13
+ module Rakit
14
+ module Generated
15
+ WordCloudConfig = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.wordcloud.WordCloudConfig").msgclass
16
+ TextSource = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.wordcloud.TextSource").msgclass
17
+ GenerateRequest = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.wordcloud.GenerateRequest").msgclass
18
+ GenerateResult = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.wordcloud.GenerateResult").msgclass
19
+ ToolStatus = ::Google::Protobuf::DescriptorPool.generated_pool.lookup("rakit.wordcloud.ToolStatus").msgclass
20
+ end
21
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "google/protobuf"
5
+ require "rakit/file"
6
+
7
+ module Rakit
8
+ module CLI
9
+ module File
10
+ class << self
11
+ # @param argv [Array<String>] arguments after "file"
12
+ # @return [Integer] exit code
13
+ def run(argv)
14
+ return 1 if argv.empty?
15
+ sub = argv.shift
16
+ case sub
17
+ when "list" then run_list(argv)
18
+ when "copy" then run_copy(argv)
19
+ else
20
+ $stderr.puts "Unknown command: #{sub}. Use 'list' or 'copy'."
21
+ $stderr.puts " rakit file list <directory> [--recursive] [--include-hidden] [--format console|json|proto-json]"
22
+ $stderr.puts " rakit file copy <source> <destination> [--overwrite] [--create-directories] [--follow-symlinks] [--format ...]"
23
+ 1
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def run_list(argv)
30
+ opts = parse_list_argv(argv)
31
+ return 1 if opts[:error]
32
+ req = Rakit::File::ListRequest.new(
33
+ directory: opts[:directory],
34
+ recursive: opts[:recursive],
35
+ include_hidden: opts[:include_hidden],
36
+ config: Rakit::File::FileConfig.new(follow_symlinks: opts[:follow_symlinks])
37
+ )
38
+ result = Rakit::File.list(req)
39
+ render_list_result(result, opts[:format])
40
+ result.exit_code
41
+ end
42
+
43
+ def parse_list_argv(argv)
44
+ opts = { format: "console", recursive: false, include_hidden: false, follow_symlinks: false, directory: nil }
45
+ args = argv.dup
46
+ while (arg = args.shift)
47
+ case arg
48
+ when "--recursive" then opts[:recursive] = true
49
+ when "--include-hidden" then opts[:include_hidden] = true
50
+ when "--follow-symlinks" then opts[:follow_symlinks] = true
51
+ when "--format" then opts[:format] = args.shift || "console"
52
+ when /^--/
53
+ $stderr.puts "Unknown option: #{arg}"
54
+ opts[:error] = true
55
+ else
56
+ opts[:directory] = arg if opts[:directory].nil?
57
+ end
58
+ end
59
+ unless opts[:directory] && !opts[:directory].empty?
60
+ $stderr.puts "list requires <directory>"
61
+ opts[:error] = true
62
+ end
63
+ opts
64
+ end
65
+
66
+ def run_copy(argv)
67
+ opts = parse_copy_argv(argv)
68
+ return 1 if opts[:error]
69
+ req = Rakit::File::CopyRequest.new(
70
+ source: opts[:source],
71
+ destination: opts[:destination],
72
+ config: Rakit::File::FileConfig.new(
73
+ overwrite: opts[:overwrite],
74
+ create_directories: opts[:create_directories],
75
+ follow_symlinks: opts[:follow_symlinks]
76
+ )
77
+ )
78
+ result = Rakit::File.copy(req)
79
+ render_copy_result(result, opts[:format])
80
+ result.exit_code
81
+ end
82
+
83
+ def parse_copy_argv(argv)
84
+ opts = { format: "console", overwrite: false, create_directories: false, follow_symlinks: false, source: nil, destination: nil }
85
+ args = argv.dup
86
+ while (arg = args.shift)
87
+ case arg
88
+ when "--overwrite" then opts[:overwrite] = true
89
+ when "--create-directories" then opts[:create_directories] = true
90
+ when "--follow-symlinks" then opts[:follow_symlinks] = true
91
+ when "--format" then opts[:format] = args.shift || "console"
92
+ when /^--/
93
+ $stderr.puts "Unknown option: #{arg}"
94
+ opts[:error] = true
95
+ else
96
+ if opts[:source].nil?
97
+ opts[:source] = arg
98
+ elsif opts[:destination].nil?
99
+ opts[:destination] = arg
100
+ else
101
+ $stderr.puts "Unexpected argument: #{arg}"
102
+ opts[:error] = true
103
+ end
104
+ end
105
+ end
106
+ unless opts[:source] && opts[:destination]
107
+ $stderr.puts "copy requires <source> and <destination>"
108
+ opts[:error] = true
109
+ end
110
+ opts
111
+ end
112
+
113
+ def render_list_result(result, format)
114
+ $stderr.puts result.stderr if result.stderr && !result.stderr.empty? && !result.success
115
+ case format
116
+ when "json"
117
+ puts list_result_to_json(result)
118
+ when "proto-json"
119
+ puts Google::Protobuf.encode_json(result)
120
+ else
121
+ result.entries.each do |e|
122
+ puts "#{e.path}\t#{e.name}\t#{e.is_directory ? 'dir' : 'file'}"
123
+ end
124
+ end
125
+ end
126
+
127
+ def list_result_to_json(result)
128
+ h = {
129
+ success: result.success,
130
+ message: result.message.to_s,
131
+ entries: result.entries.map { |e| { path: e.path, name: e.name, is_directory: e.is_directory, is_symlink: e.is_symlink, size_bytes: e.size_bytes, modified_unix_ms: e.modified_unix_ms } },
132
+ exit_code: result.exit_code
133
+ }
134
+ JSON.generate(h)
135
+ end
136
+
137
+ def render_copy_result(result, format)
138
+ $stderr.puts result.stderr if result.stderr && !result.stderr.empty? && !result.success
139
+ case format
140
+ when "json"
141
+ puts copy_result_to_json(result)
142
+ when "proto-json"
143
+ puts Google::Protobuf.encode_json(result)
144
+ else
145
+ puts "Copied #{result.source} → #{result.destination}" if result.success
146
+ end
147
+ end
148
+
149
+ def copy_result_to_json(result)
150
+ h = {
151
+ success: result.success,
152
+ message: result.message.to_s,
153
+ source: result.source.to_s,
154
+ destination: result.destination.to_s,
155
+ exit_code: result.exit_code,
156
+ bytes_copied: result.bytes_copied
157
+ }
158
+ JSON.generate(h)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rakit/markdown"
4
+
5
+ module Rakit
6
+ module CLI
7
+ module Markdown
8
+ class << self
9
+ # @param argv [Array<String>] arguments after "markdown"
10
+ # @return [Integer] exit code
11
+ def run(argv)
12
+ return 1 if argv.empty?
13
+ sub = argv.shift
14
+ case sub
15
+ when "pdf" then run_pdf(argv)
16
+ when "amalgamate" then run_amalgamate(argv)
17
+ else
18
+ $stderr.puts "Unknown command: #{sub}. Use 'pdf' or 'amalgamate'."
19
+ $stderr.puts " rakit markdown pdf <input.md> [output.pdf]"
20
+ $stderr.puts " rakit markdown amalgamate <source_dir> <target.md> [--create-directories]"
21
+ 1
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def run_pdf(argv)
28
+ opts = parse_pdf_argv(argv)
29
+ return 1 if opts[:error]
30
+ result = Rakit::Markdown.generate_pdf(opts[:input], opts[:output])
31
+ $stderr.puts result[:stderr] if result[:stderr] && !result[:stderr].empty? && !result[:success]
32
+ $stderr.puts result[:message] if result[:message] && !result[:message].empty? && !result[:success]
33
+ result[:exit_code] || (result[:success] ? 0 : 1)
34
+ end
35
+
36
+ def parse_pdf_argv(argv)
37
+ opts = { input: nil, output: nil, error: false }
38
+ args = argv.dup
39
+ pos = []
40
+ while (arg = args.shift)
41
+ if arg.start_with?("--")
42
+ $stderr.puts "Unknown option: #{arg}"
43
+ opts[:error] = true
44
+ else
45
+ pos << arg
46
+ end
47
+ end
48
+ opts[:input] = pos[0]
49
+ opts[:output] = pos[1] if pos.length > 1
50
+ unless opts[:input] && !opts[:input].empty?
51
+ $stderr.puts "pdf requires <input.md>"
52
+ opts[:error] = true
53
+ end
54
+ opts
55
+ end
56
+
57
+ def run_amalgamate(argv)
58
+ opts = parse_amalgamate_argv(argv)
59
+ return 1 if opts[:error]
60
+ result = Rakit::Markdown.amalgamate(opts[:source_dir], opts[:target], create_directories: opts[:create_directories])
61
+ $stderr.puts result[:message] if result[:message] && !result[:message].empty? && !result[:success]
62
+ result[:exit_code] || (result[:success] ? 0 : 1)
63
+ end
64
+
65
+ def parse_amalgamate_argv(argv)
66
+ opts = { source_dir: nil, target: nil, create_directories: false, error: false }
67
+ args = argv.dup
68
+ pos = []
69
+ while (arg = args.shift)
70
+ case arg
71
+ when "--create-directories" then opts[:create_directories] = true
72
+ when /^--/
73
+ $stderr.puts "Unknown option: #{arg}"
74
+ opts[:error] = true
75
+ else
76
+ pos << arg
77
+ end
78
+ end
79
+ opts[:source_dir] = pos[0]
80
+ opts[:target] = pos[1]
81
+ unless opts[:source_dir] && opts[:target]
82
+ $stderr.puts "amalgamate requires <source_dir> and <target.md>"
83
+ opts[:error] = true
84
+ end
85
+ opts
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
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
@@ -44,9 +46,8 @@ module Rakit
44
46
  raise "Gem not found: #{gem_path}. Run rake package first." unless ::File.file?(gem_path)
45
47
 
46
48
  base = ::File.basename(gem_path, ".gem")
47
- parts = base.split("-")
48
- version = parts.pop
49
- name = parts.join("-")
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
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "kramdown"
4
+ require "fileutils"
5
+
6
+ module Rakit
7
+ module Markdown
8
+ class << self
9
+ # @param source_path [String] path to markdown file
10
+ # @param output_path [String, nil] path for PDF; default same dir, .pdf extension
11
+ # @return [Hash] { success: Boolean, message: String, exit_code: Integer, stderr: String (optional) }
12
+ def generate_pdf(source_path, output_path = nil)
13
+ validated = validate_file_path(source_path)
14
+ unless validated
15
+ return { success: false, message: "Source path must exist and be a file", exit_code: 1, stderr: "" }
16
+ end
17
+ # Explicit output_path is relative to CWD (or absolute); default is same dir as source, .pdf extension
18
+ out_path = if output_path.to_s.strip.empty?
19
+ nil
20
+ else
21
+ ::File.expand_path(output_path.to_s.strip)
22
+ end
23
+ out_path ||= ::File.join(::File.dirname(validated), ::File.basename(validated, ".*") + ".pdf")
24
+ out_path = ::File.expand_path(out_path)
25
+
26
+ unless wkhtmltopdf_available?
27
+ msg = wkhtmltopdf_install_message
28
+ return { success: false, message: msg, exit_code: 1, stderr: msg }
29
+ end
30
+
31
+ temp_html = nil
32
+ begin
33
+ md_content = ::File.read(validated, encoding: "UTF-8")
34
+ html = markdown_to_html(md_content)
35
+ temp_html = ::File.join(::File.dirname(out_path), ".rakit_md_#{Process.pid}_#{object_id}.html")
36
+ ::File.write(temp_html, html, encoding: "UTF-8")
37
+ ok = system("wkhtmltopdf", "-q", temp_html, out_path, out: ::File::NULL, err: ::File::NULL)
38
+ unless ok
39
+ ::File.unlink(out_path) if ::File.file?(out_path)
40
+ return { success: false, message: "wkhtmltopdf failed", exit_code: 1, stderr: "" }
41
+ end
42
+ { success: true, message: "", exit_code: 0, stderr: "" }
43
+ rescue => e
44
+ ::File.unlink(out_path) if out_path && ::File.file?(out_path)
45
+ { success: false, message: e.message, exit_code: 1, stderr: "" }
46
+ ensure
47
+ ::File.unlink(temp_html) if temp_html && ::File.file?(temp_html)
48
+ end
49
+ end
50
+
51
+ # @param source_dir [String] directory to collect .md files from
52
+ # @param target_path [String] path to output markdown file
53
+ # @param create_directories [Boolean]
54
+ # @return [Hash] { success: Boolean, message: String, exit_code: Integer }
55
+ def amalgamate(source_dir, target_path, create_directories: false)
56
+ validated_dir = validate_dir_path(source_dir)
57
+ unless validated_dir
58
+ return { success: false, message: "Source directory must exist and be a directory", exit_code: 1 }
59
+ end
60
+ target_norm = normalize_path(target_path)
61
+ unless target_norm
62
+ return { success: false, message: "Target path is required", exit_code: 1 }
63
+ end
64
+ unless target_outside_source?(validated_dir, target_norm)
65
+ return { success: false, message: "Target path must be outside source directory", exit_code: 1 }
66
+ end
67
+ parent = ::File.dirname(target_norm)
68
+ unless ::File.directory?(parent)
69
+ if create_directories
70
+ ::FileUtils.mkdir_p(parent)
71
+ else
72
+ return { success: false, message: "Parent directory does not exist; use create_directories: true", exit_code: 1 }
73
+ end
74
+ end
75
+ begin
76
+ content = amalgamate_collect(validated_dir)
77
+ ::File.write(target_norm, content, encoding: "UTF-8")
78
+ { success: true, message: "", exit_code: 0 }
79
+ rescue => e
80
+ ::File.unlink(target_norm) if ::File.file?(target_norm)
81
+ { success: false, message: e.message, exit_code: 1 }
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ # T004: Normalize path; return nil if nil/empty after strip.
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_dir ? ::File.expand_path(s, base_dir) : ::File.expand_path(s)
93
+ end
94
+
95
+ # T004: Validate path exists and is a file. Return expanded path or nil.
96
+ def validate_file_path(path)
97
+ norm = normalize_path(path)
98
+ return nil unless norm
99
+ return nil unless ::File.file?(norm)
100
+ norm
101
+ end
102
+
103
+ # T004: Validate path exists and is a directory. Return expanded path or nil.
104
+ def validate_dir_path(path)
105
+ norm = normalize_path(path)
106
+ return nil unless norm
107
+ return nil unless ::File.directory?(norm)
108
+ norm
109
+ end
110
+
111
+ # T005: Target is outside source_dir (not equal, not under). Both should be normalized.
112
+ def target_outside_source?(source_dir, target_path)
113
+ src = source_dir.to_s.gsub(::File::SEPARATOR, "/").chomp("/")
114
+ target = ::File.expand_path(target_path).to_s.gsub(::File::SEPARATOR, "/")
115
+ target_dir = ::File.dirname(target).gsub(::File::SEPARATOR, "/")
116
+ return false if target_dir == src
117
+ return false if target_dir.start_with?(src + "/")
118
+ return false if target == src
119
+ true
120
+ end
121
+
122
+ # T006: True if wkhtmltopdf is available.
123
+ def wkhtmltopdf_available?
124
+ out = `wkhtmltopdf --version 2>&1`
125
+ $?.success?
126
+ end
127
+
128
+ # T006: Platform-specific install message for wkhtmltopdf.
129
+ def wkhtmltopdf_install_message
130
+ if ::Gem.win_platform?
131
+ "Install wkhtmltopdf: choco install wkhtmltopdf"
132
+ elsif ::File.exist?("/usr/bin/sw_vers") || RUBY_PLATFORM.include?("darwin")
133
+ "Install wkhtmltopdf: brew install wkhtmltopdf"
134
+ else
135
+ "Install wkhtmltopdf: e.g. apt install wkhtmltopdf or yum install wkhtmltopdf"
136
+ end
137
+ end
138
+
139
+ # T007: Convert markdown string to HTML. Empty input -> minimal HTML.
140
+ def markdown_to_html(md_string)
141
+ return "" if md_string.nil? || md_string.to_s.strip.empty?
142
+ Kramdown::Document.new(md_string.to_s).to_html
143
+ end
144
+
145
+ # T008: Collect all **/*.md under source_dir, sort by path, concatenate with separator. Return string.
146
+ AMALGAM_SEPARATOR = "\n\n"
147
+
148
+ def amalgamate_collect(source_dir)
149
+ norm_dir = ::File.expand_path(source_dir)
150
+ pattern = ::File.join(norm_dir, "**", "*.md")
151
+ paths = ::Dir.glob(pattern).select { |p| ::File.file?(p) }.sort
152
+ return "" if paths.empty?
153
+ paths.map { |p| ::File.read(p, encoding: "UTF-8") }.join(AMALGAM_SEPARATOR)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -102,6 +102,7 @@ module Rakit
102
102
  root_path = ::File.expand_path(root)
103
103
  pid = spawn(bin, root_path, "--port", p.to_s, out: ::File::NULL, err: ::File::NULL)
104
104
  Process.detach(pid)
105
+ FileUtils.mkdir_p(::File.dirname(pid_file_path))
105
106
  ::File.write(pid_file_path, pid.to_s)
106
107
  true
107
108
  end
data/lib/rakit.rb CHANGED
@@ -26,6 +26,7 @@ require_relative "rakit/protobuf"
26
26
  autoload :Shell, "rakit/shell"
27
27
  require_relative "rakit/static_web_server"
28
28
  require_relative "rakit/word_count"
29
+ require_relative "rakit/word_cloud"
29
30
  require_relative "rakit/file"
30
31
  require_relative "rakit/azure/dev_ops"
31
32
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rakit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - rakit
@@ -41,16 +41,22 @@ dependencies:
41
41
  name: google-protobuf
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - "~>"
44
+ - - ">="
45
45
  - !ruby/object:Gem::Version
46
46
  version: '3.25'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '6'
47
50
  type: :runtime
48
51
  prerelease: false
49
52
  version_requirements: !ruby/object:Gem::Requirement
50
53
  requirements:
51
- - - "~>"
54
+ - - ">="
52
55
  - !ruby/object:Gem::Version
53
56
  version: '3.25'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '6'
54
60
  - !ruby/object:Gem::Dependency
55
61
  name: rubyzip
56
62
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +71,20 @@ dependencies:
65
71
  - - "~>"
66
72
  - !ruby/object:Gem::Version
67
73
  version: '2.0'
74
+ - !ruby/object:Gem::Dependency
75
+ name: kramdown
76
+ requirement: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - "~>"
79
+ - !ruby/object:Gem::Version
80
+ version: '2.4'
81
+ type: :runtime
82
+ prerelease: false
83
+ version_requirements: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - "~>"
86
+ - !ruby/object:Gem::Version
87
+ version: '2.4'
68
88
  - !ruby/object:Gem::Dependency
69
89
  name: grpc-tools
70
90
  requirement: !ruby/object:Gem::Requirement
@@ -106,11 +126,16 @@ files:
106
126
  - lib/generated/rakit.word_count_pb.rb
107
127
  - lib/generated/shell_pb.rb
108
128
  - lib/generated/static_web_server_pb.rb
129
+ - lib/generated/word_cloud_pb.rb
109
130
  - lib/rakit.rb
110
131
  - lib/rakit/azure/dev_ops.rb
132
+ - lib/rakit/cli/file.rb
133
+ - lib/rakit/cli/markdown.rb
111
134
  - lib/rakit/cli/word_count.rb
135
+ - lib/rakit/file.rb
112
136
  - lib/rakit/gem.rb
113
137
  - lib/rakit/git.rb
138
+ - lib/rakit/markdown.rb
114
139
  - lib/rakit/protobuf.rb
115
140
  - lib/rakit/shell.rb
116
141
  - lib/rakit/static_web_server.rb