easyai 1.0.2 → 1.0.4
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/CLAUDE.md +154 -5
- data/README.md +38 -57
- data/bin/easyai +5 -5
- data/easyai.gemspec +1 -0
- data/lib/easyai/auth/authclaude.rb +440 -0
- data/lib/easyai/auth/jpslogin.rb +384 -0
- data/lib/easyai/base/file_crypto.rb +214 -0
- data/lib/easyai/base/system_keychain.rb +240 -0
- data/lib/easyai/base/update_notifier.rb +129 -0
- data/lib/easyai/base/version_checker.rb +329 -0
- data/lib/easyai/command/claude.rb +278 -0
- data/lib/easyai/command/clean.rb +453 -0
- data/lib/easyai/command/gemini.rb +58 -0
- data/lib/easyai/command/gpt.rb +58 -0
- data/lib/easyai/command/update.rb +210 -0
- data/lib/easyai/command/utils/decry.rb +102 -0
- data/lib/easyai/command/utils/encry.rb +102 -0
- data/lib/easyai/command/utils.rb +32 -0
- data/lib/easyai/command.rb +56 -0
- data/lib/easyai/config/config.rb +550 -0
- data/lib/easyai/version.rb +1 -1
- data/lib/easyai.rb +67 -55
- metadata +31 -4
- data/lib/easyai/claude.rb +0 -61
- data/lib/easyai/gemini.rb +0 -61
- data/lib/easyai/gpt.rb +0 -60
@@ -0,0 +1,384 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'webrick'
|
4
|
+
require 'json'
|
5
|
+
require 'net/http'
|
6
|
+
require 'uri'
|
7
|
+
require 'securerandom'
|
8
|
+
require 'digest'
|
9
|
+
require 'base64'
|
10
|
+
require 'fileutils'
|
11
|
+
|
12
|
+
module EasyAI
|
13
|
+
module Auth
|
14
|
+
class JPSLogin
|
15
|
+
attr_reader :access_token, :username, :expires_at
|
16
|
+
|
17
|
+
def initialize
|
18
|
+
@client_id = "cli_a7bc7fe9b3d1d00b"
|
19
|
+
@server_port = 8898
|
20
|
+
@state = "client_login"
|
21
|
+
@api_endpoint = "https://jps-api.devtestapp.com/api/lark_login"
|
22
|
+
@redirect_uri = "https://jps-new.devtestapp.com/auth/jwt/login"
|
23
|
+
@feishu_auth_url = 'https://passport.feishu.cn/suite/passport/oauth/authorize?'
|
24
|
+
|
25
|
+
@scope_list = [
|
26
|
+
'task:task:write',
|
27
|
+
'task:section:write',
|
28
|
+
'task:custom_field:write',
|
29
|
+
'task:tasklist:write'
|
30
|
+
]
|
31
|
+
|
32
|
+
@access_token = nil
|
33
|
+
@username = nil
|
34
|
+
@expires_at = nil
|
35
|
+
|
36
|
+
# token 存储路径
|
37
|
+
@token_dir = File.expand_path('~/.easyai')
|
38
|
+
@token_file = File.join(@token_dir, '.jpstoken')
|
39
|
+
end
|
40
|
+
|
41
|
+
# 主登录入口
|
42
|
+
def login
|
43
|
+
# 检查已存储的 token
|
44
|
+
if load_stored_token && validate_token
|
45
|
+
return true
|
46
|
+
end
|
47
|
+
|
48
|
+
puts "🔐 需要登录 JPS..."
|
49
|
+
return authorize_and_login
|
50
|
+
end
|
51
|
+
|
52
|
+
# 获取用户名(登录后可用)
|
53
|
+
def get_username
|
54
|
+
return @username if @username
|
55
|
+
|
56
|
+
# 如果没有用户名但有 token,尝试从存储中加载
|
57
|
+
if load_stored_token
|
58
|
+
return @username
|
59
|
+
end
|
60
|
+
|
61
|
+
nil
|
62
|
+
end
|
63
|
+
|
64
|
+
# 检查 token 是否有效
|
65
|
+
def validate_token(token = nil)
|
66
|
+
token_to_check = token || @access_token
|
67
|
+
return false unless token_to_check
|
68
|
+
|
69
|
+
# 检查过期时间
|
70
|
+
if @expires_at && Time.now.to_i > @expires_at
|
71
|
+
puts "Token 已过期,需要重新登录"
|
72
|
+
return false
|
73
|
+
end
|
74
|
+
|
75
|
+
# 简单验证(实际项目中可以调用 API 验证)
|
76
|
+
return true
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# 完整的授权和登录流程
|
82
|
+
def authorize_and_login
|
83
|
+
# 构建授权 URL
|
84
|
+
authorization_uri = build_authorization_uri
|
85
|
+
puts "正在打开浏览器进行飞书 OAuth 授权..."
|
86
|
+
puts "授权 URL: #{authorization_uri}"
|
87
|
+
|
88
|
+
# 在浏览器中打开授权 URL
|
89
|
+
open_browser(authorization_uri)
|
90
|
+
|
91
|
+
# 启动本地服务器处理回调
|
92
|
+
code = start_callback_server
|
93
|
+
|
94
|
+
# 如果自动获取失败,提示用户手动输入
|
95
|
+
if code.nil?
|
96
|
+
puts "自动获取授权码失败,您可以手动输入:"
|
97
|
+
puts "1. 授权码 (直接复制 'code=' 后面的内容)"
|
98
|
+
puts "2. 完整回调 URL"
|
99
|
+
print "> "
|
100
|
+
input = STDIN.gets.chomp
|
101
|
+
|
102
|
+
if input.start_with?("http")
|
103
|
+
# 尝试从 URL 中提取 code
|
104
|
+
begin
|
105
|
+
uri = URI(input)
|
106
|
+
query_params = URI.decode_www_form(uri.query || '').to_h
|
107
|
+
code = query_params['code']
|
108
|
+
puts "从 URL 中成功提取授权码" if code
|
109
|
+
rescue => e
|
110
|
+
puts "无法从 URL 中提取授权码: #{e.message}"
|
111
|
+
end
|
112
|
+
else
|
113
|
+
# 将输入直接作为 code
|
114
|
+
code = input unless input.empty?
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
if code
|
119
|
+
puts "成功获取授权码!正在使用飞书身份登录 JPS..."
|
120
|
+
if exchange_code_for_token(code)
|
121
|
+
puts "✓ JPS 登录成功!"
|
122
|
+
puts " 用户名: #{@username}"
|
123
|
+
store_token
|
124
|
+
return true
|
125
|
+
end
|
126
|
+
return false
|
127
|
+
else
|
128
|
+
puts "✗ 授权失败"
|
129
|
+
return false
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
# 构建授权 URI
|
134
|
+
def build_authorization_uri
|
135
|
+
uri = URI(@feishu_auth_url)
|
136
|
+
|
137
|
+
params = {
|
138
|
+
'client_id' => @client_id,
|
139
|
+
'redirect_uri' => @redirect_uri,
|
140
|
+
'response_type' => 'code',
|
141
|
+
'state' => @state,
|
142
|
+
'scope' => @scope_list.join(' ')
|
143
|
+
}
|
144
|
+
|
145
|
+
uri.query = URI.encode_www_form(params)
|
146
|
+
uri.to_s
|
147
|
+
end
|
148
|
+
|
149
|
+
# 在浏览器中打开 URL
|
150
|
+
def open_browser(url)
|
151
|
+
case RbConfig::CONFIG['host_os']
|
152
|
+
when /mswin|mingw|cygwin/
|
153
|
+
system("start", url)
|
154
|
+
when /darwin/
|
155
|
+
system("open", url)
|
156
|
+
when /linux|bsd/
|
157
|
+
system("xdg-open", url)
|
158
|
+
else
|
159
|
+
puts "无法自动打开浏览器,请手动访问: #{url}"
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
# 启动本地服务器处理回调
|
164
|
+
def start_callback_server
|
165
|
+
code = nil
|
166
|
+
|
167
|
+
puts "启动本地服务器,监听端口 #{@server_port}..."
|
168
|
+
|
169
|
+
server = WEBrick::HTTPServer.new(
|
170
|
+
Port: @server_port,
|
171
|
+
Logger: WEBrick::Log.new("/dev/null"),
|
172
|
+
AccessLog: []
|
173
|
+
)
|
174
|
+
|
175
|
+
# 处理根路径的请求
|
176
|
+
server.mount_proc '/' do |req, res|
|
177
|
+
begin
|
178
|
+
# 解析请求参数
|
179
|
+
query_params = URI.decode_www_form(req.query_string || '').to_h
|
180
|
+
puts "接收到回调请求,参数: #{query_params.inspect}"
|
181
|
+
|
182
|
+
if query_params['error']
|
183
|
+
puts "授权错误: #{query_params['error']}"
|
184
|
+
res.content_type = "text/html; charset=UTF-8"
|
185
|
+
res.body = build_error_page(query_params['error'])
|
186
|
+
elsif query_params['code']
|
187
|
+
code = query_params['code']
|
188
|
+
puts "成功获取授权码"
|
189
|
+
res.content_type = "text/html; charset=UTF-8"
|
190
|
+
res.body = build_success_page
|
191
|
+
else
|
192
|
+
puts "未获取到授权码"
|
193
|
+
res.content_type = "text/html; charset=UTF-8"
|
194
|
+
res.body = build_error_page("未获取到授权码")
|
195
|
+
end
|
196
|
+
|
197
|
+
# 4秒后关闭服务器
|
198
|
+
Thread.new do
|
199
|
+
sleep 4
|
200
|
+
server.shutdown
|
201
|
+
end
|
202
|
+
rescue => e
|
203
|
+
puts "处理请求时出错: #{e.class} - #{e.message}"
|
204
|
+
res.content_type = "text/html; charset=UTF-8"
|
205
|
+
res.body = build_error_page("处理请求时出错")
|
206
|
+
|
207
|
+
Thread.new do
|
208
|
+
sleep 4
|
209
|
+
server.shutdown
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
213
|
+
|
214
|
+
# 捕获 Ctrl+C
|
215
|
+
trap('INT') { server.shutdown }
|
216
|
+
|
217
|
+
# 在线程中运行服务器,最多等待 3 分钟
|
218
|
+
thread = Thread.new { server.start }
|
219
|
+
begin
|
220
|
+
thread.join(180) # 最多等待 3 分钟
|
221
|
+
rescue => e
|
222
|
+
puts "服务器等待超时"
|
223
|
+
server.shutdown
|
224
|
+
end
|
225
|
+
|
226
|
+
code
|
227
|
+
end
|
228
|
+
|
229
|
+
# 使用授权码换取 token
|
230
|
+
def exchange_code_for_token(code)
|
231
|
+
begin
|
232
|
+
request_data = {
|
233
|
+
'code' => code,
|
234
|
+
'redirectUri' => @redirect_uri,
|
235
|
+
'scope' => @scope_list.join(' ')
|
236
|
+
}
|
237
|
+
|
238
|
+
uri = URI(@api_endpoint)
|
239
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
240
|
+
http.use_ssl = (uri.scheme == 'https')
|
241
|
+
|
242
|
+
request = Net::HTTP::Post.new(uri)
|
243
|
+
request['Content-Type'] = 'application/json'
|
244
|
+
request.body = request_data.to_json
|
245
|
+
|
246
|
+
puts "正在请求 JPS API: #{@api_endpoint}"
|
247
|
+
response = http.request(request)
|
248
|
+
|
249
|
+
puts "API 响应状态码: #{response.code}"
|
250
|
+
|
251
|
+
if response.body
|
252
|
+
result = JSON.parse(response.body)
|
253
|
+
puts "API 响应: #{result.inspect}"
|
254
|
+
|
255
|
+
if result['meta'] && result['meta']['code'] == 200 && result['data']
|
256
|
+
data = result['data']
|
257
|
+
@access_token = data['token'] if data['token']
|
258
|
+
@username = data['username'] if data['username']
|
259
|
+
# 设置过期时间为 6 天后
|
260
|
+
@expires_at = Time.now.to_i + 6 * 24 * 60 * 60
|
261
|
+
|
262
|
+
return true if @access_token
|
263
|
+
else
|
264
|
+
error_msg = result['meta'] && result['meta']['message'] ? result['meta']['message'] : '未知错误'
|
265
|
+
puts "JPS 登录失败: #{error_msg}"
|
266
|
+
end
|
267
|
+
else
|
268
|
+
puts "API 返回空响应"
|
269
|
+
end
|
270
|
+
|
271
|
+
return false
|
272
|
+
rescue => e
|
273
|
+
puts "请求 JPS API 失败: #{e.class} - #{e.message}"
|
274
|
+
return false
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# 存储 token 和用户名到 ~/.easyai/.jpstoken 文件
|
279
|
+
def store_token
|
280
|
+
return unless @access_token
|
281
|
+
|
282
|
+
# 确保目录存在
|
283
|
+
FileUtils.mkdir_p(@token_dir) unless Dir.exist?(@token_dir)
|
284
|
+
|
285
|
+
token_data = {
|
286
|
+
'token' => @access_token,
|
287
|
+
'username' => @username,
|
288
|
+
'expires_at' => @expires_at,
|
289
|
+
'created_at' => Time.now.to_i
|
290
|
+
}
|
291
|
+
|
292
|
+
File.write(@token_file, token_data.to_json)
|
293
|
+
puts "✓ JPS Token 和用户名已存储到 #{@token_file}"
|
294
|
+
rescue => e
|
295
|
+
puts "⚠ 存储 JPS Token 失败: #{e.message}"
|
296
|
+
end
|
297
|
+
|
298
|
+
# 从 ~/.easyai/.jpstoken 文件加载 token 和用户名
|
299
|
+
def load_stored_token
|
300
|
+
return false unless File.exist?(@token_file)
|
301
|
+
|
302
|
+
begin
|
303
|
+
token_data = JSON.parse(File.read(@token_file))
|
304
|
+
@access_token = token_data['token']
|
305
|
+
@username = token_data['username']
|
306
|
+
@expires_at = token_data['expires_at']
|
307
|
+
|
308
|
+
return @access_token ? true : false
|
309
|
+
rescue => e
|
310
|
+
puts "⚠️ 读取 JPS Token 失败: #{e.message}"
|
311
|
+
return false
|
312
|
+
end
|
313
|
+
end
|
314
|
+
|
315
|
+
# 构建成功页面
|
316
|
+
def build_success_page
|
317
|
+
<<~HTML
|
318
|
+
<!DOCTYPE html>
|
319
|
+
<html>
|
320
|
+
<head>
|
321
|
+
<meta charset="UTF-8">
|
322
|
+
<title>JPS 授权成功</title>
|
323
|
+
<style>
|
324
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
|
325
|
+
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
326
|
+
.success { color: #4CAF50; margin-bottom: 20px; }
|
327
|
+
.countdown { font-weight: bold; color: #FF5722; }
|
328
|
+
.close-btn { background: #4CAF50; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
|
329
|
+
</style>
|
330
|
+
<script>
|
331
|
+
var secondsLeft = 3;
|
332
|
+
function updateCountdown() {
|
333
|
+
document.getElementById('countdown').innerText = secondsLeft;
|
334
|
+
if (secondsLeft <= 0) {
|
335
|
+
window.close();
|
336
|
+
} else {
|
337
|
+
secondsLeft -= 1;
|
338
|
+
setTimeout(updateCountdown, 1000);
|
339
|
+
}
|
340
|
+
}
|
341
|
+
window.onload = function() { updateCountdown(); }
|
342
|
+
</script>
|
343
|
+
</head>
|
344
|
+
<body>
|
345
|
+
<div class="container">
|
346
|
+
<h1 class="success">✓ JPS 授权成功!</h1>
|
347
|
+
<p>已获取授权码,正在返回命令行...</p>
|
348
|
+
<p>此窗口将在 <span id="countdown" class="countdown">3</span> 秒后自动关闭</p>
|
349
|
+
<button class="close-btn" onclick="window.close()">立即关闭</button>
|
350
|
+
</div>
|
351
|
+
</body>
|
352
|
+
</html>
|
353
|
+
HTML
|
354
|
+
end
|
355
|
+
|
356
|
+
# 构建错误页面
|
357
|
+
def build_error_page(error_msg)
|
358
|
+
<<~HTML
|
359
|
+
<!DOCTYPE html>
|
360
|
+
<html>
|
361
|
+
<head>
|
362
|
+
<meta charset="UTF-8">
|
363
|
+
<title>JPS 授权失败</title>
|
364
|
+
<style>
|
365
|
+
body { font-family: Arial, sans-serif; text-align: center; padding: 40px; background-color: #f5f5f5; }
|
366
|
+
.container { max-width: 600px; margin: 0 auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
367
|
+
.error { color: #f44336; margin-bottom: 20px; }
|
368
|
+
.close-btn { background: #f44336; color: white; border: none; padding: 10px 20px; cursor: pointer; border-radius: 4px; font-size: 16px; }
|
369
|
+
</style>
|
370
|
+
</head>
|
371
|
+
<body>
|
372
|
+
<div class="container">
|
373
|
+
<h1 class="error">✗ JPS 授权失败</h1>
|
374
|
+
<p>错误信息: #{error_msg}</p>
|
375
|
+
<p>请返回命令行重试</p>
|
376
|
+
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
377
|
+
</div>
|
378
|
+
</body>
|
379
|
+
</html>
|
380
|
+
HTML
|
381
|
+
end
|
382
|
+
end
|
383
|
+
end
|
384
|
+
end
|
@@ -0,0 +1,214 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
require 'base64'
|
3
|
+
require 'digest'
|
4
|
+
require 'io/console'
|
5
|
+
require 'fileutils'
|
6
|
+
|
7
|
+
module EasyAI
|
8
|
+
module Base
|
9
|
+
module FileCrypto
|
10
|
+
|
11
|
+
# 生成 AES-128 密钥
|
12
|
+
def self.generate_key(password)
|
13
|
+
Digest::MD5.hexdigest(password)[0, 16]
|
14
|
+
end
|
15
|
+
|
16
|
+
# AES-128-ECB 加密字符串
|
17
|
+
def self.aes_128_ecb_encrypt(key, data)
|
18
|
+
cipher = OpenSSL::Cipher.new('AES-128-ECB')
|
19
|
+
cipher.encrypt
|
20
|
+
cipher.key = key
|
21
|
+
encrypted = cipher.update(data) + cipher.final
|
22
|
+
Base64.strict_encode64(encrypted)
|
23
|
+
end
|
24
|
+
|
25
|
+
# AES-128-ECB 解密字符串
|
26
|
+
def self.aes_128_ecb_decrypt(key, encrypted_data)
|
27
|
+
cipher = OpenSSL::Cipher.new('AES-128-ECB')
|
28
|
+
cipher.decrypt
|
29
|
+
cipher.key = key
|
30
|
+
decrypted_data = Base64.strict_decode64(encrypted_data)
|
31
|
+
cipher.update(decrypted_data) + cipher.final
|
32
|
+
end
|
33
|
+
|
34
|
+
# 加密文件
|
35
|
+
def self.encrypt_file(file_path, password, output_path = nil)
|
36
|
+
raise "文件不存在: #{file_path}" unless File.exist?(file_path)
|
37
|
+
raise "指定路径不是文件: #{file_path}" unless File.file?(file_path)
|
38
|
+
|
39
|
+
# 如果没有指定输出路径,使用原文件路径加 .encrypted 后缀
|
40
|
+
output_path ||= "#{file_path}.encrypted"
|
41
|
+
|
42
|
+
# 生成密钥
|
43
|
+
key = generate_key(password)
|
44
|
+
|
45
|
+
# 读取文件内容
|
46
|
+
file_content = File.read(file_path)
|
47
|
+
|
48
|
+
# 加密内容
|
49
|
+
encrypted_content = aes_128_ecb_encrypt(key, file_content)
|
50
|
+
|
51
|
+
# 写入加密文件
|
52
|
+
File.write(output_path, encrypted_content)
|
53
|
+
|
54
|
+
output_path
|
55
|
+
rescue => e
|
56
|
+
raise "加密文件失败: #{e.message}"
|
57
|
+
end
|
58
|
+
|
59
|
+
# 加密目录中的所有文件
|
60
|
+
def self.encrypt_directory(dir_path, password, output_dir = nil)
|
61
|
+
raise "目录不存在: #{dir_path}" unless File.exist?(dir_path)
|
62
|
+
raise "指定路径不是目录: #{dir_path}" unless File.directory?(dir_path)
|
63
|
+
|
64
|
+
# 如果没有指定输出目录,使用原目录路径加 _encrypted 后缀
|
65
|
+
output_dir ||= "#{dir_path}_encrypted"
|
66
|
+
|
67
|
+
# 创建输出目录
|
68
|
+
Dir.mkdir(output_dir) unless File.exist?(output_dir)
|
69
|
+
|
70
|
+
encrypted_files = []
|
71
|
+
|
72
|
+
# 遍历目录中的所有文件(递归)
|
73
|
+
Dir.glob(File.join(dir_path, "**", "*")).each do |file_path|
|
74
|
+
next unless File.file?(file_path)
|
75
|
+
|
76
|
+
# 计算相对路径
|
77
|
+
relative_path = file_path.gsub(/^#{Regexp.escape(dir_path)}\//, '')
|
78
|
+
output_file_path = File.join(output_dir, "#{relative_path}.encrypted")
|
79
|
+
|
80
|
+
# 确保输出文件的目录存在
|
81
|
+
output_file_dir = File.dirname(output_file_path)
|
82
|
+
FileUtils.mkdir_p(output_file_dir) unless File.exist?(output_file_dir)
|
83
|
+
|
84
|
+
begin
|
85
|
+
encrypt_file(file_path, password, output_file_path)
|
86
|
+
encrypted_files << output_file_path
|
87
|
+
rescue => e
|
88
|
+
puts "⚠️ 跳过文件 #{relative_path}: #{e.message}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
{ output_dir: output_dir, encrypted_files: encrypted_files }
|
93
|
+
rescue => e
|
94
|
+
raise "加密目录失败: #{e.message}"
|
95
|
+
end
|
96
|
+
|
97
|
+
# 解密文件
|
98
|
+
def self.decrypt_file(file_path, password, output_path = nil)
|
99
|
+
raise "文件不存在: #{file_path}" unless File.exist?(file_path)
|
100
|
+
raise "指定路径不是文件: #{file_path}" unless File.file?(file_path)
|
101
|
+
|
102
|
+
# 如果没有指定输出路径,移除 .encrypted 后缀或加 .decrypted 后缀
|
103
|
+
if output_path.nil?
|
104
|
+
if file_path.end_with?('.encrypted')
|
105
|
+
output_path = file_path.gsub(/\.encrypted$/, '')
|
106
|
+
else
|
107
|
+
output_path = "#{file_path}.decrypted"
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# 生成密钥
|
112
|
+
key = generate_key(password)
|
113
|
+
|
114
|
+
# 读取加密文件内容
|
115
|
+
encrypted_content = File.read(file_path)
|
116
|
+
|
117
|
+
# 解密内容
|
118
|
+
decrypted_content = aes_128_ecb_decrypt(key, encrypted_content)
|
119
|
+
|
120
|
+
# 写入解密文件
|
121
|
+
File.write(output_path, decrypted_content)
|
122
|
+
|
123
|
+
output_path
|
124
|
+
rescue => e
|
125
|
+
raise "解密文件失败: #{e.message}"
|
126
|
+
end
|
127
|
+
|
128
|
+
# 解密目录中的所有加密文件
|
129
|
+
def self.decrypt_directory(dir_path, password, output_dir = nil)
|
130
|
+
raise "目录不存在: #{dir_path}" unless File.exist?(dir_path)
|
131
|
+
raise "指定路径不是目录: #{dir_path}" unless File.directory?(dir_path)
|
132
|
+
|
133
|
+
# 如果没有指定输出目录,智能判断输出路径
|
134
|
+
if output_dir.nil?
|
135
|
+
if dir_path.end_with?('_encrypted')
|
136
|
+
output_dir = dir_path.gsub(/_encrypted$/, '')
|
137
|
+
else
|
138
|
+
output_dir = "#{dir_path}_decrypted"
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# 创建输出目录
|
143
|
+
Dir.mkdir(output_dir) unless File.exist?(output_dir)
|
144
|
+
|
145
|
+
decrypted_files = []
|
146
|
+
|
147
|
+
# 遍历目录中的所有文件(递归)
|
148
|
+
Dir.glob(File.join(dir_path, "**", "*")).each do |file_path|
|
149
|
+
next unless File.file?(file_path)
|
150
|
+
|
151
|
+
# 计算相对路径
|
152
|
+
relative_path = file_path.gsub(/^#{Regexp.escape(dir_path)}\//, '')
|
153
|
+
|
154
|
+
# 智能处理输出文件路径
|
155
|
+
if relative_path.end_with?('.encrypted')
|
156
|
+
# 移除 .encrypted 后缀
|
157
|
+
output_relative_path = relative_path.gsub(/\.encrypted$/, '')
|
158
|
+
else
|
159
|
+
# 如果不是 .encrypted 文件,跳过或添加 .decrypted 后缀
|
160
|
+
puts "⚠️ 跳过非加密文件: #{relative_path}"
|
161
|
+
next
|
162
|
+
end
|
163
|
+
|
164
|
+
output_file_path = File.join(output_dir, output_relative_path)
|
165
|
+
|
166
|
+
# 确保输出文件的目录存在
|
167
|
+
output_file_dir = File.dirname(output_file_path)
|
168
|
+
FileUtils.mkdir_p(output_file_dir) unless File.exist?(output_file_dir)
|
169
|
+
|
170
|
+
begin
|
171
|
+
decrypt_file(file_path, password, output_file_path)
|
172
|
+
decrypted_files << output_file_path
|
173
|
+
rescue => e
|
174
|
+
puts "⚠️ 跳过文件 #{relative_path}: #{e.message}"
|
175
|
+
end
|
176
|
+
end
|
177
|
+
|
178
|
+
{ output_dir: output_dir, decrypted_files: decrypted_files }
|
179
|
+
rescue => e
|
180
|
+
raise "解密目录失败: #{e.message}"
|
181
|
+
end
|
182
|
+
|
183
|
+
# 获取用户密码输入
|
184
|
+
def self.get_password(prompt = "请输入密码: ")
|
185
|
+
# 如果设置了环境变量,直接使用(主要用于测试和脚本自动化)
|
186
|
+
if ENV['EASYAI_TEST_PASSWORD']
|
187
|
+
password = ENV['EASYAI_TEST_PASSWORD']
|
188
|
+
puts "使用环境变量密码"
|
189
|
+
return password
|
190
|
+
end
|
191
|
+
|
192
|
+
begin
|
193
|
+
print prompt
|
194
|
+
password = STDIN.noecho(&:gets)&.chomp
|
195
|
+
puts # 换行
|
196
|
+
|
197
|
+
if password.nil? || password.empty?
|
198
|
+
puts "❌ 密码不能为空"
|
199
|
+
return nil
|
200
|
+
end
|
201
|
+
|
202
|
+
password
|
203
|
+
rescue Interrupt
|
204
|
+
puts "\n\n用户取消输入"
|
205
|
+
return nil
|
206
|
+
rescue => e
|
207
|
+
puts "\n❌ 密码输入出错: #{e.message}"
|
208
|
+
return nil
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
end
|
213
|
+
end
|
214
|
+
end
|