shai-cli 0.1.0 → 0.2.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.
@@ -5,8 +5,6 @@ require "time"
5
5
  module Shai
6
6
  module Commands
7
7
  module Configurations
8
- INSTALLED_FILE = ".shai-installed"
9
-
10
8
  def self.included(base)
11
9
  base.class_eval do
12
10
  desc "list", "List your configurations"
@@ -82,37 +80,31 @@ module Shai
82
80
  display_name = owner ? "#{owner}/#{slug}" : slug
83
81
  base_path = File.expand_path(options[:path])
84
82
  shairc_path = File.join(base_path, ".shairc")
85
- installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
86
83
 
87
- # Check if a configuration is already installed/initialized
88
- unless options[:force]
89
- existing_slug = nil
84
+ installed = InstalledProjects.new(base_path)
90
85
 
91
- if File.exist?(installed_path)
92
- existing_config = begin
93
- YAML.safe_load_file(installed_path)
94
- rescue
95
- {}
96
- end
97
- existing_slug = existing_config["slug"]
98
- elsif File.exist?(shairc_path)
99
- existing_config = begin
100
- YAML.safe_load_file(shairc_path)
101
- rescue
102
- {}
103
- end
104
- existing_slug = existing_config["slug"]
86
+ # Check if this exact project is already installed
87
+ if installed.has_project?(display_name) && !options[:force]
88
+ ui.error("'#{display_name}' is already installed in this directory.")
89
+ ui.info("Use --force to reinstall, or run `shai uninstall #{display_name}` first.")
90
+ exit EXIT_INVALID_INPUT
91
+ end
92
+
93
+ # Check if a .shairc exists (authored config)
94
+ if File.exist?(shairc_path) && !options[:force]
95
+ existing_config = begin
96
+ YAML.safe_load_file(shairc_path)
97
+ rescue
98
+ {}
105
99
  end
100
+ existing_slug = existing_config["slug"]
106
101
 
107
102
  if existing_slug
108
- ui.error("A configuration is already present in this directory.")
103
+ ui.error("This directory contains an authored configuration (.shairc).")
109
104
  ui.indent("Existing: #{existing_slug}")
110
105
  ui.blank
111
- ui.info("To install a different configuration:")
112
- ui.indent("1. Run `shai uninstall #{existing_slug}` to remove the current configuration")
113
- ui.indent("2. Then run `shai install #{display_name}`")
114
- ui.blank
115
- ui.info("Or use --force to install anyway (may cause conflicts)")
106
+ ui.info("Installing here may cause conflicts with your authored config.")
107
+ ui.info("Use --force to install anyway.")
116
108
  exit EXIT_INVALID_INPUT
117
109
  end
118
110
  end
@@ -127,39 +119,65 @@ module Shai
127
119
  # Security: Validate all paths before any file operations
128
120
  validate_tree_paths!(tree, base_path)
129
121
 
130
- # Check for conflicts
131
- conflicts = []
122
+ # Get file paths from tree (excluding folders)
123
+ new_files = tree.reject { |n| n["kind"] == "folder" }.map { |n| n["path"] }
124
+
125
+ # Check for conflicts with existing local files
126
+ local_conflicts = []
132
127
  tree.each do |node|
133
128
  next if node["kind"] == "folder"
134
-
135
129
  local_path = File.join(base_path, node["path"])
136
- conflicts << node["path"] if File.exist?(local_path)
130
+ local_conflicts << node["path"] if File.exist?(local_path)
137
131
  end
138
132
 
133
+ # Check for conflicts with other installed projects
134
+ project_conflicts = installed.find_conflicts(new_files)
135
+
139
136
  if options[:dry_run]
140
137
  ui.header("Would install:")
141
138
  tree.each { |node| ui.display_file_operation(:would_create, node["path"]) }
139
+
140
+ if project_conflicts.any?
141
+ ui.blank
142
+ ui.warning("Would conflict with installed projects:")
143
+ project_conflicts.each do |file, owner|
144
+ ui.indent("#{file} (from #{owner})")
145
+ end
146
+ end
147
+
142
148
  ui.blank
