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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 512f49f01b45fb318992c5dff4df86626d61aea2c57818556763fc869ecee047
4
- data.tar.gz: 949c652229a3c30bf38d1e0727fb30be191b93e9198da561a7648592a0ca2687
3
+ metadata.gz: 905aafaf87595c7b9f88071620d883fe4d15e513fc297d55208e88d3fe9fc263
4
+ data.tar.gz: 10b04c144cb0de4c4db92373d4b4b7e5fbc9fc8d25af4c5de2e6e9eb8c9db051
5
5
  SHA512:
6
- metadata.gz: 4884ca69b0efaf1ab59bd0ecd660d755b93c762545b578b6e30547c613d77ee8e65972efa6783aca244c27951980b45506f09cb4005bf27f2f5fa4905220521e
7
- data.tar.gz: 7a108e44ed054b191a7d6dd5f78316a2cccc49b2298a6f18135d408aecf87e2c8370480ad640c3a1baa6bc2cbbad406f327c1a8b3c34705794838a6717902533
6
+ metadata.gz: 5f362de8ab1337aa62297ddd595c8912dba9b78fd8fa0fec15d2f22a4b8fbefade601bee257155a31a517a404a85789946785a2dd1b89255ad9f191cb8972eb4
7
+ data.tar.gz: 0f44e337fb7f5ac586e506d55a1ac1360b91ccf999f5a5551be781706537fb29e8956f6ceb53bb40e8552ce613dcfea5f8e5004513de453f7a53375bea3adc05
data/README.md CHANGED
@@ -4,7 +4,7 @@ Appship 是一款面向 iOS 开发者的命令行自动化工具,旨在简化
4
4
 
5
5
  它支持自动构建和导出 IPA,并将应用上传至蒲公英(Pgyer)或 fir.im,同时提供 API Key、访问密码和项目配置管理。上传完成后,终端会输出应用版本、密码状态及下载链接,方便团队快速进行测试和分发。
6
6
 
7
- ### 构建耗时对比
7
+ ### 构建耗时对比(构建机器: Apple M4 16gb)
8
8
 
9
9
  ```mermaid
10
10
  gantt
@@ -20,12 +20,15 @@ gantt
20
20
 
21
21
  ## 安装
22
22
 
23
- ```
24
- gem install appship
23
+ macOS 默认的 zsh 终端只需执行以下两条命令:
25
24
 
26
- export PATH="$(ruby -e 'print Gem.user_dir')/bin:$PATH"
25
+ ```bash
26
+ gem install appship
27
+ echo 'export PATH="$(ruby -rrubygems -e "print Gem.bindir"):$PATH"' >> ~/.zshrc && source ~/.zshrc
27
28
  ```
28
29
 
30
+ 第二条命令只需执行一次:将 gem 的命令目录写入 `~/.zshrc`,并立即加载。以后重新打开终端,即可直接执行 `appship pgyer` 或 `appship fir`。
31
+
29
32
  ### 环境依赖
30
33
 
31
34
  如果需要图标角标,还需要 ImageMagick:
@@ -50,25 +53,168 @@ appship pgyer
50
53
  appship fir
51
54
  ```
52
55
 
56
+ 按终端提示,配置构建信息
57
+
53
58
  等待片刻...
54
59
 
55
- ```
56
- =======================================================
57
- 🎉 发布完成 🎉
58
- =======================================================
59
- 分发平台: fir.im
60
+ 发布成功后,终端会输出发布消息(默认模板格式):
61
+
62
+ ```text
60
63
  应用名称: Demo
61
64
  应用类型: iOS
62
65
  版本信息: 1.0.0(42)
66
+ 构建模式: Release
67
+ 应用大小: 167.8MB
68
+ 更新时间: 2026-11-04 16:52:16
63
69
  更新描述: xxx
64
- 访问密码: xxx
65
- 下载链接: https://fir.im/abc123
66
- =======================================================
70
+ 安装密码: xxx
71
+ 下载链接: https://www.pgyer.com/xxxx
72
+ ```
73
+
74
+ ## 高阶用法
75
+
76
+ 在包含 `.xcodeproj` 的工程根目录执行:
77
+
78
+ ```bash
79
+ appship init
80
+ ```
81
+
82
+ 生成与 `.xcodeproj` 同级的目录和两个示例文件(已有文件不会覆盖):
83
+
84
+ ```text
85
+ Demo.xcodeproj/
86
+ appship/
87
+ config.yml.sample
88
+ message.yml.sample
89
+ ```
90
+
91
+ 示例文件不会参与运行。按需复制,去掉 `.sample` 后编辑:
92
+
93
+ ```bash
94
+ cp -n appship/config.yml.sample appship/config.yml
95
+ cp -n appship/message.yml.sample appship/message.yml
96
+ ```
97
+
98
+ `config.yml.sample` 从 `.xcodeproj` 识别 `project_name`,并用注释列出蒲公英、fir.im 和两个机器人的配置字段,不复制全局密钥。生效的 `appship/config.yml` 是 YAML,可只填需要覆盖的字段。例如:
99
+
100
+ ```yaml
101
+ project_name: Demo
102
+ app_name: DemoApp
103
+ pgyer_password: ""
104
+ wechat_webhook: ""
105
+ ...
67
106
  ```
