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.
@@ -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
@@ -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
- return true if response.code.to_i == 204 || response.is_a?(Net::HTTPSuccess)
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
- return true if response.is_a?(Net::HTTPSuccess)
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
- percent = [[(current.to_i * 100.0 / @total).floor, 0].max, 100].min
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.to_i)
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
- render(100, @total)
399
- if @interactive
400
- @output.print "\r\e[2K"
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 || +""
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Appship
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -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