rails-blocks-cli 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a208908dfd35e07556c86dc68f85642d7f0b4428927f370898fa91611155881f
4
+ data.tar.gz: ea4678983d9e63e30a99450b665c3ec4be126e4b72ca1e616f0953e497345561
5
+ SHA512:
6
+ metadata.gz: 1ea4fe353fbe84f63aa493a9040b4ac51c736f47715e4be550912b2c307c4a188cf727b2f7c7522969954f94d2bc01a42b1909cca99c2807cdbf13dcdc68415e
7
+ data.tar.gz: a9f560d47c9f4dae1ea9dfa3b14f43fa49287913a9bbb787ab361ae84411a8b06d3da7762898de4c4d05c63a9aa091f046426188c5f1f550567c3f38408792d2
data/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # Rails Blocks CLI
2
+
3
+ Static-first installer for Rails Blocks components.
4
+
5
+ Free component metadata and packages are read from the public `Rails-Blocks` GitHub artifact registry. The Rails Blocks app is only contacted for login, account status, and Pro component access.
6
+
7
+ ## Example
8
+
9
+ ```bash
10
+ # List free components from the public registry
11
+ rails-blocks list --free
12
+
13
+ # Read component documentation in the terminal
14
+ rails-blocks docs accordion
15
+
16
+ # Install one component as shared ERB partials
17
+ rails-blocks install accordion --as erb_template
18
+
19
+ # Preview installing all free components as shared partials
20
+ rails-blocks install --all --free --as partial --dry-run
21
+
22
+ # Review local changes before updating a component package
23
+ rails-blocks diff accordion --as partial
24
+
25
+ # Update a component package after reviewing the diff
26
+ rails-blocks update accordion --as partial
27
+
28
+ # Compare one local Stimulus controller with the latest version
29
+ rails-blocks diff stimulus tooltip
30
+
31
+ # Update all free Stimulus controllers
32
+ rails-blocks update stimulus --all --free
33
+
34
+ # Log in before fetching Pro packages
35
+ rails-blocks login
36
+
37
+ # Install one Pro component as ViewComponent files
38
+ rails-blocks install dropdown --as view_component
39
+
40
+ # Install all Pro components as ViewComponent files
41
+ rails-blocks install --all --pro --as view_component
42
+
43
+ # Update all Pro-access Stimulus controllers
44
+ rails-blocks update stimulus --all --pro
45
+ ```
data/exe/rails-blocks ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "rails_blocks/cli"
4
+
5
+ RailsBlocks::CLI.new(ARGV).run
@@ -0,0 +1,492 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "net/http"
4
+ require "open3"
5
+ require "tempfile"
6
+ require "uri"
7
+ require "zip"
8
+
9
+ module RailsBlocks
10
+ class CLI
11
+ DownloadError = Class.new(StandardError)
12
+
13
+ DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/Rails-Blocks/components/main/registry.json".freeze
14
+ DEFAULT_API_URL = "https://railsblocks.com".freeze
15
+
16
+ def initialize(argv)
17
+ @argv = argv.dup
18
+ end
19
+
20
+ def run
21
+ command = @argv.shift
22
+
23
+ case command
24
+ when "list" then list
25
+ when "search" then search(@argv.join(" "))
26
+ when "show" then show(@argv.shift)
27
+ when "docs" then docs(@argv.shift)
28
+ when "examples" then examples(@argv.shift)
29
+ when "install" then install(@argv.shift)
30
+ when "diff" then diff(@argv.shift)
31
+ when "update" then update(@argv.shift)
32
+ when "login" then login
33
+ when "logout" then logout
34
+ when "whoami" then whoami
35
+ when "agents" then agents
36
+ when "mcp" then mcp
37
+ when "doctor" then doctor
38
+ else help
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :argv
45
+
46
+ def list
47
+ components.each do |component|
48
+ next unless include_component?(component)
49
+
50
+ puts "#{component['slug']}#{component['pro'] ? ' (pro)' : ''} - #{component['title']}"
51
+ end
52
+ end
53
+
54
+ def search(query)
55
+ abort "Usage: rails-blocks search QUERY" if query.to_s.strip.empty?
56
+
57
+ components.select { |component| component["slug"].include?(query.tr("-", "_")) || component["title"].downcase.include?(query.downcase) }.each do |component|
58
+ puts "#{component['slug']}#{component['pro'] ? ' (pro)' : ''} - #{component['title']}"
59
+ end
60
+ end
61
+
62
+ def show(slug)
63
+ component = find_component(slug)
64
+ puts JSON.pretty_generate(component)
65
+ end
66
+
67
+ def docs(slug)
68
+ component = find_component(slug)
69
+ if component["pro"]
70
+ puts api_get("/api/v1/components/#{component['slug']}/docs")["markdown"]
71
+ else
72
+ path = cached_component_file(component["slug"], "#{component['slug'].upcase}.md")
73
+ puts File.exist?(path) ? File.read(path) : "Docs are available at #{component['docs_path']}"
74
+ end
75
+ end
76
+
77
+ def examples(slug)
78
+ component = find_component(slug)
79
+ if component["pro"]
80
+ puts JSON.pretty_generate(api_get("/api/v1/components/#{component['slug']}/examples"))
81
+ else
82
+ Dir.glob(File.join(cache_dir, "components", component["slug"], "USAGE.*.erb")).each do |path|
83
+ puts "\n# #{File.basename(path)}\n\n"
84
+ puts File.read(path)
85
+ end
86
+ end
87
+ end
88
+
89
+ def install(slug)
90
+ implementation = package_implementation
91
+ dry_run = argv.include?("--dry-run")
92
+
93
+ return install_all(implementation, dry_run: dry_run) if slug == "--all"
94
+
95
+ abort "Usage: rails-blocks install COMPONENT [--as erb_template|view_component|partial] [--dry-run] [--force]" if slug.to_s.empty?
96
+
97
+ component = find_component(slug)
98
+ install_component(component, implementation, dry_run: dry_run)
99
+ end
100
+
101
+ def install_all(implementation, dry_run:)
102
+ selected_components = components.select { |component| include_component?(component) }
103
+ abort "No components matched the selected filters." if selected_components.empty?
104
+
105
+ failures = []
106
+
107
+ selected_components.each do |component|
108
+ puts "\nInstalling #{component['slug']}#{component['pro'] ? ' (pro)' : ''} as #{implementation}..."
109
+ install_component(component, implementation, dry_run: dry_run)
110
+ rescue StandardError => error
111
+ failures << [component["slug"], error.message]
112
+ warn "Failed to install #{component['slug']}: #{error.message}"
113
+ end
114
+
115
+ puts "\nInstalled #{selected_components.size - failures.size} of #{selected_components.size} components."
116
+ return if failures.empty?
117
+
118
+ warn "Failures: #{failures.map(&:first).join(', ')}"
119
+ exit(1)
120
+ end
121
+
122
+ def install_component(component, implementation, dry_run:)
123
+ if component["pro"]
124
+ install_pro(component, implementation, dry_run: dry_run)
125
+ else
126
+ install_free(component, implementation, dry_run: dry_run)
127
+ end
128
+ end
129
+
130
+ def diff(target)
131
+ change_files(target, mode: :diff)
132
+ end
133
+
134
+ def update(target)
135
+ change_files(target, mode: :update)
136
+ end
137
+
138
+ def change_files(target, mode:)
139
+ abort "Usage: rails-blocks #{mode} COMPONENT|--all|stimulus [NAME|--all] [--as erb_template|view_component|partial] [--free|--pro] [--dry-run]" if target.to_s.empty?
140
+
141
+ if target == "stimulus"
142
+ change_stimulus_files(argv.shift, mode: mode)
143
+ elsif target == "--all"
144
+ change_all_components(mode: mode)
145
+ else
146
+ component = find_component(target)
147
+ change_component_files(component, mode: mode)
148
+ end
149
+ end
150
+
151
+ def change_all_components(mode:)
152
+ selected_components = components.select { |component| include_component?(component) }
153
+ abort "No components matched the selected filters." if selected_components.empty?
154
+
155
+ failures = []
156
+
157
+ selected_components.each do |component|
158
+ puts "\n#{mode_label(mode)} #{component['slug']}#{component['pro'] ? ' (pro)' : ''} as #{package_implementation}..."
159
+ change_component_files(component, mode: mode)
160
+ rescue StandardError => error
161
+ failures << [component["slug"], error.message]
162
+ warn "Failed to #{mode} #{component['slug']}: #{error.message}"
163
+ end
164
+
165
+ puts "\n#{mode_label(mode)} #{selected_components.size - failures.size} of #{selected_components.size} components."
166
+ return if failures.empty?
167
+
168
+ warn "Failures: #{failures.map(&:first).join(', ')}"
169
+ exit(1)
170
+ end
171
+
172
+ def change_component_files(component, mode:)
173
+ data = component["pro"] ? pro_package_data(component, package_implementation) : free_package_data(component, package_implementation)
174
+ process_zip(data, mode: mode) { |entry_name| Pathname.pwd.join(entry_name) }
175
+ end
176
+
177
+ def change_stimulus_files(name, mode:)
178
+ abort "Usage: rails-blocks #{mode} stimulus NAME|--all [--free|--pro] [--dry-run]" if name.to_s.empty?
179
+
180
+ process_zip(stimulus_package_data, mode: mode) do |entry_name|
181
+ filename = File.basename(entry_name)
182
+ next unless name == "--all" || filename == stimulus_controller_filename(name)
183
+
184
+ Pathname.pwd.join("app", "javascript", "controllers", filename)
185
+ end
186
+ end
187
+
188
+ def login
189
+ email = option_value("--email")
190
+ body = { name: "Rails Blocks CLI", scopes: %w[cli mcp_read mcp_install], email: email }.compact
191
+ response = api_post("/api/v1/cli_sessions", body, auth: false)
192
+ puts "Open #{response['verification_uri']} and approve code #{response['code']}."
193
+ puts "Polling for approval..."
194
+
195
+ loop do
196
+ sleep(response["interval"] || 3)
197
+ status = api_get("/api/v1/cli_sessions/#{response['code']}", auth: false)
198
+ next unless status["status"] == "approved"
199
+
200
+ save_config("api_token" => status["token"], "api_url" => api_url)
201
+ puts "Logged in as Rails Blocks Pro token #{status['token_prefix']}."
202
+ break
203
+ end
204
+ end
205
+
206
+ def logout
207
+ save_config(config.reject { |key, _| key == "api_token" })
208
+ puts "Logged out."
209
+ end
210
+
211
+ def whoami
212
+ puts JSON.pretty_generate(api_get("/api/v1/me"))
213
+ end
214
+
215
+ def agents
216
+ subcommand = argv.shift
217
+ case subcommand
218
+ when "create"
219
+ name = argv.shift || "Rails Blocks Agent"
220
+ response = api_post("/api/v1/cli_sessions", { name: name, scopes: %w[cli mcp_read mcp_install] }, auth: false)
221
+ puts "Approve #{response['verification_uri']} for #{name}."
222
+ else
223
+ puts "Agent tokens can be created and revoked from the Rails Blocks account page."
224
+ end
225
+ end
226
+
227
+ def mcp
228
+ puts "Add @rails-blocks/mcp to your MCP client and point it at this rails-blocks executable."
229
+ end
230
+
231
+ def doctor
232
+ puts "Registry: #{registry_url}"
233
+ puts "API: #{api_url}"
234
+ puts "Token: #{api_token ? 'present' : 'missing'}"
235
+ end
236
+
237
+ def help
238
+ puts "Usage: rails-blocks [list|search|show|docs|examples|install|diff|update|login|logout|whoami|agents|mcp|doctor]"
239
+ puts " rails-blocks install COMPONENT [--as erb_template|view_component|partial] [--dry-run] [--force]"
240
+ puts " rails-blocks install --all [--free|--pro] [--as erb_template|view_component|partial] [--dry-run] [--force]"
241
+ puts " rails-blocks diff COMPONENT|--all [--free|--pro] [--as erb_template|view_component|partial]"
242
+ puts " rails-blocks update COMPONENT|--all [--free|--pro] [--as erb_template|view_component|partial] [--dry-run]"
243
+ puts " rails-blocks diff stimulus NAME|--all [--free|--pro]"
244
+ puts " rails-blocks update stimulus NAME|--all [--free|--pro] [--dry-run]"
245
+ end
246
+
247
+ def install_free(component, implementation, dry_run:)
248
+ install_zip(free_package_data(component, implementation), dry_run: dry_run)
249
+ end
250
+
251
+ def install_pro(component, implementation, dry_run:)
252
+ install_zip(pro_package_data(component, implementation), dry_run: dry_run)
253
+ end
254
+
255
+ def free_package_data(component, implementation)
256
+ zip_url = component["package_url"].to_s.sub("#{component['slug']}.zip", "#{component['slug']}-#{implementation}.zip")
257
+ download(zip_url)
258
+ end
259
+
260
+ def pro_package_data(component, implementation)
261
+ response = api_get("/api/v1/components/#{component['slug']}/signed_package_url?implementation=#{implementation}")
262
+ download(response["url"], auth: true)
263
+ end
264
+
265
+ def stimulus_package_data
266
+ type = component_filter == :pro ? "pro" : "free"
267
+ if type == "free"
268
+ stimulus_artifact = registry["stimulus_controllers"]
269
+ return download(stimulus_artifact["package_url"]) if stimulus_artifact&.dig("package_url")
270
+ end
271
+
272
+ download(api_url_for("/api/v1/stimulus_controllers?type=#{type}"), auth: type == "pro")
273
+ end
274
+
275
+ def install_zip(data, dry_run:)
276
+ zip_entries(data).each do |entry|
277
+ destination = Pathname.pwd.join(entry[:name])
278
+ puts "#{dry_run ? 'Would write' : 'Writing'} #{destination}"
279
+ next if dry_run
280
+ raise "#{destination} already exists. Use --force when overwrite support is enabled." if destination.exist? && !argv.include?("--force")
281
+
282
+ FileUtils.mkdir_p(destination.dirname)
283
+ File.binwrite(destination, entry[:content])
284
+ end
285
+ end
286
+
287
+ def process_zip(data, mode:)
288
+ counts = { changed: 0, unchanged: 0, skipped: 0 }
289
+
290
+ zip_entries(data).each do |entry|
291
+ destination = yield(entry[:name])
292
+ unless destination
293
+ counts[:skipped] += 1
294
+ next
295
+ end
296
+
297
+ if mode == :diff
298
+ diff_file(destination, entry[:content], counts)
299
+ else
300
+ update_file(destination, entry[:content], counts)
301
+ end
302
+ end
303
+
304
+ puts "No matching files found." if counts.values.sum.zero? || (counts[:changed].zero? && counts[:unchanged].zero?)
305
+ counts
306
+ end
307
+
308
+ def zip_entries(data)
309
+ entries = []
310
+ Zip::File.open_buffer(data) do |zip|
311
+ entries = zip.entries.reject(&:directory?).map do |entry|
312
+ { name: entry.name, content: entry.get_input_stream.read }
313
+ end
314
+ end
315
+ entries
316
+ end
317
+
318
+ def diff_file(destination, new_content, counts)
319
+ old_content = destination.exist? ? File.binread(destination) : ""
320
+ if old_content == new_content
321
+ counts[:unchanged] += 1
322
+ puts "No changes #{destination}"
323
+ return
324
+ end
325
+
326
+ counts[:changed] += 1
327
+ puts unified_diff(old_content, new_content, destination.exist? ? destination.to_s : "/dev/null", destination.to_s)
328
+ end
329
+
330
+ def update_file(destination, new_content, counts)
331
+ old_content = destination.exist? ? File.binread(destination) : nil
332
+ if old_content == new_content
333
+ counts[:unchanged] += 1
334
+ puts "Unchanged #{destination}"
335
+ return
336
+ end
337
+
338
+ counts[:changed] += 1
339
+ puts "#{argv.include?('--dry-run') ? 'Would update' : old_content.nil? ? 'Writing' : 'Updating'} #{destination}"
340
+ return if argv.include?("--dry-run")
341
+
342
+ FileUtils.mkdir_p(destination.dirname)
343
+ File.binwrite(destination, new_content)
344
+ end
345
+
346
+ def unified_diff(old_content, new_content, old_label, new_label)
347
+ old_file = Tempfile.new("rails-blocks-old")
348
+ new_file = Tempfile.new("rails-blocks-new")
349
+ old_file.binmode
350
+ new_file.binmode
351
+ old_file.write(old_content)
352
+ new_file.write(new_content)
353
+ old_file.flush
354
+ new_file.flush
355
+
356
+ output, = Open3.capture2e("diff", "-u", "--label", old_label, "--label", new_label, old_file.path, new_file.path)
357
+ output
358
+ ensure
359
+ old_file&.close!
360
+ new_file&.close!
361
+ end
362
+
363
+ def stimulus_controller_filename(name)
364
+ normalized = name.to_s.tr("-", "_")
365
+ normalized.end_with?("_controller.js") ? normalized : "#{normalized}_controller.js"
366
+ end
367
+
368
+ def mode_label(mode)
369
+ mode == :diff ? "Diffing" : "Updating"
370
+ end
371
+
372
+ def components
373
+ registry.fetch("components")
374
+ end
375
+
376
+ def find_component(slug)
377
+ normalized = slug.to_s.tr("-", "_")
378
+ components.find { |component| component["slug"] == normalized } || abort("Component not found: #{slug}")
379
+ end
380
+
381
+ def include_component?(component)
382
+ case component_filter
383
+ when :pro then component["pro"]
384
+ when :free then !component["pro"]
385
+ else true
386
+ end
387
+ end
388
+
389
+ def component_filter
390
+ return :pro if argv.include?("--pro")
391
+ return :free if argv.include?("--free")
392
+
393
+ :all
394
+ end
395
+
396
+ def registry
397
+ @registry ||= JSON.parse(download(registry_url))
398
+ rescue DownloadError => error
399
+ abort "Could not load the Rails Blocks registry from #{registry_url}: #{error.message}"
400
+ rescue JSON::ParserError => error
401
+ abort "Could not parse the Rails Blocks registry from #{registry_url}: #{error.message}"
402
+ end
403
+
404
+ def download(url, auth: false, redirects: 3)
405
+ uri = URI(url)
406
+ request = Net::HTTP::Get.new(uri)
407
+ request["Authorization"] = "Bearer #{api_token}" if auth && api_token
408
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
409
+
410
+ if response.is_a?(Net::HTTPRedirection) && response["location"] && redirects.positive?
411
+ return download(URI.join(url, response["location"]).to_s, redirects: redirects - 1)
412
+ end
413
+
414
+ raise DownloadError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
415
+
416
+ response.body
417
+ end
418
+
419
+ def api_get(path, auth: true)
420
+ request_json(Net::HTTP::Get, path, auth: auth)
421
+ end
422
+
423
+ def api_post(path, body, auth: true)
424
+ request_json(Net::HTTP::Post, path, body: body, auth: auth)
425
+ end
426
+
427
+ def request_json(method, path, body: nil, auth: true)
428
+ uri = URI(api_url_for(path))
429
+ request = method.new(uri)
430
+ request["Content-Type"] = "application/json"
431
+ request["Authorization"] = "Bearer #{api_token}" if auth && api_token
432
+ request.body = JSON.generate(body) if body
433
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
434
+ abort(response.body) unless response.is_a?(Net::HTTPSuccess) || response.is_a?(Net::HTTPCreated)
435
+
436
+ JSON.parse(response.body)
437
+ end
438
+
439
+ def api_url_for(path)
440
+ return path if path.to_s.match?(/\Ahttps?:\/\//)
441
+
442
+ URI.join(api_url, path).to_s
443
+ end
444
+
445
+ def cached_component_file(slug, filename)
446
+ File.join(cache_dir, "components", slug, filename)
447
+ end
448
+
449
+ def option_value(name)
450
+ index = argv.index(name)
451
+ index ? argv[index + 1] : nil
452
+ end
453
+
454
+ def package_implementation
455
+ implementation = option_value("--as") || "erb_template"
456
+ return "erb_template" if implementation == "partial"
457
+ return implementation if %w[erb_template view_component].include?(implementation)
458
+
459
+ abort "Unsupported implementation: #{implementation}. Use erb_template, view_component, or partial."
460
+ end
461
+
462
+ def registry_url
463
+ ENV.fetch("RAILS_BLOCKS_REGISTRY_URL", config.fetch("registry_url", DEFAULT_REGISTRY_URL))
464
+ end
465
+
466
+ def api_url
467
+ ENV.fetch("RAILS_BLOCKS_API_URL", config.fetch("api_url", DEFAULT_API_URL))
468
+ end
469
+
470
+ def api_token
471
+ ENV["RAILS_BLOCKS_API_TOKEN"] || config["api_token"]
472
+ end
473
+
474
+ def config
475
+ @config ||= File.exist?(config_path) ? JSON.parse(File.read(config_path)) : {}
476
+ end
477
+
478
+ def save_config(values)
479
+ FileUtils.mkdir_p(File.dirname(config_path))
480
+ File.write(config_path, JSON.pretty_generate(values))
481
+ @config = values
482
+ end
483
+
484
+ def config_path
485
+ File.expand_path("~/.rails_blocks/config.json")
486
+ end
487
+
488
+ def cache_dir
489
+ File.expand_path("~/.rails_blocks/cache")
490
+ end
491
+ end
492
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rails-blocks-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Rails Blocks
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rubyzip
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.3'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.3'
26
+ executables:
27
+ - rails-blocks
28
+ extensions: []
29
+ extra_rdoc_files: []
30
+ files:
31
+ - README.md
32
+ - exe/rails-blocks
33
+ - lib/rails_blocks/cli.rb
34
+ homepage: https://railsblocks.com/docs/cli
35
+ licenses:
36
+ - MIT
37
+ metadata:
38
+ rubygems_mfa_required: 'true'
39
+ rdoc_options: []
40
+ require_paths:
41
+ - lib
42
+ required_ruby_version: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '3.2'
47
+ required_rubygems_version: !ruby/object:Gem::Requirement
48
+ requirements:
49
+ - - ">="
50
+ - !ruby/object:Gem::Version
51
+ version: '0'
52
+ requirements: []
53
+ rubygems_version: 4.0.3
54
+ specification_version: 4
55
+ summary: Install Rails Blocks components from the command line
56
+ test_files: []