appship 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 +164 -18
- data/lib/appship/builder.rb +48 -39
- data/lib/appship/cache.rb +55 -19
- data/lib/appship/cli.rb +143 -50
- data/lib/appship/config.rb +18 -2
- data/lib/appship/default_message +9 -0
- data/lib/appship/message.rb +193 -0
- data/lib/appship/project_config.rb +115 -0
- data/lib/appship/uploader.rb +26 -10
- data/lib/appship/version.rb +1 -1
- data/lib/appship/webhook.rb +151 -0
- data/lib/appship.rb +3 -0
- data/test/test_cache.rb +263 -0
- data/test/test_cli.rb +356 -0
- data/test/test_display.rb +120 -0
- data/test/test_message.rb +138 -0
- data/test/test_project_config.rb +221 -0
- data/test/test_upload_progress.rb +110 -0
- data/test/test_webhook.rb +352 -0
- metadata +13 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appship
|
|
4
|
+
class ProjectConfig < Cache
|
|
5
|
+
def self.path(project_dir = Dir.pwd)
|
|
6
|
+
File.join(File.expand_path(project_dir), "appship", "config.yml")
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.load(global_path = DEFAULT_PATH, project_dir: Dir.pwd, interactive: true, provider: "pgyer")
|
|
10
|
+
local_path = path(project_dir)
|
|
11
|
+
unless File.exist?(local_path) || File.symlink?(local_path)
|
|
12
|
+
return Cache.load(global_path, project_dir: project_dir, interactive: interactive, provider: provider)
|
|
13
|
+
end
|
|
14
|
+
unless File.file?(local_path) && !File.symlink?(local_path)
|
|
15
|
+
raise ConfigurationError, "项目配置必须是普通文件: #{local_path}"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
new(global_path, project_dir: project_dir)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.init!(project_dir: Dir.pwd)
|
|
22
|
+
config_path = "#{path(project_dir)}.sample"
|
|
23
|
+
if File.exist?(config_path) || File.symlink?(config_path)
|
|
24
|
+
unless File.file?(config_path) && !File.symlink?(config_path)
|
|
25
|
+
raise ConfigurationError, "项目配置必须是普通文件: #{config_path}"
|
|
26
|
+
end
|
|
27
|
+
else
|
|
28
|
+
name = Cache.infer_project_name!(project_dir)
|
|
29
|
+
FileUtils.mkdir_p(File.dirname(config_path), mode: 0o700)
|
|
30
|
+
File.open(config_path, "wx", 0o600) do |file|
|
|
31
|
+
file.write(<<~YAML)
|
|
32
|
+
# 按需取消注释并填写;未配置的字段继承全局配置。
|
|
33
|
+
# 密码或 Webhook 填 "" 表示关闭对应设置。
|
|
34
|
+
|
|
35
|
+
#{YAML.dump("project_name" => name).delete_prefix("---\n").chomp}
|
|
36
|
+
|
|
37
|
+
# app_name: app-name # ipa 名字
|
|
38
|
+
|
|
39
|
+
# 蒲公英 API Key(登录后查看):https://www.pgyer.com/account/api
|
|
40
|
+
# pgyer_api_key: "your-pgyer-api-key"
|
|
41
|
+
# pgyer_password: "your-pgyer-password"
|
|
42
|
+
|
|
43
|
+
# fir.im API Token(登录后查看):https://www.betaqr.com.cn/apps/apitoken
|
|
44
|
+
# fir_api_key: "your-fir-api-key"
|
|
45
|
+
# fir_password: "your-fir-password"
|
|
46
|
+
|
|
47
|
+
# 机器人配置
|
|
48
|
+
# wechat_webhook: "" # 企微机器人
|
|
49
|
+
# dingtalk_webhook: "" # 钉钉机器人
|
|
50
|
+
# feishu_webhook: "" # 飞书机器人
|
|
51
|
+
# feishu_webhook_secret: "" # 飞书签名密钥,仅开启签名校验时填写
|
|
52
|
+
|
|
53
|
+
# App Icon 角标
|
|
54
|
+
# app_icon_badge: true # 是否启用角标
|
|
55
|
+
# app_icon_badge_environment: true # 显示 DEBUG / RELEASE
|
|
56
|
+
# app_icon_badge_version: false # 预留,暂不绘制版本号
|
|
57
|
+
YAML
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
[config_path, Message.init!(project_dir: project_dir)]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def initialize(global_path, project_dir: Dir.pwd)
|
|
64
|
+
@path = self.class.path(project_dir)
|
|
65
|
+
@global_path = File.expand_path(global_path)
|
|
66
|
+
@project_dir = File.expand_path(project_dir)
|
|
67
|
+
@project_data = Config.new(@path).data
|
|
68
|
+
@entries = []
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve(project_name: nil, project_dir: @project_dir, provider: "pgyer", interactive: true, credential_overrides: {}, credentials: true)
|
|
72
|
+
name = project_name.to_s.strip
|
|
73
|
+
name = @project_data["project_name"].to_s.strip if name.empty?
|
|
74
|
+
name = Cache.infer_project_name!(project_dir) if name.empty?
|
|
75
|
+
global_entries = if File.file?(@global_path) && !File.read(@global_path).strip.empty?
|
|
76
|
+
Cache.new(@global_path).entries
|
|
77
|
+
else
|
|
78
|
+
[]
|
|
79
|
+
end
|
|
80
|
+
matches = global_entries.select { |entry| entry["project_name"] == name }
|
|
81
|
+
raise ConfigurationError, "全局配置中 project_name 重复: #{name}" if matches.length > 1
|
|
82
|
+
|
|
83
|
+
entry = (matches.first || {}).merge(@project_data).merge("project_name" => name)
|
|
84
|
+
@entries = [entry]
|
|
85
|
+
@baseline = entry.dup
|
|
86
|
+
if credentials && entry["app_name"].to_s.empty?
|
|
87
|
+
unless interactive && $stdin.tty?
|
|
88
|
+
raise ConfigurationError, "项目配置缺少 app_name: #{@path}"
|
|
89
|
+
end
|
|
90
|
+
update_profile!(entry, "app_name" => Cache.prompt_required("app_name"))
|
|
91
|
+
end
|
|
92
|
+
super(project_name: name, project_dir: project_dir, provider: provider, interactive: interactive,
|
|
93
|
+
credential_overrides: credential_overrides, credentials: credentials)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def update_profile!(entry, values)
|
|
97
|
+
values = values.transform_keys(&:to_s).reject { |_key, value| value.nil? }
|
|
98
|
+
@project_data.merge!(values)
|
|
99
|
+
entry.merge!(values)
|
|
100
|
+
persist!
|
|
101
|
+
entry
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def persist!
|
|
107
|
+
entry = @entries.first
|
|
108
|
+
@project_data.merge!(entry.reject { |key, value| @baseline.key?(key) && @baseline[key] == value })
|
|
109
|
+
@project_data["project_name"] ||= entry["project_name"]
|
|
110
|
+
File.write(@path, YAML.dump(@project_data))
|
|
111
|
+
File.chmod(0o600, @path)
|
|
112
|
+
@baseline = entry.dup
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/appship/uploader.rb
CHANGED
|
@@ -84,11 +84,13 @@ module Appship
|
|
|
84
84
|
request["Content-Length"] = body.length.to_s
|
|
85
85
|
request.body_stream = body
|
|
86
86
|
response = http(uri).request(request)
|
|
87
|
-
|
|
87
|
+
success = response.is_a?(Net::HTTPSuccess)
|
|
88
|
+
return true if success
|
|
88
89
|
|
|
89
90
|
raise UploadError, "蒲公英文件上传失败(HTTP #{response.code}): #{response.body}"
|
|
90
91
|
ensure
|
|
91
|
-
progress&.finish
|
|
92
|
+
progress&.finish(success: !!success)
|
|
93
|
+
body&.close
|
|
92
94
|
end
|
|
93
95
|
|
|
94
96
|
def poll_build_info!(build_key)
|
|
@@ -340,11 +342,13 @@ module Appship
|
|
|
340
342
|
request["Content-Length"] = body.length.to_s
|
|
341
343
|
request.body_stream = body
|
|
342
344
|
response = http(uri).request(request)
|
|
343
|
-
|
|
345
|
+
success = response.is_a?(Net::HTTPSuccess)
|
|
346
|
+
return true if success
|
|
344
347
|
|
|
345
348
|
raise UploadError, "fir.im 文件上传失败(HTTP #{response.code}): #{response.body}"
|
|
346
349
|
ensure
|
|
347
|
-
progress&.finish
|
|
350
|
+
progress&.finish(success: !!success)
|
|
351
|
+
body&.close
|
|
348
352
|
end
|
|
349
353
|
|
|
350
354
|
def http(uri)
|
|
@@ -377,6 +381,7 @@ module Appship
|
|
|
377
381
|
@interactive = output.tty?
|
|
378
382
|
@last_percent = -1
|
|
379
383
|
@started = false
|
|
384
|
+
@current = 0
|
|
380
385
|
end
|
|
381
386
|
|
|
382
387
|
def start
|
|
@@ -385,20 +390,27 @@ module Appship
|
|
|
385
390
|
end
|
|
386
391
|
|
|
387
392
|
def update(current, total = @total)
|
|
393
|
+
return unless @started
|
|
394
|
+
|
|
388
395
|
@total = [total.to_i, 1].max
|
|
389
|
-
|
|
396
|
+
@current = [[current.to_i, 0].max, @total].min
|
|
397
|
+
percent = [(@current * 100.0 / @total).floor, 99].min
|
|
390
398
|
return if percent == @last_percent
|
|
391
399
|
|
|
392
|
-
render(percent, current
|
|
400
|
+
render(percent, @current)
|
|
393
401
|
end
|
|
394
402
|
|
|
395
|
-
def finish
|
|
403
|
+
def finish(success: false)
|
|
396
404
|
return unless @started
|
|
397
405
|
|
|
398
|
-
|
|
399
|
-
if
|
|
400
|
-
@
|
|
406
|
+
@started = false
|
|
407
|
+
if success
|
|
408
|
+
render(100, @total)
|
|
409
|
+
@output.print "\r\e[2K" if @interactive
|
|
401
410
|
@output.puts "☁️ 上传完成(#{format_size(@total)})"
|
|
411
|
+
else
|
|
412
|
+
@output.print "\r\e[2K" if @interactive
|
|
413
|
+
@output.puts "❌ 上传失败(已读取 #{format_size(@current)}/#{format_size(@total)})"
|
|
402
414
|
end
|
|
403
415
|
@output.flush
|
|
404
416
|
end
|
|
@@ -452,6 +464,10 @@ module Appship
|
|
|
452
464
|
@length
|
|
453
465
|
end
|
|
454
466
|
|
|
467
|
+
def close
|
|
468
|
+
@file.close unless @file.closed?
|
|
469
|
+
end
|
|
470
|
+
|
|
455
471
|
def read(length = nil, out_buffer = nil)
|
|
456
472
|
requested = length || 16 * 1024
|
|
457
473
|
result = out_buffer || +""
|
data/lib/appship/version.rb
CHANGED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appship
|
|
4
|
+
class Webhook
|
|
5
|
+
PLATFORMS = { "wechat" => "企业微信", "dingtalk" => "钉钉", "feishu" => "飞书" }.freeze
|
|
6
|
+
URL_FIELDS = PLATFORMS.keys.map { |platform| :"#{platform}_webhook" }.freeze
|
|
7
|
+
OPTION_FIELDS = (URL_FIELDS + [:feishu_webhook_secret]).freeze
|
|
8
|
+
|
|
9
|
+
def self.parse_url!(url)
|
|
10
|
+
uri = URI.parse(url)
|
|
11
|
+
unless uri.is_a?(URI::HTTPS) && !uri.host.to_s.empty? && !uri.userinfo && !uri.fragment
|
|
12
|
+
raise ConfigurationError, "Webhook 必须是有效的 HTTPS 地址,不能包含用户名、密码或片段标记"
|
|
13
|
+
end
|
|
14
|
+
uri
|
|
15
|
+
rescue URI::InvalidURIError
|
|
16
|
+
raise ConfigurationError, "Webhook 必须是有效的 HTTPS 地址"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(options = {})
|
|
20
|
+
@options = options
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def notify(message, failure: false, formats: {})
|
|
24
|
+
label = failure ? "失败通知" : "发布通知"
|
|
25
|
+
context = failure ? "" : "(应用已发布)"
|
|
26
|
+
PLATFORMS.each do |platform, name|
|
|
27
|
+
url = @options["#{platform}_webhook".to_sym].to_s.strip
|
|
28
|
+
next if url.empty?
|
|
29
|
+
|
|
30
|
+
content = content_for(message, platform, failure)
|
|
31
|
+
next if content.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
format = failure ? nil : formats["#{platform}_webhook_format"]
|
|
35
|
+
post!(self.class.parse_url!(url), platform, content, format)
|
|
36
|
+
puts "✅ #{name}#{label}已发送"
|
|
37
|
+
rescue Error => e
|
|
38
|
+
warn "⚠️ #{name}#{label}失败#{context}: #{e.message}"
|
|
39
|
+
rescue IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, Net::ProtocolError => e
|
|
40
|
+
warn "⚠️ #{name}#{label}失败#{context}: 网络请求失败(#{e.class})"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def failure(error)
|
|
46
|
+
return unless @options.values_at(*URL_FIELDS).any? { |url| !url.to_s.strip.empty? }
|
|
47
|
+
|
|
48
|
+
values = {
|
|
49
|
+
name: @options[:app_name] || @options[:project_name] || "未知",
|
|
50
|
+
provider: @options[:provider] == "fir" ? "fir.im" : "蒲公英",
|
|
51
|
+
configuration: @options[:configuration] || "未选择",
|
|
52
|
+
error_type: error.class.to_s,
|
|
53
|
+
error_message: error.message
|
|
54
|
+
}
|
|
55
|
+
message = [
|
|
56
|
+
"❌ Appship 发布流程失败",
|
|
57
|
+
"应用名称: #{values[:name]}",
|
|
58
|
+
"分发平台: #{values[:provider]}",
|
|
59
|
+
"构建配置: #{values[:configuration]}",
|
|
60
|
+
"错误类型: #{values[:error_type]}",
|
|
61
|
+
"错误原因: #{values[:error_message]}"
|
|
62
|
+
].join("\n")
|
|
63
|
+
templates = Message.failure_templates(project_dir: @options[:project_dir] || Dir.pwd)
|
|
64
|
+
if templates.empty?
|
|
65
|
+
notify(safe_failure_content(message), failure: true)
|
|
66
|
+
else
|
|
67
|
+
messages = templates.transform_values { |body| Message.render_text(body, values) }
|
|
68
|
+
messages["default"] = message
|
|
69
|
+
notify(messages.transform_values { |body| safe_failure_content(body) }, failure: true)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def content_for(message, platform, failure)
|
|
76
|
+
return message unless message.is_a?(Hash)
|
|
77
|
+
return message.fetch("#{platform}_webhook_failure_content", message["default"]) if failure
|
|
78
|
+
|
|
79
|
+
message.fetch("#{platform}_webhook_success_content") do
|
|
80
|
+
message.fetch("#{platform}_webhook_content", message["cli_content"])
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def safe_failure_content(message)
|
|
85
|
+
secrets = @options.values_at(:api_key, :password, :fir_api_token, :fir_password, *OPTION_FIELDS)
|
|
86
|
+
secrets << @options[:feishu_webhook].to_s[%r{/open-apis/bot/v2/hook/([^/?#]+)}, 1]
|
|
87
|
+
env_names = %w[PGYER_API_KEY PGYER_INSTALL_PASSWORD FIR_API_TOKEN FIR_PASSWORD]
|
|
88
|
+
env_names += @options.values_at(:api_key_env, :password_env, :fir_api_token_env, :fir_password_env).compact
|
|
89
|
+
secrets += env_names.map { |name| ENV[name] }
|
|
90
|
+
secrets.compact.map(&:to_s).reject(&:empty?).sort_by { |value| -value.length }.each do |secret|
|
|
91
|
+
message = message.gsub(secret, "[已隐藏]")
|
|
92
|
+
end
|
|
93
|
+
message = message.gsub(%r{(https?://[^\s"'<>?]+)\?[^\s"'<>]+}, '\\1?[已隐藏]')
|
|
94
|
+
message.byteslice(0, 1900).scrub("")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def post!(uri, platform, message, format)
|
|
98
|
+
request = Net::HTTP::Post.new(uri)
|
|
99
|
+
request["Content-Type"] = "application/json; charset=utf-8"
|
|
100
|
+
request.body = JSON.generate(payload(platform, message, format))
|
|
101
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
102
|
+
http.use_ssl = true
|
|
103
|
+
http.open_timeout = 10
|
|
104
|
+
http.read_timeout = 15
|
|
105
|
+
http.write_timeout = 15
|
|
106
|
+
http.max_retries = 0
|
|
107
|
+
response = http.request(request)
|
|
108
|
+
raise CommandError, "机器人返回 HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
109
|
+
|
|
110
|
+
data = JSON.parse(response.body)
|
|
111
|
+
code = if data.is_a?(Hash)
|
|
112
|
+
platform == "feishu" ? data.fetch("code", data["StatusCode"]) : data["errcode"]
|
|
113
|
+
end
|
|
114
|
+
raise CommandError, "机器人未返回成功状态(错误码 #{code || "缺失"})" unless code.to_s == "0"
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
raise CommandError, "机器人返回了无法解析的响应"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def payload(platform, message, format)
|
|
120
|
+
if platform == "feishu"
|
|
121
|
+
body = if format == "post"
|
|
122
|
+
paragraphs = message.lines.filter_map do |line|
|
|
123
|
+
next if line.strip.empty?
|
|
124
|
+
|
|
125
|
+
line.chomp.split(%r{(https?://[^\s<>",。]+)}).reject(&:empty?).map do |part|
|
|
126
|
+
if part.start_with?("http://", "https://")
|
|
127
|
+
{ tag: "a", text: part, href: part }
|
|
128
|
+
else
|
|
129
|
+
{ tag: "text", text: part }
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
{ msg_type: "post", content: { post: { zh_cn: { title: "Appship 发布通知", content: paragraphs } } } }
|
|
134
|
+
else
|
|
135
|
+
{ msg_type: "text", content: { text: message } }
|
|
136
|
+
end
|
|
137
|
+
secret = @options[:feishu_webhook_secret].to_s
|
|
138
|
+
unless secret.empty?
|
|
139
|
+
timestamp = Time.now.to_i.to_s
|
|
140
|
+
body[:timestamp] = timestamp
|
|
141
|
+
body[:sign] = [OpenSSL::HMAC.digest("SHA256", "#{timestamp}\n#{secret}", "")].pack("m0")
|
|
142
|
+
end
|
|
143
|
+
body
|
|
144
|
+
elsif platform == "wechat" && format == "markdown"
|
|
145
|
+
{ msgtype: "markdown", markdown: { content: message } }
|
|
146
|
+
else
|
|
147
|
+
{ msgtype: "text", text: { content: message } }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/lib/appship.rb
CHANGED
|
@@ -21,5 +21,8 @@ require_relative "appship/runner"
|
|
|
21
21
|
require_relative "appship/badger"
|
|
22
22
|
require_relative "appship/ipa"
|
|
23
23
|
require_relative "appship/uploader"
|
|
24
|
+
require_relative "appship/message"
|
|
25
|
+
require_relative "appship/project_config"
|
|
26
|
+
require_relative "appship/webhook"
|
|
24
27
|
require_relative "appship/builder"
|
|
25
28
|
require_relative "appship/cli"
|
data/test/test_cache.rb
CHANGED
|
@@ -61,6 +61,201 @@ class CacheTest < Minitest::Test
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
def test_matches_project_files_instead_of_directory_name
|
|
65
|
+
Dir.mktmpdir("iosBuild-") do |directory|
|
|
66
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
|
|
67
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcworkspace"))
|
|
68
|
+
path = File.join(directory, ".config")
|
|
69
|
+
File.write(path, JSON.generate([
|
|
70
|
+
{ "project_name" => "WMApp", "pgyer_api_key" => "other-key" },
|
|
71
|
+
{ "project_name" => "Demo", "pgyer_api_key" => "demo-key" }
|
|
72
|
+
]))
|
|
73
|
+
|
|
74
|
+
profile = Appship::Cache.new(path).resolve(project_dir: directory, interactive: false)
|
|
75
|
+
|
|
76
|
+
assert_equal "Demo", profile["project_name"]
|
|
77
|
+
assert_equal "demo-key", profile["pgyer_api_key"]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def test_first_time_setup_can_resume_after_interruption
|
|
82
|
+
Dir.mktmpdir do |directory|
|
|
83
|
+
path = File.join(directory, ".config")
|
|
84
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
|
|
85
|
+
original_stdin = $stdin
|
|
86
|
+
interrupted_input = InteractiveInput.new(["DemoApp"])
|
|
87
|
+
interrupted_input.define_singleton_method(:gets) { raise Interrupt }
|
|
88
|
+
$stdin = interrupted_input
|
|
89
|
+
|
|
90
|
+
capture_io do
|
|
91
|
+
assert_raises(Interrupt) { Appship::Cache.load(path, project_dir: directory) }
|
|
92
|
+
end
|
|
93
|
+
assert_equal [], JSON.parse(File.read(path))
|
|
94
|
+
|
|
95
|
+
$stdin = InteractiveInput.new(["DemoApp", "key", ""])
|
|
96
|
+
cache = nil
|
|
97
|
+
capture_io { cache = Appship::Cache.load(path, project_dir: directory) }
|
|
98
|
+
assert_equal "key", cache.entries.first["pgyer_api_key"]
|
|
99
|
+
assert_equal "", cache.entries.first["pgyer_password"]
|
|
100
|
+
assert_equal 0o600, File.stat(path).mode & 0o777
|
|
101
|
+
ensure
|
|
102
|
+
$stdin = original_stdin
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def test_empty_array_without_terminal_gives_setup_instructions
|
|
107
|
+
Dir.mktmpdir do |directory|
|
|
108
|
+
path = File.join(directory, ".config")
|
|
109
|
+
content = "[ \n ]\n"
|
|
110
|
+
File.write(path, content)
|
|
111
|
+
|
|
112
|
+
error = assert_raises(Appship::ConfigurationError) { Appship::Cache.load(path, interactive: false) }
|
|
113
|
+
assert_includes error.message, "pgyer_api_key"
|
|
114
|
+
assert_equal content, File.read(path)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_eof_at_optional_password_does_not_commit_incomplete_setup
|
|
119
|
+
Dir.mktmpdir do |directory|
|
|
120
|
+
path = File.join(directory, ".config")
|
|
121
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
|
|
122
|
+
original_stdin = $stdin
|
|
123
|
+
input = InteractiveInput.new([])
|
|
124
|
+
input.define_singleton_method(:gets) { @lines ||= ["DemoApp\n", "key\n"]; @lines.shift }
|
|
125
|
+
$stdin = input
|
|
126
|
+
|
|
127
|
+
capture_io do
|
|
128
|
+
error = assert_raises(Appship::ConfigurationError) { Appship::Cache.load(path, project_dir: directory) }
|
|
129
|
+
assert_includes error.message, "输入已中断"
|
|
130
|
+
end
|
|
131
|
+
assert_equal [], JSON.parse(File.read(path))
|
|
132
|
+
ensure
|
|
133
|
+
$stdin = original_stdin
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_unmatched_project_prompts_for_configuration_without_changing_other_projects
|
|
138
|
+
Dir.mktmpdir("iosBuild-") do |directory|
|
|
139
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
|
|
140
|
+
path = File.join(directory, ".config")
|
|
141
|
+
original = JSON.generate([{ "project_name" => "WMApp", "pgyer_api_key" => "other-key" }])
|
|
142
|
+
File.write(path, original)
|
|
143
|
+
|
|
144
|
+
error = assert_raises(Appship::ConfigurationError) do
|
|
145
|
+
Appship::Cache.new(path).resolve(
|
|
146
|
+
project_dir: directory,
|
|
147
|
+
interactive: false,
|
|
148
|
+
credential_overrides: { "pgyer_api_key" => "demo-key" }
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
assert_includes error.message, "增加当前项目配置"
|
|
153
|
+
assert_includes error.message, path
|
|
154
|
+
assert_includes error.message, "project_name 应填写: Demo"
|
|
155
|
+
assert_equal original, File.read(path)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_guides_new_project_setup_and_preserves_existing_profiles
|
|
160
|
+
%w[pgyer fir].each do |provider|
|
|
161
|
+
key_field = provider == "fir" ? "fir_api_key" : "pgyer_api_key"
|
|
162
|
+
password_field = provider == "fir" ? "fir_password" : "pgyer_password"
|
|
163
|
+
Dir.mktmpdir("iosBuild-") do |directory|
|
|
164
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
|
|
165
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcworkspace"))
|
|
166
|
+
path = File.join(directory, ".config")
|
|
167
|
+
existing = { "project_name" => "WMApp", "app_name" => "ExistingApp", "pgyer_api_key" => "old-key", "custom" => "keep" }
|
|
168
|
+
File.write(path, JSON.generate([existing]))
|
|
169
|
+
original_stdin = $stdin
|
|
170
|
+
$stdin = InteractiveInput.new(["DemoApp", "new-key", ""])
|
|
171
|
+
cache = Appship::Cache.new(path)
|
|
172
|
+
profile = nil
|
|
173
|
+
|
|
174
|
+
output, = capture_io do
|
|
175
|
+
profile = cache.resolve(project_dir: directory, provider: provider)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
assert_includes output, "请输入 app_name"
|
|
179
|
+
assert_includes output, "请输入 #{key_field}"
|
|
180
|
+
assert_includes output, "请输入 #{password_field}"
|
|
181
|
+
assert_equal "Demo", profile["project_name"]
|
|
182
|
+
assert_equal "DemoApp", profile["app_name"]
|
|
183
|
+
assert_equal "new-key", profile[key_field]
|
|
184
|
+
assert_equal "", profile[password_field]
|
|
185
|
+
assert_equal "", profile["wechat_webhook"]
|
|
186
|
+
assert_equal "", profile["dingtalk_webhook"]
|
|
187
|
+
refute profile.key?("api_key")
|
|
188
|
+
refute profile.key?("password")
|
|
189
|
+
assert_equal [existing, profile], JSON.parse(File.read(path))
|
|
190
|
+
assert_equal 0o600, File.stat(path).mode & 0o777
|
|
191
|
+
assert_equal profile, cache.resolve(project_dir: directory, provider: provider, interactive: false)
|
|
192
|
+
assert_equal 2, cache.entries.length
|
|
193
|
+
ensure
|
|
194
|
+
$stdin = original_stdin
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_new_project_setup_uses_credential_overrides
|
|
200
|
+
Dir.mktmpdir do |directory|
|
|
201
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcworkspace"))
|
|
202
|
+
path = File.join(directory, ".config")
|
|
203
|
+
File.write(path, JSON.generate([{ "project_name" => "WMApp" }]))
|
|
204
|
+
original_stdin = $stdin
|
|
205
|
+
$stdin = InteractiveInput.new(["DemoApp"])
|
|
206
|
+
profile = nil
|
|
207
|
+
|
|
208
|
+
output, = capture_io do
|
|
209
|
+
profile = Appship::Cache.new(path).resolve(
|
|
210
|
+
project_dir: directory,
|
|
211
|
+
credential_overrides: { "pgyer_api_key" => "cli-key", "pgyer_password" => "" }
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
assert_equal "cli-key", profile["pgyer_api_key"]
|
|
216
|
+
assert_equal "", profile["pgyer_password"]
|
|
217
|
+
refute_includes output, "请输入 pgyer_api_key"
|
|
218
|
+
refute_includes output, "请输入 pgyer_password"
|
|
219
|
+
ensure
|
|
220
|
+
$stdin = original_stdin
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def test_incomplete_new_project_setup_keeps_cache_unchanged
|
|
225
|
+
Dir.mktmpdir do |directory|
|
|
226
|
+
FileUtils.mkdir_p(File.join(directory, "Demo.xcodeproj"))
|
|
227
|
+
path = File.join(directory, ".config")
|
|
228
|
+
original = JSON.generate([{ "project_name" => "WMApp", "pgyer_api_key" => "old-key" }])
|
|
229
|
+
File.write(path, original)
|
|
230
|
+
original_stdin = $stdin
|
|
231
|
+
$stdin = InteractiveInput.new(["DemoApp", ""])
|
|
232
|
+
|
|
233
|
+
capture_io do
|
|
234
|
+
assert_raises(Appship::ConfigurationError) do
|
|
235
|
+
Appship::Cache.new(path).resolve(project_dir: directory)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
assert_equal original, File.read(path)
|
|
240
|
+
ensure
|
|
241
|
+
$stdin = original_stdin
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def test_missing_explicit_project_names_the_configured_cache_file
|
|
246
|
+
Dir.mktmpdir do |directory|
|
|
247
|
+
path = File.join(directory, ".config")
|
|
248
|
+
File.write(path, JSON.generate([{ "project_name" => "WMApp", "fir_api_key" => "other-key" }]))
|
|
249
|
+
error = assert_raises(Appship::ConfigurationError) do
|
|
250
|
+
Appship::Cache.new(path).resolve(project_name: "Demo", provider: "fir", interactive: false)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
assert_includes error.message, path
|
|
254
|
+
assert_includes error.message, "project_name 应填写: Demo"
|
|
255
|
+
assert_includes error.message, "fir_api_key"
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
64
259
|
def test_guides_first_time_setup
|
|
65
260
|
Dir.mktmpdir do |directory|
|
|
66
261
|
path = File.join(directory, ".config")
|
|
@@ -143,4 +338,72 @@ class CacheTest < Minitest::Test
|
|
|
143
338
|
assert_equal "new-password", saved["fir_password"]
|
|
144
339
|
end
|
|
145
340
|
end
|
|
341
|
+
|
|
342
|
+
def test_loading_provider_specific_keys_preserves_all_fields_without_rewriting
|
|
343
|
+
Dir.mktmpdir do |directory|
|
|
344
|
+
path = File.join(directory, ".config")
|
|
345
|
+
original = [
|
|
346
|
+
{ "project_name" => "Demo", "pgyer_api_key" => "old-key", "pgyer_password" => "",
|
|
347
|
+
"fir_api_key" => "fir-key", "fir_password" => "fir-password", "wechat_webhook" => "keep-url" },
|
|
348
|
+
{ "project_name" => "Other", "pgyer_api_key" => "other-key", "custom" => "keep" }
|
|
349
|
+
]
|
|
350
|
+
content = JSON.generate(original)
|
|
351
|
+
File.write(path, content)
|
|
352
|
+
|
|
353
|
+
entries = Appship::Cache.load(path, interactive: false).entries
|
|
354
|
+
|
|
355
|
+
assert_equal "old-key", entries.first["pgyer_api_key"]
|
|
356
|
+
assert_equal "", entries.first["pgyer_password"]
|
|
357
|
+
refute entries.first.key?("api_key")
|
|
358
|
+
refute entries.first.key?("password")
|
|
359
|
+
assert_equal "fir-key", entries.first["fir_api_key"]
|
|
360
|
+
assert_equal "fir-password", entries.first["fir_password"]
|
|
361
|
+
assert_equal "keep-url", entries.first["wechat_webhook"]
|
|
362
|
+
assert_equal original.last, entries.last
|
|
363
|
+
assert_equal original, entries
|
|
364
|
+
assert_equal content, File.read(path)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def test_generic_keys_do_not_satisfy_pgyer_credentials
|
|
369
|
+
Dir.mktmpdir do |directory|
|
|
370
|
+
path = File.join(directory, ".config")
|
|
371
|
+
content = JSON.generate([{ "project_name" => "Demo", "api_key" => "generic-key", "password" => "generic-password" }])
|
|
372
|
+
File.write(path, content)
|
|
373
|
+
|
|
374
|
+
error = assert_raises(Appship::ConfigurationError) do
|
|
375
|
+
Appship::Cache.new(path).resolve(project_name: "Demo", interactive: false)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
assert_includes error.message, "缓存缺少 pgyer_api_key"
|
|
379
|
+
assert_equal content, File.read(path)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def test_invalid_cache_is_not_rewritten
|
|
384
|
+
Dir.mktmpdir do |directory|
|
|
385
|
+
path = File.join(directory, ".config")
|
|
386
|
+
content = JSON.generate([{ "project_name" => "Demo", "pgyer_api_key" => "old-key" }, "invalid"])
|
|
387
|
+
File.write(path, content)
|
|
388
|
+
|
|
389
|
+
assert_raises(Appship::ConfigurationError) { Appship::Cache.new(path) }
|
|
390
|
+
assert_equal content, File.read(path)
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def test_pgyer_credential_overrides_use_provider_specific_keys
|
|
395
|
+
Dir.mktmpdir do |directory|
|
|
396
|
+
path = File.join(directory, ".config")
|
|
397
|
+
File.write(path, JSON.generate([{ "project_name" => "Demo", "pgyer_api_key" => "old-key", "pgyer_password" => "old-password" }]))
|
|
398
|
+
|
|
399
|
+
entry = Appship::Cache.new(path).resolve(
|
|
400
|
+
project_name: "Demo", interactive: false,
|
|
401
|
+
credential_overrides: { "pgyer_api_key" => "new-key", "pgyer_password" => "" }
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
assert_equal "new-key", entry["pgyer_api_key"]
|
|
405
|
+
assert_equal "", entry["pgyer_password"]
|
|
406
|
+
assert_equal [entry], JSON.parse(File.read(path))
|
|
407
|
+
end
|
|
408
|
+
end
|
|
146
409
|
end
|