shai-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 +7 -0
- data/LICENSE +21 -0
- data/README.md +192 -0
- data/bin/shai +23 -0
- data/lib/shai/api_client.rb +146 -0
- data/lib/shai/cli.rb +96 -0
- data/lib/shai/commands/auth.rb +70 -0
- data/lib/shai/commands/config.rb +168 -0
- data/lib/shai/commands/configurations.rb +393 -0
- data/lib/shai/commands/sync.rb +464 -0
- data/lib/shai/configuration.rb +65 -0
- data/lib/shai/credentials.rb +79 -0
- data/lib/shai/ui.rb +201 -0
- data/lib/shai/version.rb +5 -0
- data/lib/shai.rb +54 -0
- metadata +158 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Shai
|
|
6
|
+
module Commands
|
|
7
|
+
module Config
|
|
8
|
+
SHAIRC_FILE = ".shairc"
|
|
9
|
+
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.class_eval do
|
|
12
|
+
desc "config SUBCOMMAND", "Manage configuration metadata"
|
|
13
|
+
def config(subcommand = nil, *args)
|
|
14
|
+
case subcommand
|
|
15
|
+
when "show"
|
|
16
|
+
config_show
|
|
17
|
+
when "set"
|
|
18
|
+
config_set(args)
|
|
19
|
+
when nil
|
|
20
|
+
ui.error("Missing subcommand. Use `shai config show` or `shai config set`")
|
|
21
|
+
exit EXIT_INVALID_INPUT
|
|
22
|
+
else
|
|
23
|
+
ui.error("Unknown subcommand: #{subcommand}")
|
|
24
|
+
exit EXIT_INVALID_INPUT
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "delete SLUG", "Delete a configuration"
|
|
29
|
+
def delete(slug)
|
|
30
|
+
require_auth!
|
|
31
|
+
|
|
32
|
+
unless ui.yes?("Are you sure you want to delete '#{slug}'? This cannot be undone.")
|
|
33
|
+
ui.info("Cancelled")
|
|
34
|
+
return
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
begin
|
|
38
|
+
ui.spinner("Deleting...") do
|
|
39
|
+
api.delete_configuration(slug)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
ui.success("Configuration '#{slug}' deleted")
|
|
43
|
+
rescue NotFoundError
|
|
44
|
+
ui.error("Configuration '#{slug}' not found.")
|
|
45
|
+
exit EXIT_NOT_FOUND
|
|
46
|
+
rescue PermissionDeniedError
|
|
47
|
+
ui.error("You don't have permission to delete '#{slug}'.")
|
|
48
|
+
exit EXIT_PERMISSION_DENIED
|
|
49
|
+
rescue NetworkError => e
|
|
50
|
+
ui.error(e.message)
|
|
51
|
+
exit EXIT_NETWORK_ERROR
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def config_show
|
|
60
|
+
require_auth!
|
|
61
|
+
|
|
62
|
+
shairc = load_shairc_for_config
|
|
63
|
+
slug = shairc["slug"]
|
|
64
|
+
|
|
65
|
+
begin
|
|
66
|
+
response = ui.spinner("Fetching configuration...") do
|
|
67
|
+
api.get_configuration(slug)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
config = response["configuration"] || response
|
|
71
|
+
|
|
72
|
+
ui.info("Configuration: #{config["slug"]}")
|
|
73
|
+
ui.info("Name: #{config["name"]}")
|
|
74
|
+
ui.info("Description: #{config["description"] || "(none)"}")
|
|
75
|
+
ui.info("Visibility: #{config["visibility"]}")
|
|
76
|
+
owner = config["owner"]
|
|
77
|
+
owner_name = owner.is_a?(Hash) ? owner["username"] : owner
|
|
78
|
+
ui.info("Owner: #{owner_name || credentials.username}")
|
|
79
|
+
ui.info("Stars: #{config["stars_count"] || 0}")
|
|
80
|
+
ui.info("URL: #{Shai.configuration.api_url}/#{credentials.username}/#{config["slug"]}")
|
|
81
|
+
ui.info("Created: #{format_config_date(config["created_at"])}")
|
|
82
|
+
ui.info("Updated: #{format_config_date(config["updated_at"])}")
|
|
83
|
+
rescue NotFoundError
|
|
84
|
+
ui.error("Configuration '#{slug}' not found.")
|
|
85
|
+
exit EXIT_NOT_FOUND
|
|
86
|
+
rescue NetworkError => e
|
|
87
|
+
ui.error(e.message)
|
|
88
|
+
exit EXIT_NETWORK_ERROR
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def config_set(args)
|
|
93
|
+
require_auth!
|
|
94
|
+
|
|
95
|
+
if args.length < 2
|
|
96
|
+
ui.error("Usage: shai config set <key> <value>")
|
|
97
|
+
ui.info("Valid keys: name, description, visibility")
|
|
98
|
+
exit EXIT_INVALID_INPUT
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
key = args[0]
|
|
102
|
+
value = args[1..].join(" ")
|
|
103
|
+
|
|
104
|
+
valid_keys = %w[name description visibility]
|
|
105
|
+
unless valid_keys.include?(key)
|
|
106
|
+
ui.error("Invalid key: #{key}")
|
|
107
|
+
ui.info("Valid keys: #{valid_keys.join(", ")}")
|
|
108
|
+
exit EXIT_INVALID_INPUT
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if key == "visibility" && !%w[public private].include?(value)
|
|
112
|
+
ui.error("Visibility must be 'public' or 'private'")
|
|
113
|
+
exit EXIT_INVALID_INPUT
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
shairc = load_shairc_for_config
|
|
117
|
+
slug = shairc["slug"]
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
ui.spinner("Updating...") do
|
|
121
|
+
api.update_configuration(slug, key.to_sym => value)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
ui.success("Updated #{key} to '#{value}'")
|
|
125
|
+
rescue NotFoundError
|
|
126
|
+
ui.error("Configuration '#{slug}' not found.")
|
|
127
|
+
exit EXIT_NOT_FOUND
|
|
128
|
+
rescue PermissionDeniedError
|
|
129
|
+
ui.error("You don't have permission to modify this configuration.")
|
|
130
|
+
exit EXIT_PERMISSION_DENIED
|
|
131
|
+
rescue InvalidConfigurationError => e
|
|
132
|
+
ui.error(e.message)
|
|
133
|
+
exit EXIT_INVALID_INPUT
|
|
134
|
+
rescue NetworkError => e
|
|
135
|
+
ui.error(e.message)
|
|
136
|
+
exit EXIT_NETWORK_ERROR
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def load_shairc_for_config
|
|
141
|
+
unless File.exist?(SHAIRC_FILE)
|
|
142
|
+
ui.error("No #{SHAIRC_FILE} file found. Run `shai init` to create one.")
|
|
143
|
+
exit EXIT_INVALID_INPUT
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
shairc = YAML.safe_load_file(SHAIRC_FILE)
|
|
147
|
+
|
|
148
|
+
unless shairc["slug"]
|
|
149
|
+
ui.error("Invalid #{SHAIRC_FILE} file. Missing required field: slug")
|
|
150
|
+
exit EXIT_INVALID_INPUT
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
shairc
|
|
154
|
+
rescue Psych::SyntaxError => e
|
|
155
|
+
ui.error("Invalid #{SHAIRC_FILE} file: #{e.message}")
|
|
156
|
+
exit EXIT_INVALID_INPUT
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def format_config_date(date_string)
|
|
160
|
+
return "(unknown)" unless date_string
|
|
161
|
+
|
|
162
|
+
Time.parse(date_string).strftime("%B %-d, %Y")
|
|
163
|
+
rescue ArgumentError
|
|
164
|
+
date_string
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Shai
|
|
6
|
+
module Commands
|
|
7
|
+
module Configurations
|
|
8
|
+
INSTALLED_FILE = ".shai-installed"
|
|
9
|
+
|
|
10
|
+
def self.included(base)
|
|
11
|
+
base.class_eval do
|
|
12
|
+
desc "list", "List your configurations"
|
|
13
|
+
def list
|
|
14
|
+
require_auth!
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
response = ui.spinner("Fetching configurations...") do
|
|
18
|
+
api.list_configurations
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
configs = response.is_a?(Array) ? response : response["configurations"]
|
|
22
|
+
|
|
23
|
+
if configs.empty?
|
|
24
|
+
ui.info("You don't have any configurations yet.")
|
|
25
|
+
ui.info("Run `shai init` to create one.")
|
|
26
|
+
else
|
|
27
|
+
ui.header("Your configurations:")
|
|
28
|
+
ui.blank
|
|
29
|
+
configs.each do |config|
|
|
30
|
+
ui.display_configuration(config)
|
|
31
|
+
ui.indent("Updated: #{time_ago(config["updated_at"])}")
|
|
32
|
+
ui.blank
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
rescue NetworkError => e
|
|
36
|
+
ui.error(e.message)
|
|
37
|
+
exit EXIT_NETWORK_ERROR
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
desc "search QUERY", "Search public configurations"
|
|
42
|
+
option :tag, type: :array, default: [], desc: "Filter by tags"
|
|
43
|
+
def search(query = nil)
|
|
44
|
+
tags = options[:tag] || []
|
|
45
|
+
|
|
46
|
+
if query.nil? && tags.empty?
|
|
47
|
+
ui.error("Please provide a search query or tags")
|
|
48
|
+
exit EXIT_INVALID_INPUT
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
begin
|
|
52
|
+
response = ui.spinner("Searching...") do
|
|
53
|
+
api.search_configurations(query: query, tags: tags)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
configs = response.is_a?(Array) ? response : response["configurations"]
|
|
57
|
+
search_term = query ? "\"#{query}\"" : "tags: #{tags.join(", ")}"
|
|
58
|
+
|
|
59
|
+
if configs.empty?
|
|
60
|
+
ui.info("No configurations found for #{search_term}")
|
|
61
|
+
else
|
|
62
|
+
ui.header("Search results for #{search_term}:")
|
|
63
|
+
ui.blank
|
|
64
|
+
configs.each do |config|
|
|
65
|
+
ui.display_configuration(config, detailed: true)
|
|
66
|
+
ui.blank
|
|
67
|
+
end
|
|
68
|
+
ui.info("Found #{configs.length} configuration(s). Use `shai install <name>` to install.")
|
|
69
|
+
end
|
|
70
|
+
rescue NetworkError => e
|
|
71
|
+
ui.error(e.message)
|
|
72
|
+
exit EXIT_NETWORK_ERROR
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
desc "install CONFIGURATION", "Install a configuration to local project"
|
|
77
|
+
option :force, type: :boolean, aliases: "-f", default: false, desc: "Overwrite existing files"
|
|
78
|
+
option :dry_run, type: :boolean, default: false, desc: "Show what would be installed"
|
|
79
|
+
option :path, type: :string, default: ".", desc: "Install to specific directory"
|
|
80
|
+
def install(configuration)
|
|
81
|
+
owner, slug = parse_configuration_name(configuration)
|
|
82
|
+
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
83
|
+
base_path = File.expand_path(options[:path])
|
|
84
|
+
shairc_path = File.join(base_path, ".shairc")
|
|
85
|
+
installed_path = File.join(base_path, Shai::Commands::Configurations::INSTALLED_FILE)
|
|
86
|
+
|
|
87
|
+
# Check if a configuration is already installed/initialized
|
|
88
|
+
unless options[:force]
|
|
89
|
+
existing_slug = nil
|
|
90
|
+
|
|
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"]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
if existing_slug
|
|
108
|
+
ui.error("A configuration is already present in this directory.")
|
|
109
|
+
ui.indent("Existing: #{existing_slug}")
|
|
110
|
+
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)")
|
|
116
|
+
exit EXIT_INVALID_INPUT
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
response = ui.spinner("Fetching #{display_name}...") do
|
|
122
|
+
api.get_tree(display_name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
tree = response["tree"]
|
|
126
|
+
|
|
127
|
+
# Security: Validate all paths before any file operations
|
|
128
|
+
validate_tree_paths!(tree, base_path)
|
|
129
|
+
|
|
130
|
+
# Check for conflicts
|
|
131
|
+
conflicts = []
|
|
132
|
+
tree.each do |node|
|
|
133
|
+
next if node["kind"] == "folder"
|
|
134
|
+
|
|
135
|
+
local_path = File.join(base_path, node["path"])
|
|
136
|
+
conflicts << node["path"] if File.exist?(local_path)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if options[:dry_run]
|
|
140
|
+
ui.header("Would install:")
|
|
141
|
+
tree.each { |node| ui.display_file_operation(:would_create, node["path"]) }
|
|
142
|
+
ui.blank
|
|
143
|
+
ui.info("No changes made (dry run)")
|
|
144
|
+
return
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# 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) }
|
|
152
|
+
ui.blank
|
|
153
|
+
|
|
154
|
+
choice = ui.select("Overwrite existing files?", [
|
|
155
|
+
{name: "Yes", value: :yes},
|
|
156
|
+
{name: "No (abort)", value: :no},
|
|
157
|
+
{name: "Show diff", value: :diff}
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
if choice == :diff
|
|
161
|
+
show_install_diff(tree, base_path, conflicts)
|
|
162
|
+
return unless ui.yes?("Overwrite existing files?")
|
|
163
|
+
elsif choice == :no
|
|
164
|
+
ui.info("Installation cancelled")
|
|
165
|
+
return
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
ui.header("Installing #{display_name}...")
|
|
170
|
+
ui.blank
|
|
171
|
+
|
|
172
|
+
# Create folders and files
|
|
173
|
+
created_count = 0
|
|
174
|
+
tree.sort_by { |n| (n["kind"] == "folder") ? 0 : 1 }.each do |node|
|
|
175
|
+
local_path = File.join(base_path, node["path"])
|
|
176
|
+
|
|
177
|
+
if node["kind"] == "folder"
|
|
178
|
+
FileUtils.mkdir_p(local_path)
|
|
179
|
+
ui.display_file_operation(:created, node["path"] + "/")
|
|
180
|
+
else
|
|
181
|
+
FileUtils.mkdir_p(File.dirname(local_path))
|
|
182
|
+
File.write(local_path, node["content"])
|
|
183
|
+
ui.display_file_operation(:created, node["path"])
|
|
184
|
+
end
|
|
185
|
+
created_count += 1
|
|
186
|
+
end
|
|
187
|
+
|
|
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)
|
|
195
|
+
|
|
196
|
+
ui.blank
|
|
197
|
+
ui.success("Installed #{created_count} items from #{display_name}")
|
|
198
|
+
rescue NotFoundError
|
|
199
|
+
ui.error("Configuration '#{display_name}' not found.")
|
|
200
|
+
exit EXIT_NOT_FOUND
|
|
201
|
+
rescue PermissionDeniedError
|
|
202
|
+
ui.error("You don't have permission to access '#{display_name}'.")
|
|
203
|
+
exit EXIT_PERMISSION_DENIED
|
|
204
|
+
rescue NetworkError => e
|
|
205
|
+
ui.error(e.message)
|
|
206
|
+
exit EXIT_NETWORK_ERROR
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
desc "uninstall [CONFIGURATION]", "Remove an installed configuration from local project"
|
|
211
|
+
option :dry_run, type: :boolean, default: false, desc: "Show what would be removed"
|
|
212
|
+
option :path, type: :string, default: ".", desc: "Path where configuration is installed"
|
|
213
|
+
def uninstall(configuration = nil)
|
|
214
|
+
require_auth!
|
|
215
|
+
|
|
216
|
+
base_path = File.expand_path(options[:path])
|
|
217
|
+
installed_path = File.join(base_path, INSTALLED_FILE)
|
|
218
|
+
|
|
219
|
+
# If no configuration specified, try to read from .shai-installed
|
|
220
|
+
if configuration.nil?
|
|
221
|
+
unless File.exist?(installed_path)
|
|
222
|
+
ui.error("No configuration specified and no .shai-installed file found.")
|
|
223
|
+
ui.info("Usage: shai uninstall <configuration>")
|
|
224
|
+
exit EXIT_INVALID_INPUT
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
installed_config = YAML.safe_load_file(installed_path)
|
|
228
|
+
configuration = installed_config["slug"]
|
|
229
|
+
|
|
230
|
+
unless configuration
|
|
231
|
+
ui.error("Could not read configuration from .shai-installed")
|
|
232
|
+
exit EXIT_INVALID_INPUT
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
owner, slug = parse_configuration_name(configuration)
|
|
237
|
+
display_name = owner ? "#{owner}/#{slug}" : slug
|
|
238
|
+
|
|
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)
|
|
248
|
+
|
|
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"]
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
if files_to_remove.empty? && folders_to_remove.empty?
|
|
264
|
+
ui.info("No files from '#{display_name}' found in #{base_path}")
|
|
265
|
+
return
|
|
266
|
+
end
|
|
267
|
+
|
|
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
|
|
275
|
+
end
|
|
276
|
+
|
|
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
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
ui.header("Uninstalling #{display_name}...")
|
|
283
|
+
ui.blank
|
|
284
|
+
|
|
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
|
|
291
|
+
|
|
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
|
|
300
|
+
|
|
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)
|
|
306
|
+
end
|
|
307
|
+
|
|
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
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
private
|
|
325
|
+
|
|
326
|
+
def parse_configuration_name(name)
|
|
327
|
+
if name.include?("/")
|
|
328
|
+
name.split("/", 2)
|
|
329
|
+
else
|
|
330
|
+
[nil, name]
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Security: Validate that a path is safe and doesn't escape base_path
|
|
335
|
+
def safe_path?(path, base_path)
|
|
336
|
+
return false if path.nil? || path.empty?
|
|
337
|
+
return false if path.start_with?("/") # No absolute paths
|
|
338
|
+
return false if path.include?("..") # No directory traversal
|
|
339
|
+
return false if path.include?("\0") # No null bytes
|
|
340
|
+
|
|
341
|
+
# Verify resolved path stays within base_path
|
|
342
|
+
full_path = File.expand_path(path, base_path)
|
|
343
|
+
full_path.start_with?(File.expand_path(base_path) + "/") || full_path == File.expand_path(base_path)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def validate_tree_paths!(tree, base_path)
|
|
347
|
+
tree.each do |node|
|
|
348
|
+
unless safe_path?(node["path"], base_path)
|
|
349
|
+
raise SecurityError, "Invalid path detected: #{node["path"].inspect}"
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def time_ago(timestamp)
|
|
355
|
+
return "unknown" unless timestamp
|
|
356
|
+
|
|
357
|
+
seconds = Time.now - Time.parse(timestamp)
|
|
358
|
+
case seconds
|
|
359
|
+
when 0..59
|
|
360
|
+
"just now"
|
|
361
|
+
when 60..3599
|
|
362
|
+
"#{(seconds / 60).to_i} minutes ago"
|
|
363
|
+
when 3600..86399
|
|
364
|
+
hours = (seconds / 3600).to_i
|
|
365
|
+
(hours == 1) ? "1 hour ago" : "#{hours} hours ago"
|
|
366
|
+
when 86400..604799
|
|
367
|
+
days = (seconds / 86400).to_i
|
|
368
|
+
(days == 1) ? "yesterday" : "#{days} days ago"
|
|
369
|
+
else
|
|
370
|
+
Time.parse(timestamp).strftime("%B %-d, %Y")
|
|
371
|
+
end
|
|
372
|
+
rescue ArgumentError
|
|
373
|
+
timestamp
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def show_install_diff(tree, base_path, conflicts)
|
|
377
|
+
require "diffy"
|
|
378
|
+
|
|
379
|
+
conflicts.each do |path|
|
|
380
|
+
local_path = File.join(base_path, path)
|
|
381
|
+
local_content = File.read(local_path)
|
|
382
|
+
remote_content = tree.find { |n| n["path"] == path }&.dig("content") || ""
|
|
383
|
+
|
|
384
|
+
ui.info("--- local #{path}")
|
|
385
|
+
ui.info("+++ remote #{path}")
|
|
386
|
+
diff = Diffy::Diff.new(local_content, remote_content, context: 3)
|
|
387
|
+
ui.diff(diff.to_s)
|
|
388
|
+
ui.blank
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|