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,464 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "diffy"
|
|
5
|
+
|
|
6
|
+
module Shai
|
|
7
|
+
module Commands
|
|
8
|
+
module Sync
|
|
9
|
+
SHAIRC_FILE = ".shairc"
|
|
10
|
+
|
|
11
|
+
def self.included(base)
|
|
12
|
+
base.class_eval do
|
|
13
|
+
desc "init", "Initialize a new configuration"
|
|
14
|
+
def init
|
|
15
|
+
require_auth!
|
|
16
|
+
|
|
17
|
+
if File.exist?(SHAIRC_FILE)
|
|
18
|
+
ui.error("A #{SHAIRC_FILE} file already exists in this directory.")
|
|
19
|
+
ui.info("Use `shai push` to upload changes to the existing configuration.")
|
|
20
|
+
exit EXIT_INVALID_INPUT
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
name = ui.ask("Configuration name:")
|
|
24
|
+
description = ui.ask("Description (optional):")
|
|
25
|
+
visibility = ui.select("Visibility:", %w[private public], default: "private")
|
|
26
|
+
include_patterns = ui.ask("Include paths (glob patterns, comma-separated):", default: ".claude/**,.cursor/**")
|
|
27
|
+
|
|
28
|
+
ui.blank
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
response = ui.spinner("Creating configuration...") do
|
|
32
|
+
api.create_configuration(
|
|
33
|
+
name: name,
|
|
34
|
+
description: description.to_s.empty? ? nil : description,
|
|
35
|
+
visibility: visibility
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
config = response["configuration"] || response
|
|
40
|
+
slug = config["slug"]
|
|
41
|
+
username = credentials.username
|
|
42
|
+
|
|
43
|
+
# Write .shairc file
|
|
44
|
+
shairc_content = <<~YAML
|
|
45
|
+
# .shairc - Shai configuration
|
|
46
|
+
slug: #{slug}
|
|
47
|
+
include:
|
|
48
|
+
#{include_patterns.split(",").map { |p| " - #{p.strip}" }.join("\n")}
|
|
49
|
+
exclude:
|
|
50
|
+
- "**/*.local.*"
|
|
51
|
+
- "**/.env"
|
|
52
|
+
YAML
|
|
53
|
+
|
|
54
|
+
File.write(SHAIRC_FILE, shairc_content)
|
|
55
|
+
|
|
56
|
+
ui.success("Created #{slug}")
|
|
57
|
+
ui.indent("Remote: #{Shai.configuration.api_url}/#{username}/#{slug}")
|
|
58
|
+
ui.blank
|
|
59
|
+
ui.info("Next steps:")
|
|
60
|
+
ui.indent("1. Add or modify files matching your include patterns")
|
|
61
|
+
ui.indent("2. Run `shai push` to upload your configuration")
|
|
62
|
+
rescue InvalidConfigurationError => e
|
|
63
|
+
ui.error(e.message)
|
|
64
|
+
exit EXIT_INVALID_INPUT
|
|
65
|
+
rescue NetworkError => e
|
|
66
|
+
ui.error(e.message)
|
|
67
|
+
exit EXIT_NETWORK_ERROR
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
desc "push", "Push local changes to remote"
|
|
72
|
+
option :dry_run, type: :boolean, default: false, desc: "Show what would be pushed"
|
|
73
|
+
option :message, type: :string, aliases: "-m", desc: "Add a message (future feature)"
|
|
74
|
+
def push
|
|
75
|
+
require_auth!
|
|
76
|
+
|
|
77
|
+
shairc = load_shairc
|
|
78
|
+
slug = shairc["slug"]
|
|
79
|
+
|
|
80
|
+
# Build tree from local files
|
|
81
|
+
tree = build_local_tree(shairc)
|
|
82
|
+
|
|
83
|
+
if tree.empty?
|
|
84
|
+
ui.warning("No files found matching include patterns.")
|
|
85
|
+
ui.info("Check your .shairc include patterns.")
|
|
86
|
+
exit EXIT_INVALID_INPUT
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
display_name = "#{credentials.username}/#{slug}"
|
|
90
|
+
|
|
91
|
+
if options[:dry_run]
|
|
92
|
+
ui.header("Would push to #{display_name}:")
|
|
93
|
+
tree.each { |node| ui.display_file_operation(:uploaded, node[:path]) }
|
|
94
|
+
ui.blank
|
|
95
|
+
ui.info("No changes made (dry run)")
|
|
96
|
+
return
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
ui.header("Pushing to #{display_name}...")
|
|
100
|
+
ui.blank
|
|
101
|
+
|
|
102
|
+
tree.each { |node| ui.display_file_operation(:uploaded, node[:path]) }
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
ui.spinner("Uploading...") do
|
|
106
|
+
api.update_tree(slug, tree)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
ui.blank
|
|
110
|
+
ui.success("Pushed #{tree.length} items")
|
|
111
|
+
ui.indent("View at: #{Shai.configuration.api_url}/#{credentials.username}/#{slug}")
|
|
112
|
+
rescue NotFoundError
|
|
113
|
+
ui.error("Configuration '#{slug}' not found. Run `shai init` first.")
|
|
114
|
+
exit EXIT_NOT_FOUND
|
|
115
|
+
rescue PermissionDeniedError
|
|
116
|
+
ui.error("You don't have permission to modify '#{display_name}'.")
|
|
117
|
+
exit EXIT_PERMISSION_DENIED
|
|
118
|
+
rescue NetworkError => e
|
|
119
|
+
ui.error(e.message)
|
|
120
|
+
exit EXIT_NETWORK_ERROR
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc "status", "Show local changes"
|
|
125
|
+
def status
|
|
126
|
+
require_auth!
|
|
127
|
+
|
|
128
|
+
shairc = load_shairc
|
|
129
|
+
slug = shairc["slug"]
|
|
130
|
+
|
|
131
|
+
display_name = "#{credentials.username}/#{slug}"
|
|
132
|
+
|
|
133
|
+
begin
|
|
134
|
+
remote_tree = ui.spinner("Fetching remote state...") do
|
|
135
|
+
api.get_tree(slug)["tree"]
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
local_tree = build_local_tree(shairc)
|
|
139
|
+
|
|
140
|
+
# Compare trees
|
|
141
|
+
remote_files = remote_tree.select { |n| n["kind"] == "file" }
|
|
142
|
+
.each_with_object({}) { |n, h| h[n["path"]] = n["content"] }
|
|
143
|
+
local_files = local_tree.select { |n| n[:kind] == "file" }
|
|
144
|
+
.each_with_object({}) { |n, h| h[n[:path]] = n[:content] }
|
|
145
|
+
|
|
146
|
+
modified = []
|
|
147
|
+
new_files = []
|
|
148
|
+
deleted = []
|
|
149
|
+
|
|
150
|
+
local_files.each do |path, content|
|
|
151
|
+
if remote_files.key?(path)
|
|
152
|
+
modified << path if remote_files[path] != content
|
|
153
|
+
else
|
|
154
|
+
new_files << path
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
remote_files.each_key do |path|
|
|
159
|
+
deleted << path unless local_files.key?(path)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
ui.header("Configuration: #{display_name}")
|
|
163
|
+
|
|
164
|
+
if modified.empty? && new_files.empty? && deleted.empty?
|
|
165
|
+
ui.info("Status: Up to date")
|
|
166
|
+
ui.blank
|
|
167
|
+
ui.info("No local changes detected.")
|
|
168
|
+
else
|
|
169
|
+
ui.info("Status: Local changes")
|
|
170
|
+
ui.blank
|
|
171
|
+
|
|
172
|
+
if modified.any?
|
|
173
|
+
ui.info("Modified:")
|
|
174
|
+
modified.each { |path| ui.indent(path) }
|
|
175
|
+
ui.blank
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
if new_files.any?
|
|
179
|
+
ui.info("New:")
|
|
180
|
+
new_files.each { |path| ui.indent(path) }
|
|
181
|
+
ui.blank
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
if deleted.any?
|
|
185
|
+
ui.info("Deleted (remote only):")
|
|
186
|
+
deleted.each { |path| ui.indent(path) }
|
|
187
|
+
ui.blank
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
ui.info("Run `shai push` to upload changes.")
|
|
191
|
+
end
|
|
192
|
+
rescue NotFoundError
|
|
193
|
+
ui.error("Configuration '#{slug}' not found on remote.")
|
|
194
|
+
exit EXIT_NOT_FOUND
|
|
195
|
+
rescue NetworkError => e
|
|
196
|
+
ui.error(e.message)
|
|
197
|
+
exit EXIT_NETWORK_ERROR
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
desc "pull", "Pull remote changes to local"
|
|
202
|
+
option :dry_run, type: :boolean, default: false, desc: "Show what would be pulled"
|
|
203
|
+
option :force, type: :boolean, default: false, aliases: "-f", desc: "Overwrite local files without prompting"
|
|
204
|
+
def pull
|
|
205
|
+
require_auth!
|
|
206
|
+
|
|
207
|
+
shairc = load_shairc
|
|
208
|
+
slug = shairc["slug"]
|
|
209
|
+
|
|
210
|
+
display_name = "#{credentials.username}/#{slug}"
|
|
211
|
+
|
|
212
|
+
begin
|
|
213
|
+
remote_tree = ui.spinner("Fetching remote state...") do
|
|
214
|
+
api.get_tree(slug)["tree"]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Security: Validate all paths before any file operations
|
|
218
|
+
validate_tree_paths!(remote_tree, Dir.pwd)
|
|
219
|
+
|
|
220
|
+
local_tree = build_local_tree(shairc)
|
|
221
|
+
|
|
222
|
+
# Build lookup maps
|
|
223
|
+
remote_files = remote_tree.select { |n| n["kind"] == "file" }
|
|
224
|
+
.each_with_object({}) { |n, h| h[n["path"]] = n["content"] }
|
|
225
|
+
local_files = local_tree.select { |n| n[:kind] == "file" }
|
|
226
|
+
.each_with_object({}) { |n, h| h[n[:path]] = n[:content] }
|
|
227
|
+
|
|
228
|
+
# Categorize changes
|
|
229
|
+
to_create = []
|
|
230
|
+
to_update = []
|
|
231
|
+
|
|
232
|
+
remote_files.each do |path, content|
|
|
233
|
+
if local_files.key?(path)
|
|
234
|
+
to_update << {path: path, content: content} if local_files[path] != content
|
|
235
|
+
else
|
|
236
|
+
to_create << {path: path, content: content}
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
if to_create.empty? && to_update.empty?
|
|
241
|
+
ui.info("Already up to date with #{display_name}")
|
|
242
|
+
return
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Dry run mode
|
|
246
|
+
if options[:dry_run]
|
|
247
|
+
ui.header("Would pull from #{display_name}:")
|
|
248
|
+
ui.blank
|
|
249
|
+
to_create.each { |f| ui.display_file_operation(:would_create, f[:path]) }
|
|
250
|
+
to_update.each { |f| ui.display_file_operation(:would_update, f[:path]) }
|
|
251
|
+
ui.blank
|
|
252
|
+
ui.info("No changes made (dry run)")
|
|
253
|
+
return
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Check for conflicts (files that would be overwritten)
|
|
257
|
+
unless options[:force] || to_update.empty?
|
|
258
|
+
ui.warning("The following local files will be overwritten:")
|
|
259
|
+
to_update.each { |f| ui.indent(f[:path]) }
|
|
260
|
+
ui.blank
|
|
261
|
+
|
|
262
|
+
unless ui.yes?("Continue and overwrite these files?")
|
|
263
|
+
ui.info("Pull cancelled.")
|
|
264
|
+
return
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Apply changes
|
|
269
|
+
ui.header("Pulling from #{display_name}...")
|
|
270
|
+
ui.blank
|
|
271
|
+
|
|
272
|
+
# Create necessary folders
|
|
273
|
+
all_folders = Set.new
|
|
274
|
+
(to_create + to_update).each do |file|
|
|
275
|
+
parts = File.dirname(file[:path]).split("/")
|
|
276
|
+
parts.each_with_index do |_, i|
|
|
277
|
+
folder_path = parts[0..i].join("/")
|
|
278
|
+
all_folders << folder_path unless folder_path == "."
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
all_folders.sort.each do |folder|
|
|
283
|
+
unless Dir.exist?(folder)
|
|
284
|
+
FileUtils.mkdir_p(folder)
|
|
285
|
+
ui.display_file_operation(:created, folder)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Create new files
|
|
290
|
+
to_create.each do |file|
|
|
291
|
+
File.write(file[:path], file[:content])
|
|
292
|
+
ui.display_file_operation(:created, file[:path])
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Update existing files
|
|
296
|
+
to_update.each do |file|
|
|
297
|
+
File.write(file[:path], file[:content])
|
|
298
|
+
ui.display_file_operation(:updated, file[:path])
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
ui.blank
|
|
302
|
+
ui.success("Pulled #{to_create.length + to_update.length} items from #{display_name}")
|
|
303
|
+
rescue NotFoundError
|
|
304
|
+
ui.error("Configuration '#{slug}' not found on remote.")
|
|
305
|
+
exit EXIT_NOT_FOUND
|
|
306
|
+
rescue PermissionDeniedError
|
|
307
|
+
ui.error("You don't have permission to access '#{display_name}'.")
|
|
308
|
+
exit EXIT_PERMISSION_DENIED
|
|
309
|
+
rescue NetworkError => e
|
|
310
|
+
ui.error(e.message)
|
|
311
|
+
exit EXIT_NETWORK_ERROR
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
desc "diff", "Show diff between local and remote"
|
|
316
|
+
def diff
|
|
317
|
+
require_auth!
|
|
318
|
+
|
|
319
|
+
shairc = load_shairc
|
|
320
|
+
slug = shairc["slug"]
|
|
321
|
+
|
|
322
|
+
begin
|
|
323
|
+
remote_tree = ui.spinner("Fetching remote state...") do
|
|
324
|
+
api.get_tree(slug)["tree"]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
local_tree = build_local_tree(shairc)
|
|
328
|
+
|
|
329
|
+
remote_files = remote_tree.select { |n| n["kind"] == "file" }
|
|
330
|
+
.each_with_object({}) { |n, h| h[n["path"]] = n["content"] }
|
|
331
|
+
local_files = local_tree.select { |n| n[:kind] == "file" }
|
|
332
|
+
.each_with_object({}) { |n, h| h[n[:path]] = n[:content] }
|
|
333
|
+
|
|
334
|
+
has_diff = false
|
|
335
|
+
|
|
336
|
+
# Modified files
|
|
337
|
+
local_files.each do |path, content|
|
|
338
|
+
if remote_files.key?(path) && remote_files[path] != content
|
|
339
|
+
has_diff = true
|
|
340
|
+
ui.info("--- remote #{path}")
|
|
341
|
+
ui.info("+++ local #{path}")
|
|
342
|
+
file_diff = Diffy::Diff.new(remote_files[path], content, context: 3)
|
|
343
|
+
ui.diff(file_diff.to_s)
|
|
344
|
+
ui.blank
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# New files
|
|
349
|
+
local_files.each do |path, content|
|
|
350
|
+
next if remote_files.key?(path)
|
|
351
|
+
|
|
352
|
+
has_diff = true
|
|
353
|
+
ui.info("--- /dev/null")
|
|
354
|
+
ui.info("+++ local #{path}")
|
|
355
|
+
file_diff = Diffy::Diff.new("", content, context: 3)
|
|
356
|
+
ui.diff(file_diff.to_s)
|
|
357
|
+
ui.blank
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Deleted files
|
|
361
|
+
remote_files.each do |path, content|
|
|
362
|
+
next if local_files.key?(path)
|
|
363
|
+
|
|
364
|
+
has_diff = true
|
|
365
|
+
ui.info("--- remote #{path}")
|
|
366
|
+
ui.info("+++ /dev/null")
|
|
367
|
+
file_diff = Diffy::Diff.new(content, "", context: 3)
|
|
368
|
+
ui.diff(file_diff.to_s)
|
|
369
|
+
ui.blank
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
ui.info("No differences found.") unless has_diff
|
|
373
|
+
rescue NotFoundError
|
|
374
|
+
ui.error("Configuration '#{slug}' not found on remote.")
|
|
375
|
+
exit EXIT_NOT_FOUND
|
|
376
|
+
rescue NetworkError => e
|
|
377
|
+
ui.error(e.message)
|
|
378
|
+
exit EXIT_NETWORK_ERROR
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
private
|
|
385
|
+
|
|
386
|
+
# Security: Validate that a path is safe and doesn't escape base_path
|
|
387
|
+
def safe_path?(path, base_path)
|
|
388
|
+
return false if path.nil? || path.empty?
|
|
389
|
+
return false if path.start_with?("/") # No absolute paths
|
|
390
|
+
return false if path.include?("..") # No directory traversal
|
|
391
|
+
return false if path.include?("\0") # No null bytes
|
|
392
|
+
|
|
393
|
+
# Verify resolved path stays within base_path
|
|
394
|
+
full_path = File.expand_path(path, base_path)
|
|
395
|
+
full_path.start_with?(File.expand_path(base_path) + "/") || full_path == File.expand_path(base_path)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def validate_tree_paths!(tree, base_path)
|
|
399
|
+
tree.each do |node|
|
|
400
|
+
path = node["path"] || node[:path]
|
|
401
|
+
unless safe_path?(path, base_path)
|
|
402
|
+
raise SecurityError, "Invalid path detected: #{path.inspect}"
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def load_shairc
|
|
408
|
+
unless File.exist?(SHAIRC_FILE)
|
|
409
|
+
ui.error("No #{SHAIRC_FILE} file found. Run `shai init` to create one.")
|
|
410
|
+
exit EXIT_INVALID_INPUT
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
shairc = YAML.safe_load_file(SHAIRC_FILE)
|
|
414
|
+
|
|
415
|
+
unless shairc["slug"]
|
|
416
|
+
ui.error("Invalid #{SHAIRC_FILE} file. Missing required field: slug")
|
|
417
|
+
exit EXIT_INVALID_INPUT
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
shairc
|
|
421
|
+
rescue Psych::SyntaxError => e
|
|
422
|
+
ui.error("Invalid #{SHAIRC_FILE} file: #{e.message}")
|
|
423
|
+
exit EXIT_INVALID_INPUT
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def build_local_tree(shairc)
|
|
427
|
+
include_patterns = shairc["include"] || []
|
|
428
|
+
exclude_patterns = shairc["exclude"] || []
|
|
429
|
+
|
|
430
|
+
files = []
|
|
431
|
+
folders = Set.new
|
|
432
|
+
|
|
433
|
+
include_patterns.each do |pattern|
|
|
434
|
+
Dir.glob(pattern).each do |path|
|
|
435
|
+
next if File.directory?(path)
|
|
436
|
+
next if excluded?(path, exclude_patterns)
|
|
437
|
+
|
|
438
|
+
# Track folders
|
|
439
|
+
parts = File.dirname(path).split("/")
|
|
440
|
+
parts.each_with_index do |_, i|
|
|
441
|
+
folder_path = parts[0..i].join("/")
|
|
442
|
+
folders << folder_path unless folder_path == "."
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
files << {
|
|
446
|
+
kind: "file",
|
|
447
|
+
path: path,
|
|
448
|
+
content: File.read(path)
|
|
449
|
+
}
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
tree = folders.sort.map { |f| {kind: "folder", path: f} }
|
|
454
|
+
tree + files.sort_by { |f| f[:path] }
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def excluded?(path, patterns)
|
|
458
|
+
patterns.any? do |pattern|
|
|
459
|
+
File.fnmatch?(pattern, path, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Shai
|
|
6
|
+
class Configuration
|
|
7
|
+
class InsecureConnectionError < StandardError; end
|
|
8
|
+
|
|
9
|
+
attr_accessor :config_dir, :token
|
|
10
|
+
attr_reader :api_url
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
self.api_url = ENV.fetch("SHAI_API_URL", "https://shaicli.dev")
|
|
14
|
+
@config_dir = expand_path(ENV.fetch("SHAI_CONFIG_DIR", default_config_dir))
|
|
15
|
+
@token = ENV["SHAI_TOKEN"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def api_url=(url)
|
|
19
|
+
validate_url_security!(url)
|
|
20
|
+
@api_url = url
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def credentials_path
|
|
24
|
+
File.join(config_dir, "credentials")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def color_enabled?
|
|
28
|
+
!ENV.key?("NO_COLOR")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def validate_url_security!(url)
|
|
34
|
+
uri = URI.parse(url)
|
|
35
|
+
|
|
36
|
+
# Allow HTTP only for localhost/127.0.0.1 (development)
|
|
37
|
+
return if uri.scheme == "https"
|
|
38
|
+
return if uri.scheme == "http" && local_host?(uri.host)
|
|
39
|
+
|
|
40
|
+
raise InsecureConnectionError,
|
|
41
|
+
"HTTPS is required for API connections. " \
|
|
42
|
+
"HTTP is only allowed for localhost/127.0.0.1 during development."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def local_host?(host)
|
|
46
|
+
return false if host.nil?
|
|
47
|
+
|
|
48
|
+
# Strip brackets from IPv6 addresses (e.g., "[::1]" -> "::1")
|
|
49
|
+
normalized = host.downcase.delete("[]")
|
|
50
|
+
%w[localhost 127.0.0.1 ::1].include?(normalized)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def default_config_dir
|
|
54
|
+
if Gem.win_platform?
|
|
55
|
+
File.join(ENV.fetch("APPDATA", Dir.home), "shai")
|
|
56
|
+
else
|
|
57
|
+
File.join(Dir.home, ".config", "shai")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def expand_path(path)
|
|
62
|
+
File.expand_path(path)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Shai
|
|
7
|
+
class Credentials
|
|
8
|
+
attr_reader :token, :expires_at, :user
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
load_credentials
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def authenticated?
|
|
15
|
+
!!(token && !expired?)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def expired?
|
|
19
|
+
return false unless expires_at
|
|
20
|
+
|
|
21
|
+
Time.parse(expires_at) < Time.now
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def save(token:, expires_at:, user:)
|
|
25
|
+
@token = token
|
|
26
|
+
@expires_at = expires_at
|
|
27
|
+
@user = user
|
|
28
|
+
|
|
29
|
+
ensure_config_dir
|
|
30
|
+
File.write(credentials_path, credentials_json)
|
|
31
|
+
File.chmod(0o600, credentials_path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clear
|
|
35
|
+
@token = nil
|
|
36
|
+
@expires_at = nil
|
|
37
|
+
@user = nil
|
|
38
|
+
|
|
39
|
+
FileUtils.rm_f(credentials_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def username
|
|
43
|
+
user&.dig("username")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def display_name
|
|
47
|
+
user&.dig("display_name")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def load_credentials
|
|
53
|
+
return unless File.exist?(credentials_path)
|
|
54
|
+
|
|
55
|
+
data = JSON.parse(File.read(credentials_path))
|
|
56
|
+
@token = data["token"]
|
|
57
|
+
@expires_at = data["expires_at"]
|
|
58
|
+
@user = data["user"]
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
clear
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def credentials_json
|
|
64
|
+
JSON.pretty_generate({
|
|
65
|
+
token: token,
|
|
66
|
+
expires_at: expires_at,
|
|
67
|
+
user: user
|
|
68
|
+
})
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def credentials_path
|
|
72
|
+
Shai.configuration.credentials_path
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def ensure_config_dir
|
|
76
|
+
FileUtils.mkdir_p(File.dirname(credentials_path))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|