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
data/lib/appship/cli.rb
CHANGED
|
@@ -13,13 +13,17 @@ module Appship
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def start(argv)
|
|
16
|
+
@notification_options = nil
|
|
16
17
|
args = argv.dup
|
|
17
18
|
global = extract_global_options!(args)
|
|
18
19
|
command = args.shift
|
|
20
|
+
@notification_options = {} if %w[build package upload pgyer fir].include?(command)
|
|
19
21
|
|
|
20
22
|
case command
|
|
21
23
|
when "init"
|
|
22
24
|
init(args)
|
|
25
|
+
when "webhook"
|
|
26
|
+
webhook(args)
|
|
23
27
|
when "doctor"
|
|
24
28
|
doctor
|
|
25
29
|
when "build"
|
|
@@ -39,10 +43,45 @@ module Appship
|
|
|
39
43
|
else
|
|
40
44
|
raise ConfigurationError, "未知命令: #{command}\n\n#{help}"
|
|
41
45
|
end
|
|
46
|
+
rescue StandardError, Interrupt => e
|
|
47
|
+
notify_failure(e)
|
|
48
|
+
raise
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
private
|
|
45
52
|
|
|
53
|
+
def notify_failure(error)
|
|
54
|
+
return unless @notification_options
|
|
55
|
+
|
|
56
|
+
options = @notification_options.dup
|
|
57
|
+
unless options.values_at(*Webhook::URL_FIELDS).any? { |url| !url.to_s.strip.empty? }
|
|
58
|
+
path = File.expand_path(options[:env_file] || ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH))
|
|
59
|
+
directory = File.expand_path(options[:project_dir] || Dir.pwd)
|
|
60
|
+
local_path = ProjectConfig.path(directory)
|
|
61
|
+
return unless File.file?(path) || File.exist?(local_path) || File.symlink?(local_path)
|
|
62
|
+
|
|
63
|
+
if options[:project_name].to_s.empty? && !File.file?(local_path) && Dir[File.join(directory, "*.xcodeproj"), File.join(directory, "*.xcworkspace")].empty?
|
|
64
|
+
return
|
|
65
|
+
end
|
|
66
|
+
begin
|
|
67
|
+
profile = ProjectConfig.load(path, project_dir: directory, interactive: false)
|
|
68
|
+
.resolve(project_name: options[:project_name], project_dir: directory,
|
|
69
|
+
interactive: false, credentials: false)
|
|
70
|
+
rescue Error, SystemCallError
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
options = {
|
|
74
|
+
app_name: profile["app_name"], project_name: profile["project_name"],
|
|
75
|
+
api_key: profile["pgyer_api_key"], password: profile["pgyer_password"],
|
|
76
|
+
fir_api_token: profile["fir_api_key"], fir_password: profile["fir_password"]
|
|
77
|
+
}.merge(options.compact)
|
|
78
|
+
Webhook::OPTION_FIELDS.each { |key| options[key] = profile[key.to_s] }
|
|
79
|
+
end
|
|
80
|
+
Webhook.new(options).failure(error)
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
warn "⚠️ 失败通知未能发送(#{e.class}),保留原始错误"
|
|
83
|
+
end
|
|
84
|
+
|
|
46
85
|
def extract_global_options!(args)
|
|
47
86
|
config_path = nil
|
|
48
87
|
verbose = false
|
|
@@ -79,16 +118,47 @@ module Appship
|
|
|
79
118
|
end
|
|
80
119
|
|
|
81
120
|
def init(args)
|
|
82
|
-
|
|
83
|
-
force = false
|
|
121
|
+
project_dir = Dir.pwd
|
|
84
122
|
parser = OptionParser.new do |opts|
|
|
85
123
|
opts.banner = "用法: appship init [选项]"
|
|
86
|
-
opts.on("--
|
|
87
|
-
opts.on("--force", "覆盖已有配置文件") { force = true }
|
|
124
|
+
opts.on("--project-dir PATH", "工程目录(默认当前目录)") { |value| project_dir = value }
|
|
88
125
|
opts.on("-h", "--help", "显示帮助") { puts opts; exit 0 }
|
|
89
126
|
end
|
|
90
127
|
parser.parse!(args)
|
|
91
|
-
|
|
128
|
+
raise ConfigurationError, "init 不接受多余参数: #{args.join(" ")}" unless args.empty?
|
|
129
|
+
|
|
130
|
+
ProjectConfig.init!(project_dir: File.expand_path(project_dir)).each do |path|
|
|
131
|
+
puts "✅ 项目示例已就绪(已有内容不会覆盖): #{path}"
|
|
132
|
+
end
|
|
133
|
+
puts "复制为 appship/config.yml 和 appship/message.yml 后编辑,示例文件不会参与运行。"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def webhook(args)
|
|
137
|
+
options = { project_dir: Dir.pwd, env_file: ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH), project_name: nil }
|
|
138
|
+
parser = OptionParser.new do |opts|
|
|
139
|
+
opts.banner = '用法: appship webhook wechat|dingtalk|feishu URL [选项](URL 传 "" 可关闭通知)'
|
|
140
|
+
opts.on("--project-dir PATH", "项目目录(默认当前目录)") { |value| options[:project_dir] = value }
|
|
141
|
+
opts.on("--env-file PATH", "本机缓存文件(默认 ~/.appship/.config)") { |value| options[:env_file] = value }
|
|
142
|
+
opts.on("--project-name NAME", "配置中的 project_name") { |value| options[:project_name] = value }
|
|
143
|
+
opts.on("-h", "--help", "显示帮助") { puts opts; return }
|
|
144
|
+
end
|
|
145
|
+
parser.parse!(args)
|
|
146
|
+
return puts(parser) if args.empty?
|
|
147
|
+
unless args.length == 2 && Webhook::PLATFORMS.key?(args.first)
|
|
148
|
+
raise ConfigurationError, "请指定 wechat、dingtalk 或 feishu,以及完整的 Webhook URL\n#{parser}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
platform, url = args
|
|
152
|
+
url = url.strip
|
|
153
|
+
Webhook.parse_url!(url) unless url.empty?
|
|
154
|
+
project_dir = File.expand_path(options[:project_dir])
|
|
155
|
+
raise ConfigurationError, "项目目录不存在: #{project_dir}" unless File.directory?(project_dir)
|
|
156
|
+
|
|
157
|
+
cache = ProjectConfig.load(options[:env_file], project_dir: project_dir, interactive: false)
|
|
158
|
+
profile = cache.resolve(project_name: options[:project_name], project_dir: project_dir, interactive: false, credentials: false)
|
|
159
|
+
cache.update_profile!(profile, "#{platform}_webhook" => url)
|
|
160
|
+
action = url.empty? ? "已关闭" : "已保存"
|
|
161
|
+
puts "✅ #{Webhook::PLATFORMS.fetch(platform)}通知#{action}(#{profile["project_name"]}): #{cache.path}"
|
|
92
162
|
end
|
|
93
163
|
|
|
94
164
|
def doctor
|
|
@@ -112,14 +182,16 @@ module Appship
|
|
|
112
182
|
puts "#{available ? "✅" : "⚠️"} #{command.ljust(16)} #{available || "未安装"} - #{purpose}"
|
|
113
183
|
end
|
|
114
184
|
config = Config.find
|
|
115
|
-
puts "#{config.path ? "✅" : "ℹ️"} 配置文件 #{config.path || "当前目录没有 .appship.yml"}"
|
|
185
|
+
puts "#{config.path ? "✅" : "ℹ️"} 配置文件 #{config.path || "当前目录没有 appship/config.yml 或 .appship.yml"}"
|
|
116
186
|
end
|
|
117
187
|
|
|
118
188
|
def build(args, global)
|
|
119
189
|
config = Config.find(global[:config_path])
|
|
120
190
|
options = build_options(config)
|
|
191
|
+
@notification_options = options
|
|
121
192
|
parser = build_parser(options)
|
|
122
193
|
parser.parse!(args)
|
|
194
|
+
apply_badge_config!(options, config.data)
|
|
123
195
|
Builder.new(runner: Runner.new(verbose: global[:verbose])).build!(options)
|
|
124
196
|
end
|
|
125
197
|
|
|
@@ -128,18 +200,21 @@ module Appship
|
|
|
128
200
|
options = upload_options(config).merge(
|
|
129
201
|
app: nil,
|
|
130
202
|
output: nil,
|
|
131
|
-
|
|
203
|
+
configuration: config.get("configuration"),
|
|
132
204
|
assets: config.get("assets"),
|
|
133
205
|
app_icon: config.get("app_icon", default: "AppIcon")
|
|
134
206
|
)
|
|
207
|
+
@notification_options = options
|
|
135
208
|
parser = package_parser(options)
|
|
136
209
|
parser.parse!(args)
|
|
210
|
+
apply_badge_config!(options, config.data)
|
|
137
211
|
Builder.new(runner: Runner.new(verbose: global[:verbose])).package!(options)
|
|
138
212
|
end
|
|
139
213
|
|
|
140
214
|
def upload(args, global)
|
|
141
215
|
config = Config.find(global[:config_path])
|
|
142
216
|
options = upload_options(config).merge(file: nil)
|
|
217
|
+
@notification_options = options
|
|
143
218
|
parser = upload_parser(options)
|
|
144
219
|
parser.parse!(args)
|
|
145
220
|
options[:file] ||= args.shift
|
|
@@ -190,6 +265,7 @@ module Appship
|
|
|
190
265
|
skip_package_plugin_validation: true,
|
|
191
266
|
skip_macro_validation: true
|
|
192
267
|
}
|
|
268
|
+
@notification_options = options
|
|
193
269
|
parser = pgyer_parser(options)
|
|
194
270
|
normalize_provider_short_options!(args, "pgyer")
|
|
195
271
|
parser.parse!(args)
|
|
@@ -200,7 +276,7 @@ module Appship
|
|
|
200
276
|
env_file = File.expand_path(options[:env_file], Dir.pwd)
|
|
201
277
|
|
|
202
278
|
Dir.chdir(project_dir) do
|
|
203
|
-
cache =
|
|
279
|
+
cache = ProjectConfig.load(env_file, project_dir: project_dir, interactive: true, provider: "pgyer")
|
|
204
280
|
profile = cache.resolve(
|
|
205
281
|
project_name: options[:project_name],
|
|
206
282
|
project_dir: project_dir,
|
|
@@ -213,7 +289,7 @@ module Appship
|
|
|
213
289
|
|
|
214
290
|
runner = Runner.new(verbose: global[:verbose])
|
|
215
291
|
builder = Builder.new(runner: runner)
|
|
216
|
-
prepare_pgyer_flow!(options, builder)
|
|
292
|
+
prepare_pgyer_flow!(options, builder, profile)
|
|
217
293
|
if options[:build_mode] == "cache"
|
|
218
294
|
options[:app] ||= builder.find_cached_app!(options)
|
|
219
295
|
builder.package!(options)
|
|
@@ -267,6 +343,7 @@ module Appship
|
|
|
267
343
|
skip_package_plugin_validation: true,
|
|
268
344
|
skip_macro_validation: true
|
|
269
345
|
}
|
|
346
|
+
@notification_options = options
|
|
270
347
|
parser = fir_parser(options)
|
|
271
348
|
normalize_provider_short_options!(args, "fir")
|
|
272
349
|
parser.parse!(args)
|
|
@@ -277,7 +354,7 @@ module Appship
|
|
|
277
354
|
env_file = File.expand_path(options[:env_file], Dir.pwd)
|
|
278
355
|
|
|
279
356
|
Dir.chdir(project_dir) do
|
|
280
|
-
cache =
|
|
357
|
+
cache = ProjectConfig.load(env_file, project_dir: project_dir, interactive: true, provider: "fir")
|
|
281
358
|
profile = cache.resolve(
|
|
282
359
|
project_name: options[:project_name],
|
|
283
360
|
project_dir: project_dir,
|
|
@@ -290,7 +367,7 @@ module Appship
|
|
|
290
367
|
|
|
291
368
|
runner = Runner.new(verbose: global[:verbose])
|
|
292
369
|
builder = Builder.new(runner: runner)
|
|
293
|
-
prepare_pgyer_flow!(options, builder)
|
|
370
|
+
prepare_pgyer_flow!(options, builder, profile)
|
|
294
371
|
if options[:build_mode] == "cache"
|
|
295
372
|
options[:app] ||= builder.find_cached_app!(options)
|
|
296
373
|
builder.package!(options)
|
|
@@ -321,7 +398,7 @@ module Appship
|
|
|
321
398
|
options[:configuration] ||= profile["configuration"] || "Debug"
|
|
322
399
|
options[:assets] ||= profile["assets"]
|
|
323
400
|
options[:app_icon] ||= profile["app_icon"] || "AppIcon"
|
|
324
|
-
options[
|
|
401
|
+
Webhook::OPTION_FIELDS.each { |key| options[key] = profile[key.to_s] }
|
|
325
402
|
if provider.to_s != "fir"
|
|
326
403
|
options[:password_env] ||= profile["pgyer_password_env"]
|
|
327
404
|
options[:password] ||= profile["pgyer_password"]
|
|
@@ -349,12 +426,16 @@ module Appship
|
|
|
349
426
|
elsif File.exist?(project)
|
|
350
427
|
options[:project] = project
|
|
351
428
|
else
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
429
|
+
names = Dir["*.xcworkspace", "*.xcodeproj"].select { |path| File.directory?(path) }
|
|
430
|
+
.map { |path| File.basename(path, File.extname(path)) }.uniq
|
|
431
|
+
config_path = File.expand_path(options[:env_file] || Cache::DEFAULT_PATH)
|
|
432
|
+
hint = if names.empty?
|
|
433
|
+
"请先 cd 到包含 .xcodeproj/.xcworkspace 的项目目录,或使用 --project-dir 指定目录。"
|
|
434
|
+
else
|
|
435
|
+
"当前目录可配置的 project_name: #{names.join(", ")}(不含文件后缀)"
|
|
436
|
+
end
|
|
437
|
+
raise ConfigurationError, "找不到 #{workspace} 或 #{project}\n当前目录: #{Dir.pwd}\n" \
|
|
438
|
+
"请在 #{config_path} 的配置数组中增加当前项目配置,设置正确的 project_name。\n#{hint}"
|
|
358
439
|
end
|
|
359
440
|
|
|
360
441
|
if options[:assets].to_s.empty?
|
|
@@ -370,7 +451,7 @@ module Appship
|
|
|
370
451
|
"自动编译打包 | 分支:#{branch} | 构建人:#{user} | 配置:#{options[:configuration]} | 更新内容:#{content}"
|
|
371
452
|
end
|
|
372
453
|
|
|
373
|
-
def prepare_pgyer_flow!(options, builder)
|
|
454
|
+
def prepare_pgyer_flow!(options, builder, profile)
|
|
374
455
|
interactive = $stdin.tty? && $stdout.tty?
|
|
375
456
|
unless options[:build_mode_explicit]
|
|
376
457
|
options[:build_mode] = choose_build_mode if interactive
|
|
@@ -380,10 +461,8 @@ module Appship
|
|
|
380
461
|
options[:configuration] = choose_configuration
|
|
381
462
|
end
|
|
382
463
|
|
|
383
|
-
if options[:build_mode]
|
|
384
|
-
options
|
|
385
|
-
elsif !options[:badge_explicit] && interactive
|
|
386
|
-
options[:badge] = choose_badge ? badge_text(options[:configuration]) : nil
|
|
464
|
+
if options[:build_mode] != "cache"
|
|
465
|
+
apply_badge_config!(options, profile) { interactive && choose_badge }
|
|
387
466
|
end
|
|
388
467
|
|
|
389
468
|
unless options[:description_explicit] || !interactive
|
|
@@ -393,18 +472,13 @@ module Appship
|
|
|
393
472
|
end
|
|
394
473
|
|
|
395
474
|
if options[:build_mode] == "cache"
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
options
|
|
400
|
-
builder.find_cached_app!(options)
|
|
401
|
-
end
|
|
402
|
-
puts "✅ 找到缓存 App: #{options[:app]}"
|
|
403
|
-
rescue CommandError => e
|
|
404
|
-
puts "⚠️ #{e.message},将切换到重新构建模式"
|
|
405
|
-
options[:build_mode] = "rebuild"
|
|
406
|
-
options[:app] = nil
|
|
475
|
+
puts ""
|
|
476
|
+
puts "📦 使用缓存模式"
|
|
477
|
+
options[:app] ||= with_spinner("正在查找编译缓存中的 .app 文件") do
|
|
478
|
+
builder.find_cached_app!(options)
|
|
407
479
|
end
|
|
480
|
+
puts "✅ 找到缓存 App: #{options[:app]}"
|
|
481
|
+
apply_badge_config!(options, profile) { true }
|
|
408
482
|
end
|
|
409
483
|
|
|
410
484
|
options[:description] ||= default_description(options)
|
|
@@ -539,6 +613,22 @@ module Appship
|
|
|
539
613
|
nil
|
|
540
614
|
end
|
|
541
615
|
|
|
616
|
+
def apply_badge_config!(options, profile)
|
|
617
|
+
return if options[:badge_explicit]
|
|
618
|
+
|
|
619
|
+
%w[app_icon_badge app_icon_badge_environment].each do |key|
|
|
620
|
+
next if [nil, true, false].include?(profile[key])
|
|
621
|
+
|
|
622
|
+
raise ConfigurationError, "#{key} 必须是布尔值 true 或 false"
|
|
623
|
+
end
|
|
624
|
+
enabled = profile["app_icon_badge"]
|
|
625
|
+
environment = profile["app_icon_badge_environment"]
|
|
626
|
+
options[:badge] = enabled == false ? nil : profile["badge"]
|
|
627
|
+
return if enabled == false || environment == false || options[:badge]
|
|
628
|
+
|
|
629
|
+
options[:badge] = badge_text(options[:configuration]) if enabled || environment || (block_given? && yield)
|
|
630
|
+
end
|
|
631
|
+
|
|
542
632
|
def badge_text(configuration)
|
|
543
633
|
configuration.to_s.casecmp("release").zero? ? "RELEASE" : "DEBUG"
|
|
544
634
|
end
|
|
@@ -562,7 +652,6 @@ module Appship
|
|
|
562
652
|
export_method: config.get("export_method", default: "development"),
|
|
563
653
|
signing_style: config.get("signing_style", default: "automatic"),
|
|
564
654
|
team_id: config.get("team_id"),
|
|
565
|
-
badge: config.get("badge"),
|
|
566
655
|
assets: config.get("assets"),
|
|
567
656
|
app_icon: config.get("app_icon", default: "AppIcon"),
|
|
568
657
|
upload: false,
|
|
@@ -576,27 +665,28 @@ module Appship
|
|
|
576
665
|
|
|
577
666
|
def upload_options(config)
|
|
578
667
|
{
|
|
579
|
-
provider: config.get("upload", "provider", default: "pgyer"),
|
|
580
|
-
api_key:
|
|
668
|
+
provider: config.get("upload", "provider", default: config.get("provider", default: "pgyer")),
|
|
669
|
+
api_key: config.get("pgyer_api_key"),
|
|
581
670
|
api_key_env: config.get("upload", "api_key_env", default: "PGYER_API_KEY"),
|
|
582
|
-
fir_api_token:
|
|
671
|
+
fir_api_token: config.get("fir_api_key"),
|
|
583
672
|
fir_api_token_env: config.get("upload", "fir_api_token_env", default: "FIR_API_TOKEN"),
|
|
584
|
-
fir_password: config.get("upload", "fir_password"),
|
|
673
|
+
fir_password: config.get("upload", "fir_password", default: config.get("fir_password")),
|
|
585
674
|
fir_password_env: config.get("upload", "fir_password_env", default: "FIR_PASSWORD"),
|
|
586
|
-
bundle_id: config.get("upload", "bundle_id"),
|
|
587
|
-
app_name: config.get("upload", "app_name"),
|
|
588
|
-
app_version: config.get("upload", "app_version"),
|
|
589
|
-
build_number: config.get("upload", "build_number"),
|
|
675
|
+
bundle_id: config.get("upload", "bundle_id", default: config.get("bundle_id")),
|
|
676
|
+
app_name: config.get("upload", "app_name", default: config.get("app_name")),
|
|
677
|
+
app_version: config.get("upload", "app_version", default: config.get("app_version")),
|
|
678
|
+
build_number: config.get("upload", "build_number", default: config.get("build_number")),
|
|
590
679
|
release_type: config.get("upload", "release_type"),
|
|
591
680
|
icon: config.get("upload", "icon"),
|
|
592
|
-
install_type: config.get("upload", "install_type", default: 1),
|
|
593
|
-
password:
|
|
681
|
+
install_type: config.get("upload", "install_type", default: config.get("pgyer_install_type", default: config.get("pgyer_password", default: "").to_s.empty? ? 1 : 2)),
|
|
682
|
+
password: config.get("pgyer_password"),
|
|
594
683
|
password_env: config.get("upload", "password_env"),
|
|
595
684
|
description: config.get("upload", "description"),
|
|
596
685
|
install_date: config.get("upload", "install_date"),
|
|
597
686
|
start_date: config.get("upload", "start_date"),
|
|
598
687
|
end_date: config.get("upload", "end_date"),
|
|
599
688
|
channel: config.get("upload", "channel"),
|
|
689
|
+
**Webhook::OPTION_FIELDS.to_h { |key| [key, config.get("upload", key, default: config.get(key))] },
|
|
600
690
|
poll_attempts: config.get("upload", "poll_attempts", default: 60),
|
|
601
691
|
upload: false
|
|
602
692
|
}
|
|
@@ -623,8 +713,8 @@ module Appship
|
|
|
623
713
|
opts.on("--no-allow-provisioning-updates", "不允许 xcodebuild 自动更新签名") { options[:allow_provisioning_updates] = false }
|
|
624
714
|
opts.separator ""
|
|
625
715
|
opts.separator "图标角标"
|
|
626
|
-
opts.on("--badge TEXT", "添加角标,例如 DEBUG 或 RELEASE") { |value| options[:badge] = value }
|
|
627
|
-
opts.on("--no-badge", "禁用配置文件里的角标") { options[:badge] = nil }
|
|
716
|
+
opts.on("--badge TEXT", "添加角标,例如 DEBUG 或 RELEASE") { |value| options[:badge] = value; options[:badge_explicit] = true }
|
|
717
|
+
opts.on("--no-badge", "禁用配置文件里的角标") { options[:badge] = nil; options[:badge_explicit] = true }
|
|
628
718
|
opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
|
|
629
719
|
opts.on("--app-icon NAME", "AppIcon 名称(默认 AppIcon)") { |value| options[:app_icon] = value }
|
|
630
720
|
opts.separator ""
|
|
@@ -638,8 +728,8 @@ module Appship
|
|
|
638
728
|
opts.banner = "用法: appship [--config FILE] package [选项]"
|
|
639
729
|
opts.on("--app PATH", "已编译 .app 路径") { |value| options[:app] = value }
|
|
640
730
|
opts.on("--output PATH", "最终 IPA 路径") { |value| options[:output] = value }
|
|
641
|
-
opts.on("--badge TEXT", "添加角标") { |value| options[:badge] = value }
|
|
642
|
-
opts.on("--no-badge", "禁用角标") { options[:badge] = nil }
|
|
731
|
+
opts.on("--badge TEXT", "添加角标") { |value| options[:badge] = value; options[:badge_explicit] = true }
|
|
732
|
+
opts.on("--no-badge", "禁用角标") { options[:badge] = nil; options[:badge_explicit] = true }
|
|
643
733
|
opts.on("--assets PATH", "Assets.xcassets 路径") { |value| options[:assets] = value }
|
|
644
734
|
opts.on("--app-icon NAME", "AppIcon 名称") { |value| options[:app_icon] = value }
|
|
645
735
|
add_upload_options(opts, options)
|
|
@@ -773,7 +863,10 @@ module Appship
|
|
|
773
863
|
appship #{VERSION} - 通用移动应用构建、打包和分发工具
|
|
774
864
|
|
|
775
865
|
用法:
|
|
776
|
-
appship init [--
|
|
866
|
+
appship init [--project-dir PATH]
|
|
867
|
+
appship webhook wechat "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
|
868
|
+
appship webhook dingtalk "https://oapi.dingtalk.com/robot/send?access_token=xxx"
|
|
869
|
+
appship webhook feishu "https://open.feishu.cn/open-apis/bot/v2/hook/xxx"
|
|
777
870
|
appship doctor
|
|
778
871
|
appship pgyer
|
|
779
872
|
appship fir
|
data/lib/appship/config.rb
CHANGED
|
@@ -29,9 +29,11 @@ module Appship
|
|
|
29
29
|
|
|
30
30
|
attr_reader :path, :data
|
|
31
31
|
|
|
32
|
-
def initialize(path = nil)
|
|
32
|
+
def initialize(path = nil, data: nil)
|
|
33
33
|
@path = path && File.expand_path(path)
|
|
34
|
-
@data = if
|
|
34
|
+
@data = if data
|
|
35
|
+
data
|
|
36
|
+
elsif @path && File.file?(@path)
|
|
35
37
|
YAML.safe_load(File.read(@path), permitted_classes: [], aliases: false) || {}
|
|
36
38
|
else
|
|
37
39
|
{}
|
|
@@ -51,6 +53,20 @@ module Appship
|
|
|
51
53
|
def self.find(path = nil)
|
|
52
54
|
return new(path) if path
|
|
53
55
|
|
|
56
|
+
local_path = ProjectConfig.path
|
|
57
|
+
if File.exist?(local_path) || File.symlink?(local_path)
|
|
58
|
+
profile = ProjectConfig.load(ENV.fetch("APPSHIP_ENV_FILE", Cache::DEFAULT_PATH), interactive: false)
|
|
59
|
+
.resolve(interactive: false, credentials: false)
|
|
60
|
+
profile["scheme"] ||= profile["project_name"]
|
|
61
|
+
unless profile["workspace"] || profile["project"]
|
|
62
|
+
workspace = "#{profile["project_name"]}.xcworkspace"
|
|
63
|
+
project = "#{profile["project_name"]}.xcodeproj"
|
|
64
|
+
profile["workspace"] = workspace if File.directory?(workspace)
|
|
65
|
+
profile["project"] = project if !profile["workspace"] && File.directory?(project)
|
|
66
|
+
end
|
|
67
|
+
return new(local_path, data: profile)
|
|
68
|
+
end
|
|
69
|
+
|
|
54
70
|
candidates = [
|
|
55
71
|
File.join(Dir.pwd, ".appship.yml"),
|
|
56
72
|
File.join(Dir.pwd, ".appship.yaml"),
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Appship
|
|
4
|
+
module Message
|
|
5
|
+
VARIABLES = %w[name build_type_name version_info configuration build_size_mb build_updated description password url].freeze
|
|
6
|
+
PLACEHOLDER = /%\{([^}]*)\}/
|
|
7
|
+
CONTENT_FIELDS = %w[
|
|
8
|
+
cli_content
|
|
9
|
+
wechat_webhook_success_content dingtalk_webhook_success_content feishu_webhook_success_content
|
|
10
|
+
wechat_webhook_content dingtalk_webhook_content feishu_webhook_content
|
|
11
|
+
].freeze
|
|
12
|
+
FAILURE_FIELDS = %w[wechat_webhook_failure_content dingtalk_webhook_failure_content feishu_webhook_failure_content].freeze
|
|
13
|
+
FAILURE_VARIABLES = %w[name provider configuration error_type error_message].freeze
|
|
14
|
+
FORMATS = {
|
|
15
|
+
"wechat_webhook_format" => %w[text markdown].freeze,
|
|
16
|
+
"feishu_webhook_format" => %w[text post].freeze
|
|
17
|
+
}.freeze
|
|
18
|
+
INSTRUCTIONS = <<~TEXT.freeze
|
|
19
|
+
# Appship 发布消息模板
|
|
20
|
+
# 复制为 appship/message.yml 后编辑。用 | 开始多行文本,正文缩进两个空格。
|
|
21
|
+
# 成功文案留空则沿用 cli_content;失败文案留空则使用内置失败文案。
|
|
22
|
+
# 失败文案可用变量:name、provider、configuration、error_type、error_message。
|
|
23
|
+
# %{变量名} 会替换为实际数据。缺少可选数据时,会省略对应行。
|
|
24
|
+
#
|
|
25
|
+
# 可用变量:
|
|
26
|
+
# name : 应用名称
|
|
27
|
+
# build_type_name : 应用类型,例如 iOS、Android
|
|
28
|
+
# version_info : 版本信息,例如 1.0.0(42)
|
|
29
|
+
# configuration : 构建配置,例如 Debug、Release
|
|
30
|
+
# build_size_mb : 应用大小,例如 39.5MB
|
|
31
|
+
# build_updated : 更新时间
|
|
32
|
+
# description : 本次更新内容
|
|
33
|
+
# password : 安装密码或密码设置状态
|
|
34
|
+
# url : 应用下载链接
|
|
35
|
+
TEXT
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
def path(project_dir = Dir.pwd)
|
|
40
|
+
File.join(File.expand_path(project_dir), "appship", "message.yml")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def default_path
|
|
44
|
+
File.join(__dir__, "default_message")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def init!(project_dir: Dir.pwd)
|
|
48
|
+
content = INSTRUCTIONS + <<~YAML
|
|
49
|
+
|
|
50
|
+
# 自定义终端文案时,去掉下面整段每行前的 "# ",保留正文缩进。
|
|
51
|
+
# cli_content: |
|
|
52
|
+
# 🚀 %{name} 发布成功
|
|
53
|
+
# 版本:%{version_info}
|
|
54
|
+
# 更新内容:%{description}
|
|
55
|
+
# 下载地址:%{url}
|
|
56
|
+
|
|
57
|
+
# wechat_webhook_success_content: "" # 企微成功文案
|
|
58
|
+
# wechat_webhook_failure_content: "" # 企微失败文案
|
|
59
|
+
|
|
60
|
+
# dingtalk_webhook_success_content: "" # 钉钉成功文案
|
|
61
|
+
# dingtalk_webhook_failure_content: "" # 钉钉失败文案
|
|
62
|
+
|
|
63
|
+
# feishu_webhook_success_content: "" # 飞书成功文案
|
|
64
|
+
# feishu_webhook_failure_content: "" # 飞书失败文案
|
|
65
|
+
|
|
66
|
+
# 默认发送纯文本;成功通知可按平台切换消息格式。
|
|
67
|
+
# wechat_webhook_format: markdown # 企微:text 或 markdown
|
|
68
|
+
# feishu_webhook_format: post # 飞书:text 或 post(富文本)
|
|
69
|
+
YAML
|
|
70
|
+
selected = "#{path(project_dir)}.sample"
|
|
71
|
+
FileUtils.mkdir_p(File.dirname(selected), mode: 0o700)
|
|
72
|
+
File.open(selected, "wx", 0o600) { |file| file.write(content) }
|
|
73
|
+
selected
|
|
74
|
+
rescue Errno::EEXIST
|
|
75
|
+
regular_file!(selected)
|
|
76
|
+
selected
|
|
77
|
+
rescue SystemCallError => e
|
|
78
|
+
raise ConfigurationError, "消息模板初始化失败: #{e.message}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def template
|
|
82
|
+
templates.fetch("cli_content")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def templates
|
|
86
|
+
selected = path
|
|
87
|
+
unless File.exist?(selected) || File.symlink?(selected)
|
|
88
|
+
legacy = File.join(File.dirname(selected), "message")
|
|
89
|
+
selected = File.exist?(legacy) || File.symlink?(legacy) ? legacy : default_path
|
|
90
|
+
regular_file!(selected)
|
|
91
|
+
return { "cli_content" => parse(File.read(selected, encoding: "UTF-8"), selected, legacy: true) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
data = load_yaml(selected)
|
|
95
|
+
unless (data.keys - CONTENT_FIELDS - FAILURE_FIELDS - FORMATS.keys).empty?
|
|
96
|
+
raise ConfigurationError, "消息模板包含未知字段: #{selected}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
data = data.slice(*CONTENT_FIELDS, *FORMATS.keys).reject do |key, value|
|
|
100
|
+
next true if value.nil?
|
|
101
|
+
raise ConfigurationError, "消息模板 #{key} 必须是字符串: #{selected}" unless value.is_a?(String)
|
|
102
|
+
|
|
103
|
+
value.strip.empty?
|
|
104
|
+
end
|
|
105
|
+
FORMATS.each do |key, formats|
|
|
106
|
+
next unless data.key?(key) && !formats.include?(data[key])
|
|
107
|
+
|
|
108
|
+
raise ConfigurationError, "#{key} 仅支持 #{formats.join(" 或 ")}: #{selected}"
|
|
109
|
+
end
|
|
110
|
+
data["cli_content"] ||= File.read(default_path, encoding: "UTF-8")
|
|
111
|
+
CONTENT_FIELDS.each do |key|
|
|
112
|
+
parse(data[key], "#{selected} (#{key})") if data.key?(key)
|
|
113
|
+
end
|
|
114
|
+
data
|
|
115
|
+
rescue SystemCallError => e
|
|
116
|
+
raise ConfigurationError, "消息模板读取失败: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def failure_templates(project_dir: Dir.pwd)
|
|
120
|
+
selected = path(project_dir)
|
|
121
|
+
return {} unless File.exist?(selected) || File.symlink?(selected)
|
|
122
|
+
|
|
123
|
+
load_yaml(selected).slice(*FAILURE_FIELDS).each_with_object({}) do |(key, body), templates|
|
|
124
|
+
next if body.nil? || (body.is_a?(String) && body.strip.empty?)
|
|
125
|
+
raise ConfigurationError unless body.is_a?(String)
|
|
126
|
+
|
|
127
|
+
templates[key] = parse(body, selected, variables: FAILURE_VARIABLES)
|
|
128
|
+
rescue ConfigurationError
|
|
129
|
+
warn "⚠️ #{key} 无效,使用内置失败文案"
|
|
130
|
+
end
|
|
131
|
+
rescue ConfigurationError
|
|
132
|
+
warn "⚠️ 无法读取失败消息模板,使用内置失败文案"
|
|
133
|
+
{}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def load_yaml(selected)
|
|
137
|
+
regular_file!(selected)
|
|
138
|
+
content = File.read(selected, encoding: "UTF-8")
|
|
139
|
+
raise ConfigurationError, "消息模板必须使用 UTF-8 编码: #{selected}" unless content.valid_encoding?
|
|
140
|
+
|
|
141
|
+
data = YAML.safe_load(content, permitted_classes: [], aliases: false)
|
|
142
|
+
return {} if data.nil?
|
|
143
|
+
raise ConfigurationError, "消息模板必须是 YAML 对象: #{selected}" unless data.is_a?(Hash)
|
|
144
|
+
|
|
145
|
+
data
|
|
146
|
+
rescue Psych::Exception
|
|
147
|
+
raise ConfigurationError, "消息模板 YAML 格式无效: #{selected}"
|
|
148
|
+
rescue SystemCallError => e
|
|
149
|
+
raise ConfigurationError, "消息模板读取失败: #{e.message}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def parse(content, source, legacy: false, variables: VARIABLES)
|
|
153
|
+
raise ConfigurationError, "消息模板必须使用 UTF-8 编码: #{source}" unless content.valid_encoding?
|
|
154
|
+
|
|
155
|
+
body = content.delete_prefix("\uFEFF")
|
|
156
|
+
if legacy && body.lstrip.start_with?("/*")
|
|
157
|
+
comment_end = body.index("*/")
|
|
158
|
+
raise ConfigurationError, "消息模板备注缺少 */: #{source}" unless comment_end
|
|
159
|
+
|
|
160
|
+
body = body[(comment_end + 2)..].sub(/\A[ \t]*\r?\n/, "")
|
|
161
|
+
end
|
|
162
|
+
raise ConfigurationError, "消息模板正文不能为空: #{source}" if body.strip.empty?
|
|
163
|
+
|
|
164
|
+
unknown = body.scan(PLACEHOLDER).flatten.uniq - variables
|
|
165
|
+
unless unknown.empty?
|
|
166
|
+
raise ConfigurationError, "消息模板包含未知变量 #{unknown.join(", ")}: #{source}\n可用变量: #{variables.join(", ")}"
|
|
167
|
+
end
|
|
168
|
+
if body.gsub(PLACEHOLDER, "").include?("%{")
|
|
169
|
+
raise ConfigurationError, "消息模板变量缺少右花括号 }: #{source}"
|
|
170
|
+
end
|
|
171
|
+
body
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def render(values)
|
|
175
|
+
render_text(template, values)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def render_text(body, values)
|
|
179
|
+
body.lines.filter_map do |line|
|
|
180
|
+
keys = line.scan(PLACEHOLDER).flatten
|
|
181
|
+
next if keys.any? { |key| values.fetch(key.to_sym).nil? }
|
|
182
|
+
|
|
183
|
+
line.gsub(PLACEHOLDER) { values.fetch(Regexp.last_match(1).to_sym).to_s }
|
|
184
|
+
end.join
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def regular_file!(file)
|
|
188
|
+
unless File.file?(file) && !File.symlink?(file)
|
|
189
|
+
raise ConfigurationError, "消息模板必须是普通文件: #{file}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|