68
107
 
69
- ## 其他用法
108
+ 参数优先级为:命令行参数 → 项目 `appship/config.yml` → `~/.appship/.config` 中同名项目 → 默认值。缺少的字段按字段补齐,不会使用其他项目的配置;项目配置中的空字符串也是明确的值,例如空密码表示不设密码,空 Webhook 表示关闭通知。想继承全局值时,应省略该字段,而非填空字符串。
109
+
110
+ ### App Icon 角标
111
+
112
+ 在 `appship/config.yml` 中可按需设置:
113
+
114
+ ```yaml
115
+ app_icon_badge: true
116
+ app_icon_badge_environment: true
117
+ app_icon_badge_version: false # 预留,目前不绘制版本号
118
+ ```
119
+
120
+ `app_icon_badge: false` 关闭所有角标;启用时默认显示 `DEBUG` 或 `RELEASE`,缓存模式以实际找到的包为准。
121
+ `app_icon_badge_environment: false` 只关闭自动环境文字;已有的自定义 `badge` 仍可用。
122
+ 两个开关都不填时,保留原来的选择和默认行为。
123
+
124
+ ## 自定义发布消息
125
+
126
+ 直接编辑项目下的 `appship/message.yml`,目前支持企微、钉钉、飞书机器人通知,配置多个则会发多条消息通知,格式配置如下:
127
+
128
+ ```yaml
129
+ cli_content: |
130
+ 应用名称:%{name}
131
+ 版本:%{version_info}
132
+ 下载:%{url}
133
+ wechat_webhook_success_content: |
134
+ **%{name} 发布成功**
135
+ [下载应用](%{url})
136
+ wechat_webhook_format: markdown
137
+ wechat_webhook_failure_content: |
138
+ ❌ %{name} 发布失败(%{configuration})
139
+ 原因:%{error_message}
140
+ dingtalk_webhook_success_content: ""
141
+ dingtalk_webhook_failure_content: ""
142
+ feishu_webhook_success_content: |
143
+ %{name} 发布成功
144
+ 下载:%{url}
145
+ feishu_webhook_failure_content: ""
146
+ feishu_webhook_format: post
147
+ ```
148
+
149
+ #### message.yml可读参数如下:
150
+
151
+ ###### 发布成功
152
+
153
+ ```
154
+ name : 应用名称
155
+ build_type_name : 应用类型,例如 iOS、Android
156
+ version_info : 版本信息,例如 1.0.0(42)
157
+ configuration : 构建配置,例如 Debug、Release
158
+ build_size_mb : 应用大小,例如 39.5MB
159
+ build_updated : 更新时间
160
+ description : 本次更新内容
161
+ password : 安装密码或密码设置状态
162
+ url : 应用下载链接
163
+ ```
164
+
165
+ ###### 失败
166
+
167
+ 以下变量用于各机器人的 `*_webhook_failure_content`,在文案中写成 `%{变量名}`。
168
+
169
+ ```text
170
+ name : 应用名称,缺失时使用工程名;两者都未取得时显示「未知」
171
+ provider : 分发平台,例如「蒲公英」或「fir.im」
172
+ configuration : 构建配置,例如 Debug、Release;未取得时显示「未选择」
173
+ error_type : 异常类型,例如 Appship::CommandError
174
+ error_message : 具体错误说明,例如「归档失败」或「上传失败」
175
+ ```
176
+
177
+ 例如,在 `appship/message.yml` 中自定义飞书失败文案:
178
+
179
+ ```yaml
180
+ feishu_webhook_failure_content: |
181
+ ❌ %{name} 发布失败(%{provider} / %{configuration})
182
+ 错误类型:%{error_type}
183
+ 失败原因:%{error_message}
184
+ ```
185
+
186
+ 失败文案不填写或无效时使用内置文案。失败通知以纯文本发送,发送前会隐藏已知的 API Key、密码、Webhook 地址和 URL 查询参数。
187
+
188
+ [企微 Markdown 配置](https://cloud.tencent.com/document/product/248/50413)
189
+ [飞书自定义机器人](https://open.feishu.cn/document/client-docs/bot-v3/add-custom-bot)
190
+ [钉钉群自定义机器人说明](https://open-dingtalk.github.io/developerpedia/docs/learn/bot/webhook/overview/)
191
+
192
+ 钉钉存量自定义机器人需配置匹配消息内容的关键词或出口 IP 白名单;Appship 不支持钉钉加签。Webhook 地址不会回显到终端,配置文件权限保持为 `0600`。
193
+ 接口参考:
194
+ [企业微信文本消息示例](https://cloud.tencent.com/document/product/1831/134518)
195
+ [钉钉机器人安全设置](https://open.dingtalk.com/document/robots/customize-robot-security-settings)
196
+
197
+ ## 本地配置命令
198
+
199
+ #### 通知机器人
200
+
201
+ `wechat_webhook`(企业微信)、`dingtalk_webhook`(钉钉)和 `feishu_webhook`(飞书)都是可选字段,不填写或留空时不发送通知。在项目目录执行以下命令保存地址;URL 需要用引号包裹:
202
+
203
+ ```bash
204
+ appship webhook wechat "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
205
+ appship webhook dingtalk "https://oapi.dingtalk.com/robot/send?access_token=xxx"
206
+ appship webhook feishu "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
207
+ ```
208
+
209
+ #### 关闭某个平台的通知:
210
+
211
+ ```bash
212
+ appship webhook wechat ""
213
+ appship webhook dingtalk ""
214
+ appship webhook feishu ""
215
+ ```
70
216
 
71
- 更换安装密码
217
+ #### 更换安装密码
72
218
 
73
219
  ```
74
220
  appship pgyer -password xxx
@@ -76,7 +222,7 @@ appship pgyer -password xxx
76
222
  appship fir -password xxx
77
223
  ```
78
224
 
79
- 更换上传秘钥
225
+ #### 更换上传秘钥
80
226
 
81
227
  ```
82
228
  appship pgyer -key xxx
@@ -87,8 +233,8 @@ appship fir -key xxx
87
233
  ## 待办
88
234
 
89
235
  ```
90
- 1、补充消息机器人
91
- 2、自定义消息格式
92
- 3、其他分发平台
236
+ 1、其他分发平台
237
+ 2、AI 接入git log ,自动归纳更新描述
238
+ 3、优化命令
93
239
  ...
94
240
  ```
@@ -9,6 +9,7 @@ module Appship
9
9
  end
10
10
 
11
11
  def build!(options)
12
+ templates = Message.templates if options[:upload]
12
13
  target = target_options(options)
13
14
  scheme = required!(options[:scheme], "--scheme 或配置 scheme")
14
15
  configuration = options[:configuration] || "Debug"
@@ -58,7 +59,7 @@ module Appship
58
59
  end
59
60
  puts "✅ IPA 已生成: #{output}"
60
61
 
61
- upload_if_requested!(output, options)
62
+ upload_if_requested!(output, options, templates)
62
63
  output
63
64
  ensure
64
65
  FileUtils.rm_rf(temp_root) if temp_root && !options[:keep_temporary]
@@ -99,6 +100,7 @@ module Appship
99
100
  end
100
101
 
101
102
  def package!(options)
103
+ templates = Message.templates if options[:upload]
102
104
  app = File.expand_path(required!(options[:app], "--app"))
103
105
  raise ConfigurationError, ".app 不存在: #{app}" unless File.directory?(app)
104
106
 
@@ -107,17 +109,16 @@ module Appship
107
109
  runner.require_command!("unzip", "macOS 通常自带 unzip") if options[:badge]
108
110
  Ipa.package!(app, output: output, badge: options[:badge], assets: options[:assets], app_icon: options[:app_icon], runner: runner)
109
111
  puts "✅ IPA 已生成: #{output}"
110
- upload_if_requested!(output, options)
112
+ upload_if_requested!(output, options, templates)
111
113
  output
112
114
  end
113
115
 
114
116
  def upload!(options)
117
+ templates = Message.templates
115
118
  file = File.expand_path(required!(options[:file], "IPA 文件路径"))
116
119
  raise ConfigurationError, "文件不存在: #{file}" unless File.file?(file)
117
120
 
118
- result = Uploader.new(options).upload!(file)
119
- Display.upload_result(result, file: file, options: options)
120
- result
121
+ upload_file!(file, options, templates)
121
122
  end
122
123
 
123
124
  private
@@ -191,11 +192,17 @@ module Appship
191
192
  PLIST
192
193
  end
193
194
 
194
- def upload_if_requested!(file, options)
195
+ def upload_if_requested!(file, options, templates = nil)
195
196
  return unless options[:upload]
196
197
 
198
+ upload_file!(file, options, templates || Message.templates)
199
+ end
200
+
201
+ def upload_file!(file, options, templates)
197
202
  result = Uploader.new(options).upload!(file)
198
- Display.upload_result(result, file: file, options: options)
203
+ messages = Display.upload_result(result, file: file, options: options, templates: templates)
204
+ Webhook.new(options).notify(messages, formats: templates.slice(*Message::FORMATS.keys))
205
+ result
199
206
  end
200
207
 
201
208
  def required!(value, name)
@@ -212,12 +219,18 @@ module Appship
212
219
  module Display
213
220
  module_function
214
221
 
215
- def upload_result(result, file:, options: {})
216
- if result.is_a?(Hash) && result["provider"] == "fir"
217
- fir_upload_result(result, file: file, options: options)
218
- return
219
- end
222
+ def upload_result(result, file:, options: {}, templates: Message.templates)
223
+ values = if result.is_a?(Hash) && result["provider"] == "fir"
224
+ fir_message_values(result, file: file, options: options)
225
+ else
226
+ pgyer_message_values(result, file: file, options: options)
227
+ end
228
+ messages = templates.slice(*Message::CONTENT_FIELDS).transform_values { |body| Message.render_text(body, values) }
229
+ puts messages.fetch("cli_content")
230
+ messages
231
+ end
220
232
 
233
+ def pgyer_message_values(result, file:, options:)
221
234
  data = result.is_a?(Hash) ? (result["data"] || result) : {}
222
235
  key = deep_find(data, "buildKey")
223
236
  name = deep_find(data, "buildName") || options[:app_name] || File.basename(file, File.extname(file))
@@ -240,23 +253,20 @@ module Appship
240
253
  password ||= "未设置"
241
254
  url = key ? "https://www.pgyer.com/#{key}" : "https://www.pgyer.com/"
242
255
 
243
- puts ""
244
- puts "======================================================="
245
- puts " 🎉 发布完成 🎉 "
246
- puts "======================================================="
247
- puts "应用名称: #{name}"
248
- puts "应用类型: #{build_type_name}"
249
- puts "版本信息: #{version_info}" if version_info
250
- puts "构建模式: #{configuration}"
251
- puts "应用大小: #{build_size_mb}" if build_size_mb
252
- puts "更新时间: #{build_updated}" if build_updated && !build_updated.to_s.empty?
253
- puts "更新描述: #{description}"
254
- puts "安装密码: #{password}"
255
- puts "下载链接: #{url}"
256
- puts "======================================================="
256
+ {
257
+ name: name,
258
+ build_type_name: build_type_name,
259
+ version_info: version_info,
260
+ configuration: configuration,
261
+ build_size_mb: build_size_mb,
262
+ build_updated: build_updated.to_s.empty? ? nil : build_updated,
263
+ description: description,
264
+ password: password,
265
+ url: url
266
+ }
257
267
  end
258
268
 
259
- def fir_upload_result(result, file:, options: {})
269
+ def fir_message_values(result, file:, options:)
260
270
  data = result["data"].is_a?(Hash) ? result["data"] : result
261
271
  name = data["name"] || options[:app_name] || File.basename(file, File.extname(file))
262
272
  type = data["type"].to_s == "android" ? "Android" : "iOS"
@@ -266,18 +276,17 @@ module Appship
266
276
  build && !build.to_s.empty? ? "#{version}(#{build})" : version.to_s
267
277
  end
268
278
 
269
- puts ""
270
- puts "======================================================="
271
- puts " 🎉 发布完成 🎉 "
272
- puts "======================================================="
273
- puts "分发平台: fir.im"
274
- puts "应用名称: #{name}"
275
- puts "应用类型: #{type}"
276
- puts "版本信息: #{version_info}" if version_info
277
- puts "更新描述: #{options[:description] || ""}"
278
- puts "访问密码: #{data["password_protected"] ? "已设置" : "未设置"}"
279
- puts "下载链接: #{result["url"]}"
280
- puts "======================================================="
279
+ {
280
+ name: name,
281
+ build_type_name: type,
282
+ version_info: version_info,
283
+ configuration: nil,
284
+ build_size_mb: nil,
285
+ build_updated: nil,
286
+ description: options[:description] || "",
287
+ password: data["password_protected"] ? "已设置" : "未设置",
288
+ url: result["url"] || ""
289
+ }
281
290
  end
282
291
 
283
292
  def deep_find(value, wanted_key)
data/lib/appship/cache.rb CHANGED
@@ -30,7 +30,11 @@ module Appship
30
30
  raise ConfigurationError, missing_credentials_message(expanded, provider, empty: true)
31
31
  end
32
32
 
33
- new(expanded)
33
+ cache = new(expanded)
34
+ return cache unless cache.entries.empty?
35
+ return collect_interactively!(expanded, project_dir: project_dir, provider: provider) if interactive && $stdin.tty?
36
+
37
+ raise ConfigurationError, missing_credentials_message(expanded, provider, empty: true)
34
38
  end
35
39
 
36
40
  def self.create_file!(path)
@@ -41,24 +45,29 @@ module Appship
41
45
  path
42
46
  end
43
47
 
44
- def self.collect_interactively!(path, project_dir: Dir.pwd, provider: "pgyer")
45
- project_name = infer_project_name!(project_dir)
48
+ def self.collect_interactively!(path, project_dir: Dir.pwd, provider: "pgyer", entries: [], project_name: nil, credential_overrides: {})
49
+ project_name ||= infer_project_name!(project_dir)
50
+ unless [".xcodeproj", ".xcworkspace"].any? { |extension| File.directory?(File.join(project_dir, "#{project_name}#{extension}")) }
51
+ raise ConfigurationError, "当前目录找不到 #{project_name}.xcodeproj 或 #{project_name}.xcworkspace,无法新增项目配置: #{File.expand_path(project_dir)}"
52
+ end
46
53
  provider_name = provider.to_s == "fir" ? "fir.im" : "pgyer"
47
54
  credential_key = credential_key_for(provider)
48
55
  puts ""
49
- puts "首次使用 appship #{provider_name},请填写本机项目缓存。"
56
+ puts "首次使用当前项目的 appship #{provider_name},请填写本机项目缓存。"
50
57
  puts "缓存文件: #{path}"
51
58
  puts ""
52
- puts "✅ 已从 #{File.basename(project_name)}.xcodeproj 识别 project_name: #{project_name}"
59
+ puts "✅ 已识别 project_name: #{project_name}"
53
60
  app_name = prompt_required("app_name")
54
61
  data = {
55
62
  "project_name" => project_name,
56
63
  "app_name" => app_name,
57
- credential_key => prompt_required(credential_key)
64
+ "wechat_webhook" => "",
65
+ "dingtalk_webhook" => "",
66
+ credential_key => credential_overrides[credential_key] || prompt_required(credential_key)
58
67
  }
59
68
  password_key = provider.to_s == "fir" ? "fir_password" : "pgyer_password"
60
- data[password_key] = prompt_optional(password_key)
61
- File.write(path, JSON.pretty_generate([data]) + "\n")
69
+ data[password_key] = credential_overrides.fetch(password_key) { prompt_optional(password_key) }
70
+ File.write(path, JSON.pretty_generate(entries + [data]) + "\n")
62
71
  File.chmod(0o600, path)
63
72
  puts ""
64
73
  puts "✅ 本机缓存已保存: #{path}"
@@ -88,7 +97,10 @@ module Appship
88
97
 
89
98
  def self.prompt_optional(name)
90
99
  print "请输入 #{name}(可为空,直接回车表示不设置密码): "
91
- $stdin.gets.to_s.strip
100
+ value = $stdin.gets
101
+ raise ConfigurationError, "配置输入已中断,请重新执行命令继续填写" unless value
102
+
103
+ value.strip
92
104
  end
93
105
 
94
106
  def initialize(path = DEFAULT_PATH)
@@ -98,31 +110,55 @@ module Appship
98
110
  @entries = parse(File.read(@path))
99
111
  raise ConfigurationError, "缓存文件必须是字典数组: #{@path}" unless @entries.is_a?(Array)
100
112
  @entries = @entries.map { |entry| normalize(entry) }
101
- raise ConfigurationError, "缓存文件中没有有效项目: #{@path}" if @entries.empty?
102
113
  rescue Psych::SyntaxError => e
103
114
  raise ConfigurationError, "缓存文件解析失败: #{e.message}"
104
115
  end
105
116
 
106
- def resolve(project_name: nil, project_dir: Dir.pwd, provider: "pgyer", interactive: true, credential_overrides: {})
107
- candidate_name = project_name.to_s.strip
108
- if candidate_name.empty? && @entries.length > 1
109
- directory_name = File.basename(File.expand_path(project_dir))
110
- candidate_name = directory_name if @entries.any? { |entry| entry["project_name"] == directory_name }
117
+ def resolve(project_name: nil, project_dir: Dir.pwd, provider: "pgyer", interactive: true, credential_overrides: {}, credentials: true)
118
+ candidate_names = [project_name.to_s.strip].reject(&:empty?)
119
+ if candidate_names.empty?
120
+ directory = File.expand_path(project_dir)
121
+ candidate_names = Dir[File.join(directory, "*.xcodeproj"), File.join(directory, "*.xcworkspace")]
122
+ .select { |path| File.directory?(path) }
123
+ .map { |path| File.basename(path, File.extname(path)) }.uniq
124
+ if candidate_names.empty? && @entries.length > 1
125
+ directory_name = File.basename(directory)
126
+ candidate_names = [directory_name] if @entries.any? { |entry| entry["project_name"] == directory_name }
127
+ end
111
128
  end
112
129
 
113
- matches = if candidate_name.empty?
130
+ matches = if candidate_names.empty?
114
131
  @entries
115
132
  else
116
- @entries.select { |entry| entry["project_name"] == candidate_name }
133
+ @entries.select { |entry| candidate_names.include?(entry["project_name"]) }
117
134
  end
118
135
  if matches.length == 1
119
136
  update_profile!(matches.first, credential_overrides) unless credential_overrides.empty?
120
- ensure_provider_credential!(matches.first, provider, interactive: interactive)
137
+ ensure_provider_credential!(matches.first, provider, interactive: interactive) if credentials
121
138
  return matches.first
122
139
  end
123
140
  if matches.empty?
141
+ if interactive && $stdin.tty?
142
+ puts "ℹ️ 当前项目没有匹配的 project_name,将引导新增项目配置。"
143
+ cache = self.class.collect_interactively!(
144
+ @path,
145
+ project_dir: project_dir,
146
+ provider: provider,
147
+ entries: @entries,
148
+ project_name: candidate_names.one? ? candidate_names.first : nil,
149
+ credential_overrides: credential_overrides
150
+ )
151
+ @entries = cache.entries
152
+ return @entries.last
153
+ end
154
+
124
155
  available = @entries.filter_map { |entry| entry["project_name"] }.join(", ")
125
- raise ConfigurationError, "找不到项目缓存 #{candidate_name.inspect},可用项目: #{available}"
156
+ key = self.class.send(:credential_key_for, provider)
157
+ raise ConfigurationError, "当前项目没有匹配的 project_name: #{candidate_names.join(", ")}\n" \
158
+ "请在 #{@path} 的配置数组中增加当前项目配置,保留已有项目。\n" \
159
+ "project_name 应填写: #{candidate_names.join(" 或 ")}(不含 .xcodeproj/.xcworkspace 后缀)\n" \
160
+ "同时填写 app_name、#{key};安装密码可为空。\n" \
161
+ "已有项目: #{available}"
126
162
  end
127
163
 
128
164
  names = matches.filter_map { |entry| entry["project_name"] }.join(", ")