143
149
  ui.info("No changes made (dry run)")
144
150
  return
145
151
  end
146
152
 
147
153
  # Handle conflicts
148
- if conflicts.any? && !options[:force]
149
- ui.blank
150
- ui.warning("The following files already exist:")
151
- conflicts.each { |path| ui.display_file_operation(:conflict, path) }
154
+ if (local_conflicts.any? || project_conflicts.any?) && !options[:force]
152
155
  ui.blank
153
156
 
154
- choice = ui.select("Overwrite existing files?", [
155
- {name: "Yes", value: :yes},
156
- {name: "No (abort)", value: :no},
157
+ if project_conflicts.any?
158
+ ui.warning("The following files conflict with already installed projects:")
159
+ project_conflicts.each do |file, owner|
160
+ ui.indent("#{file} #{ui.dim("(from #{owner})")}")
161
+ end
162
+ ui.blank
163
+ end
164
+
165
+ other_local_conflicts = local_conflicts - project_conflicts.keys
166
+ if other_local_conflicts.any?
167
+ ui.warning("The following local files will be overwritten:")
168
+ other_local_conflicts.each { |path| ui.display_file_operation(:conflict, path) }
169
+ ui.blank
170
+ end
171
+
172
+ choice = ui.select("How would you like to proceed?", [
173
+ {name: "Overwrite files (conflicting projects will be updated)", value: :yes},
174
+ {name: "Cancel installation", value: :no},
157
175
  {name: "Show diff", value: :diff}
158
176
  ])
159
177
 
160
178
  if choice == :diff
161
- show_install_diff(tree, base_path, conflicts)
162
- return unless ui.yes?("Overwrite existing files?")
179
+ show_install_diff(tree, base_path, local_conflicts)
180
+ return unless ui.yes?("Proceed with installation?")
163
181
  elsif choice == :no
164
182
  ui.info("Installation cancelled")
165
183
  return
@@ -169,8 +187,17 @@ module Shai
169
187
  ui.header("Installing #{display_name}...")
170
188
  ui.blank
171
189
 
190
+ # Remove conflicting files from their original projects
191
+ if project_conflicts.any?
192
+ affected_projects = project_conflicts.values.uniq
193
+ affected_projects.each do |project_slug|
194
+ files_to_remove = project_conflicts.select { |_, owner| owner == project_slug }.keys
195
+ installed.remove_files_from_project(project_slug, files_to_remove)
196
+ end
197
+ end
198
+
172
199
  # Create folders and files
173
- created_count = 0
200
+ created_files = []
174
201
  tree.sort_by { |n| (n["kind"] == "folder") ? 0 : 1 }.each do |node|
175
202
  local_path = File.join(base_path, node["path"])
176
203
 
@@ -181,20 +208,26 @@ module Shai
181
208
  FileUtils.mkdir_p(File.dirname(local_path))
182
209
  File.write(local_path, node["content"])
183
210
  ui.display_file_operation(:created, node["path"])
211
+ created_files << node["path"]
184
212
  end
185
- created_count += 1
186
213
  end
187
214
 
188
- # Write installation tracking file
189
- installed_content = <<~YAML
190
- # Installed by shai - do not edit manually
191
- slug: "#{display_name}"
192
- installed_at: "#{Time.now.iso8601}"
193
- YAML
194
- File.write(installed_path, installed_content)
215
+ # Track the installed project
216
+ installed.add_project(display_name, created_files)
217
+
218
+ # Record the install for analytics (fire and forget)
219
+ begin
220
+ api.record_install(display_name)
221
+ rescue
222
+ # Silently ignore install tracking errors
223
+ end
195
224
 
196
225
  ui.blank
197
- ui.success("Installed #{created_count} items from #{display_name}")
226
+ ui.success("Installed #{display_name}")
227
+
228
+ if installed.project_count > 1
229
+ ui.indent("#{installed.project_count} configurations now installed in this directory")
230
+ end
198
231
  rescue NotFoundError
199
232
  ui.error("Configuration '#{display_name}' not found.")
200
233
  exit EXIT_NOT_FOUND
@@ -207,115 +240,198 @@ module Shai
207
240
  end
208
241
  end
209
242
 
243
+ desc "open CONFIGURATION", "Open a configuration in the browser"
244
+ def open(configuration)
245
+ owner, slug = parse_configuration_name(configuration)
246
+ display_name = owner ? "#{owner}/#{slug}" : slug
247
+ base_url = Shai.configuration.api_url
248
+
249
+ # Fetch configuration details to determine ownership and visibility
250
+ begin
251
+ config = ui.spinner("Fetching #{display_name}...") do
252
+ api.get_configuration(display_name)
253
+ end
254
+
255
+ config_owner = config["owner"]
256
+ config_slug = config["slug"]
257
+ visibility = config["visibility"]
258
+ current_username = credentials.authenticated? ? credentials.username : nil
259
+
260
+ # Determine the appropriate URL based on ownership
261
+ if current_username && config_owner == current_username
262
+ # User owns this config - open in configuration_projects
263
+ url = "#{base_url}/configuration_projects/#{config_slug}"
264
+ elsif visibility == "public"
265
+ # Public config owned by someone else - open in explore
266
+ url = "#{base_url}/explore/#{config_owner}/#{config_slug}"
267
+ else
268
+ # Private config not owned by user - can't access
269
+ ui.error("Configuration '#{display_name}' is private and you don't have access.")
270
+ exit EXIT_PERMISSION_DENIED
271
+ end
272
+
273
+ ui.info("Opening #{display_name} in browser...")
274
+
275
+ begin
276
+ require "launchy"
277
+ Launchy.open(url)
278
+ rescue LoadError
279
+ ui.warning("Could not open browser automatically.")
280
+ ui.info("Visit: #{url}")
281
+ rescue Launchy::Error => e
282
+ ui.warning("Could not open browser: #{e.message}")
283
+ ui.info("Visit: #{url}")
284
+ end
285
+ rescue NotFoundError
286
+ if owner
287
+ ui.error("Configuration '#{display_name}' not found.")
288
+ ui.info("It may be private or doesn't exist.")
289
+ else
290
+ ui.error("Configuration '#{display_name}' not found in your projects.")
291
+ ui.info("Use 'owner/slug' format to open someone else's public configuration.")
292
+ end
293
+ exit EXIT_NOT_FOUND
294
+ rescue PermissionDeniedError
295
+ ui.error("You don't have permission to access '#{display_name}'.")
296
+ ui.info("This configuration may be private.")
297
+ exit EXIT_PERMISSION_DENIED
298
+ rescue NetworkError => e
299
+ ui.error(e.message)
300
+ exit EXIT_NETWORK_ERROR
301
+ end
302
+ end
303
+
210
304
  desc "uninstall [CONFIGURATION]", "Remove an installed configuration from local project"
211
305
  option :dry_run, type: :boolean, default: false, desc: "Show what would be removed"
212
306
  option :path, type: :string, default: ".", desc: "Path where configuration is installed"
213
307
  def uninstall(configuration = nil)
214
- require_auth!
215
-
216
308
  base_path = File.expand_path(options[:path])
217
- installed_path = File.join(base_path, INSTALLED_FILE)
309
+ installed = InstalledProjects.new(base_path)
218
310
 
219
- # If no configuration specified, try to read from .shai-installed
311
+ # If no configuration specified, determine which to uninstall
220
312
  if configuration.nil?
221
- unless File.exist?(installed_path)
222
- ui.error("No configuration specified and no .shai-installed file found.")
313
+ if installed.empty?
314
+ ui.error("No configurations installed in this directory.")
223
315
  ui.info("Usage: shai uninstall <configuration>")
224
316
  exit EXIT_INVALID_INPUT
225
- end
226
-
227
- installed_config = YAML.safe_load_file(installed_path)
228
- configuration = installed_config["slug"]
317
+ elsif installed.project_count == 1
318
+ configuration = installed.project_slugs.first
319
+ ui.info("Uninstalling #{configuration}...")
320
+ else
321
+ ui.info("Multiple configurations installed:")
322
+ configuration = ui.select("Which configuration do you want to uninstall?",
323
+ installed.project_slugs.map { |s| {name: s, value: s} } + [{name: "Cancel", value: nil}])
229
324
 
230
- unless configuration
231
- ui.error("Could not read configuration from .shai-installed")
232
- exit EXIT_INVALID_INPUT
325
+ if configuration.nil?
326
+ ui.info("Uninstall cancelled")
327
+ return
328
+ end
233
329
  end
234
330
  end
235
331
 
236
332
  owner, slug = parse_configuration_name(configuration)
237
333
  display_name = owner ? "#{owner}/#{slug}" : slug
238
334
 
239
- begin
240
- response = ui.spinner("Fetching #{display_name}...") do
241
- api.get_tree(display_name)
242
- end
243
-
244
- tree = response.is_a?(Array) ? response : response["tree"]
245
-
246
- # Security: Validate all paths before any file operations
247
- validate_tree_paths!(tree, base_path)
335
+ # Get files to remove - either from tracking or from remote tree
336
+ tracked_files = installed.files_for_project(display_name)
248
337
 
249
- # Find files that exist locally
250
- files_to_remove = []
251
- folders_to_remove = []
252
-
253
- tree.each do |node|
254
- local_path = File.join(base_path, node["path"])
255
-
256
- if node["kind"] == "folder"
257
- folders_to_remove << node["path"] if Dir.exist?(local_path)
258
- elsif File.exist?(local_path)
259
- files_to_remove << node["path"]
338
+ if tracked_files.empty?
339
+ # Fall back to fetching from remote (for v1 migrations or manual installs)
340
+ begin
341
+ response = ui.spinner("Fetching #{display_name}...") do
342
+ api.get_tree(display_name)
260
343
  end
261
- end
262
344
 
263
- if files_to_remove.empty? && folders_to_remove.empty?
264
- ui.info("No files from '#{display_name}' found in #{base_path}")
265
- return
345
+ tree = response.is_a?(Array) ? response : response["tree"]
346
+ validate_tree_paths!(tree, base_path)
347
+
348
+ tracked_files = tree.reject { |n| n["kind"] == "folder" }.map { |n| n["path"] }
349
+ rescue NotFoundError
350
+ ui.error("Configuration '#{display_name}' not found and no tracked files.")
351
+ exit EXIT_NOT_FOUND
352
+ rescue PermissionDeniedError
353
+ ui.error("You don't have permission to access '#{display_name}'.")
354
+ exit EXIT_PERMISSION_DENIED
355
+ rescue NetworkError => e
356
+ ui.error(e.message)
357
+ exit EXIT_NETWORK_ERROR
266
358
  end
359
+ end
267
360
 
268
- if options[:dry_run]
269
- ui.header("Would remove:")
270
- files_to_remove.each { |path| ui.display_file_operation(:would_create, path) }
271
- folders_to_remove.sort.reverse_each { |path| ui.display_file_operation(:would_create, path + "/") }
272
- ui.blank
273
- ui.info("No changes made (dry run)")
274
- return
361
+ # Find files that exist locally
362
+ files_to_remove = []
363
+ folders_to_check = Set.new
364
+
365
+ tracked_files.each do |path|
366
+ local_path = File.join(base_path, path)
367
+ if File.exist?(local_path)
368
+ files_to_remove << path
369
+ # Track parent folders for potential removal
370
+ dir = File.dirname(path)
371
+ while dir != "."
372
+ folders_to_check << dir
373
+ dir = File.dirname(dir)
374
+ end
275
375
  end
376
+ end
276
377
 
277
- unless ui.yes?("Remove #{files_to_remove.length} files and #{folders_to_remove.length} folders from '#{display_name}'?")
278
- ui.info("Uninstall cancelled")
279
- return
378
+ if files_to_remove.empty?
379
+ ui.info("No files from '#{display_name}' found in #{base_path}")
380
+
381
+ # Still remove from tracking if it exists
382
+ if installed.has_project?(display_name)
383
+ installed.remove_project(display_name)
384
+ installed.delete! if installed.empty?
280
385
  end
386
+ return
387
+ end
281
388
 
282
- ui.header("Uninstalling #{display_name}...")
389
+ if options[:dry_run]
390
+ ui.header("Would remove:")
391
+ files_to_remove.each { |path| ui.display_file_operation(:would_create, path) }
283
392
  ui.blank
393
+ ui.info("No changes made (dry run)")
394
+ return
395
+ end
284
396
 
285
- # Remove files first
286
- files_to_remove.each do |path|
287
- local_path = File.join(base_path, path)
288
- File.delete(local_path)
289
- ui.display_file_operation(:deleted, path)
290
- end
397
+ unless ui.yes?("Remove #{files_to_remove.length} files from '#{display_name}'?")
398
+ ui.info("Uninstall cancelled")
399
+ return
400
+ end
291
401
 
292
- # Remove folders (deepest first)
293
- folders_to_remove.sort.reverse_each do |path|
294
- local_path = File.join(base_path, path)
295
- if Dir.exist?(local_path) && Dir.empty?(local_path)
296
- Dir.rmdir(local_path)
297
- ui.display_file_operation(:deleted, path + "/")
298
- end
299
- end
402
+ ui.header("Uninstalling #{display_name}...")
403
+ ui.blank
404
+
405
+ # Remove files
406
+ files_to_remove.each do |path|
407
+ local_path = File.join(base_path, path)
408
+ File.delete(local_path)
409
+ ui.display_file_operation(:deleted, path)
410
+ end
300
411
 
301
- # Remove installation tracking file
302
- installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
303
- if File.exist?(installed_path)
304
- File.delete(installed_path)
305
- ui.display_file_operation(:deleted, Shai::Commands::Configurations::INSTALLED_FILE)
412
+ # Remove empty folders (deepest first)
413
+ folders_to_check.to_a.sort.reverse_each do |path|
414
+ local_path = File.join(base_path, path)
415
+ if Dir.exist?(local_path) && Dir.empty?(local_path)
416
+ Dir.rmdir(local_path)
417
+ ui.display_file_operation(:deleted, path + "/")
306
418
  end
419
+ end
307
420
 
308
- ui.blank
309
- ui.success("Uninstalled #{display_name}")
310
- rescue NotFoundError
311
- ui.error("Configuration '#{display_name}' not found.")
312
- exit EXIT_NOT_FOUND
313
- rescue PermissionDeniedError
314
- ui.error("You don't have permission to access '#{display_name}'.")
315
- exit EXIT_PERMISSION_DENIED
316
- rescue NetworkError => e
317
- ui.error(e.message)
318
- exit EXIT_NETWORK_ERROR
421
+ # Update tracking
422
+ installed.remove_project(display_name)
423
+
424
+ # Remove tracking file if no more projects
425
+ if installed.empty?
426
+ installed.delete!
427
+ ui.display_file_operation(:deleted, InstalledProjects::FILENAME)
428
+ end
429
+
430
+ ui.blank
431
+ ui.success("Uninstalled #{display_name}")
432
+
433
+ if installed.project_count > 0
434
+ ui.indent("#{installed.project_count} configuration(s) still installed")
319
435
  end
320
436
  end
321
437
  end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "time"
5
+
6
+ module Shai
7
+ class InstalledProjects
8
+ FILENAME = ".shai-installed"
9
+ CURRENT_VERSION = 2
10
+
11
+ attr_reader :base_path
12
+
13
+ def initialize(base_path)
14
+ @base_path = File.expand_path(base_path)
15
+ @data = load_data
16
+ end
17
+
18
+ def file_path
19
+ File.join(base_path, FILENAME)
20
+ end
21
+
22
+ def exists?
23
+ File.exist?(file_path)
24
+ end
25
+
26
+ def projects
27
+ @data["projects"] || {}
28
+ end
29
+
30
+ def project_slugs
31
+ projects.keys
32
+ end
33
+
34
+ def empty?
35
+ projects.empty?
36
+ end
37
+
38
+ def project_count
39
+ projects.size
40
+ end
41
+
42
+ def has_project?(slug)
43
+ projects.key?(slug)
44
+ end
45
+
46
+ def get_project(slug)
47
+ projects[slug]
48
+ end
49
+
50
+ def files_for_project(slug)
51
+ projects.dig(slug, "files") || []
52
+ end
53
+
54
+ def all_installed_files
55
+ projects.values.flat_map { |p| p["files"] || [] }
56
+ end
57
+
58
+ # Find which project owns a specific file
59
+ def project_for_file(file_path)
60
+ projects.each do |slug, data|
61
+ return slug if (data["files"] || []).include?(file_path)
62
+ end
63
+ nil
64
+ end
65
+
66
+ # Check for conflicts between new files and already installed projects
67
+ # Returns hash of { file_path => owning_project_slug }
68
+ def find_conflicts(new_files)
69
+ conflicts = {}
70
+ new_files.each do |file|
71
+ owner = project_for_file(file)
72
+ conflicts[file] = owner if owner
73
+ end
74
+ conflicts
75
+ end
76
+
77
+ # Add a new project with its files
78
+ def add_project(slug, files)
79
+ @data["projects"][slug] = {
80
+ "installed_at" => Time.now.iso8601,
81
+ "files" => files.sort
82
+ }
83
+ save!
84
+ end
85
+
86
+ # Remove a project and return its files
87
+ def remove_project(slug)
88
+ project = @data["projects"].delete(slug)
89
+ save! if project
90
+ project&.dig("files") || []
91
+ end
92
+
93
+ # Remove specific files from a project (when being overwritten)
94
+ def remove_files_from_project(slug, files_to_remove)
95
+ return unless @data["projects"][slug]
96
+
97
+ current_files = @data["projects"][slug]["files"] || []
98
+ @data["projects"][slug]["files"] = current_files - files_to_remove
99
+
100
+ # If no files left, remove the project entirely
101
+ if @data["projects"][slug]["files"].empty?
102
+ @data["projects"].delete(slug)
103
+ end
104
+
105
+ save!
106
+ end
107
+
108
+ def save!
109
+ content = YAML.dump(@data)
110
+ header = "# Installed by shai - do not edit manually\n"
111
+ File.write(file_path, header + content)
112
+ end
113
+
114
+ def delete!
115
+ File.delete(file_path) if exists?
116
+ end
117
+
118
+ private
119
+
120
+ def load_data
121
+ return default_data unless exists?
122
+
123
+ raw = YAML.safe_load_file(file_path) || {}
124
+
125
+ # Handle old format (version 1 / no version)
126
+ if raw["version"].nil? || raw["version"] < CURRENT_VERSION
127
+ migrate_from_v1(raw)
128
+ else
129
+ raw
130
+ end
131
+ rescue
132
+ # If file is corrupted, start fresh
133
+ default_data
134
+ end
135
+
136
+ def default_data
137
+ {
138
+ "version" => CURRENT_VERSION,
139
+ "projects" => {}
140
+ }
141
+ end
142
+
143
+ def migrate_from_v1(old_data)
144
+ # Old format had: { "slug" => "name", "installed_at" => "..." }
145
+ slug = old_data["slug"]
146
+ installed_at = old_data["installed_at"]
147
+
148
+ if slug
149
+ {
150
+ "version" => CURRENT_VERSION,
151
+ "projects" => {
152
+ slug => {
153
+ "installed_at" => installed_at || Time.now.iso8601,
154
+ "files" => [] # We don't know the files from v1, will be populated on next operation
155
+ }
156
+ }
157
+ }
158
+ else
159
+ default_data
160
+ end
161
+ end
162
+ end
163
+ end
data/lib/shai/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Shai
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end