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.
- checksums.yaml +4 -4
- data/README.md +158 -81
- data/lib/shai/api_client.rb +49 -0
- data/lib/shai/cli.rb +4 -1
- data/lib/shai/commands/auth.rb +154 -26
- data/lib/shai/commands/configurations.rb +243 -127
- data/lib/shai/installed_projects.rb +163 -0
- data/lib/shai/version.rb +1 -1
- data/lib/shai.rb +36 -0
- metadata +16 -1
|
@@ -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
|
-
|
|
88
|
-
unless options[:force]
|
|
89
|
-
existing_slug = nil
|
|
84
|
+
installed = InstalledProjects.new(base_path)
|
|
90
85
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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("
|
|
103
|
+
ui.error("This directory contains an authored configuration (.shairc).")
|
|
109
104
|
ui.indent("Existing: #{existing_slug}")
|
|
110
105
|
ui.blank
|
|
111
|
-
ui.info("
|
|
112
|
-
ui.
|
|
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
|
-
#
|
|
131
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
|
162
|
-
return unless ui.yes?("
|
|
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
|
-
|
|
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
|
-
#
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 #{
|
|
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
|
-
|
|
309
|
+
installed = InstalledProjects.new(base_path)
|
|
218
310
|
|
|
219
|
-
# If no configuration specified,
|
|
311
|
+
# If no configuration specified, determine which to uninstall
|
|
220
312
|
if configuration.nil?
|
|
221
|
-
|
|
222
|
-
ui.error("No
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
ui.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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